From 40954172964a61d802b307168ff3b499be36f5ef Mon Sep 17 00:00:00 2001 From: HuiDong Chen Date: Wed, 24 Jun 2026 09:18:36 +0800 Subject: [PATCH 001/139] Bump newton version pin to ``79e95bf5571d70a0a46c8eaedc80644531d27368`` (#6242) # Description Fixed the `newton[sim]` dependency pin to use Newton commit `79e95bf5571d70a0a46c8eaedc80644531d27368`, including the `RenderContext` triangle-mesh construction fix from [newton-physics/newton#3199](https://github.com/newton-physics/newton/pull/3199). ## 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 --- source/isaaclab/changelog.d/bump-newton-pin.rst | 7 +++++++ source/isaaclab_newton/changelog.d/bump-newton-pin.rst | 7 +++++++ source/isaaclab_newton/pyproject.toml | 2 +- source/isaaclab_physx/changelog.d/bump-newton-pin.rst | 7 +++++++ source/isaaclab_physx/pyproject.toml | 2 +- .../isaaclab_visualizers/changelog.d/bump-newton-pin.rst | 7 +++++++ source/isaaclab_visualizers/pyproject.toml | 8 ++++---- tools/wheel_builder/res/python_packages.toml | 2 +- 8 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 source/isaaclab/changelog.d/bump-newton-pin.rst create mode 100644 source/isaaclab_newton/changelog.d/bump-newton-pin.rst create mode 100644 source/isaaclab_physx/changelog.d/bump-newton-pin.rst create mode 100644 source/isaaclab_visualizers/changelog.d/bump-newton-pin.rst diff --git a/source/isaaclab/changelog.d/bump-newton-pin.rst b/source/isaaclab/changelog.d/bump-newton-pin.rst new file mode 100644 index 00000000000..90a5b53e3bc --- /dev/null +++ b/source/isaaclab/changelog.d/bump-newton-pin.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed the wheel-builder ``newton[sim]`` dependency pin to use Newton commit + ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the + RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 + `_. diff --git a/source/isaaclab_newton/changelog.d/bump-newton-pin.rst b/source/isaaclab_newton/changelog.d/bump-newton-pin.rst new file mode 100644 index 00000000000..e38454e5107 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/bump-newton-pin.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed the ``newton[sim]`` dependency pin to use Newton commit + ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the + RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 + `_. diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index 880c175855d..a59ec501540 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -27,7 +27,7 @@ all = [ "prettytable>=3.3.0", "PyOpenGL-accelerate>=3.1.0", "pyglet>=2.1.6,<3", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@811968bfb7cc7ff4e37b9260a2ba56930a3e605e", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", ] [tool.setuptools] diff --git a/source/isaaclab_physx/changelog.d/bump-newton-pin.rst b/source/isaaclab_physx/changelog.d/bump-newton-pin.rst new file mode 100644 index 00000000000..676868e37a8 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/bump-newton-pin.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed the optional ``newton[sim]`` dependency pin to use Newton commit + ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the + RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 + `_. diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index f63c83809f9..ffc37e686cf 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -24,7 +24,7 @@ Repository = "https://github.com/isaac-sim/IsaacLab" [project.optional-dependencies] newton = [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@811968bfb7cc7ff4e37b9260a2ba56930a3e605e", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", ] [tool.setuptools] diff --git a/source/isaaclab_visualizers/changelog.d/bump-newton-pin.rst b/source/isaaclab_visualizers/changelog.d/bump-newton-pin.rst new file mode 100644 index 00000000000..d8cb1021d48 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/bump-newton-pin.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed the visualizer extras' ``newton[sim]`` dependency pin to use Newton + commit ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the + RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 + `_. diff --git a/source/isaaclab_visualizers/pyproject.toml b/source/isaaclab_visualizers/pyproject.toml index 9b0aeefcf3e..1dda46c96e2 100644 --- a/source/isaaclab_visualizers/pyproject.toml +++ b/source/isaaclab_visualizers/pyproject.toml @@ -29,24 +29,24 @@ Repository = "https://github.com/isaac-sim/IsaacLab" kit = [] newton = [ "warp-lang", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@811968bfb7cc7ff4e37b9260a2ba56930a3e605e", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", "PyOpenGL-accelerate", "pyglet>=2.1.6,<3", "imgui-bundle>=1.92.5", "typing-extensions>=4.15.0", ] rerun = [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@811968bfb7cc7ff4e37b9260a2ba56930a3e605e", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", "rerun-sdk>=0.29.0", "pyarrow==22.0.0", ] viser = [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@811968bfb7cc7ff4e37b9260a2ba56930a3e605e", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", "viser>=1.0.16", ] all = [ "imgui-bundle>=1.92.5", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@811968bfb7cc7ff4e37b9260a2ba56930a3e605e", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", "PyOpenGL-accelerate", "pyglet>=2.1.6,<3", # Match rerun-sdk's supported Arrow stack and avoid resolver drift across environments. diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index db104ce02f0..72926c0a93d 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -88,7 +88,7 @@ pyproject.optional-dependencies.all = [ # ================================================================================ { "newton" = [ "warp-lang==1.14.0", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@811968bfb7cc7ff4e37b9260a2ba56930a3e605e", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", "PyOpenGL-accelerate==3.1.10" ] }, # ================================================================================ From 1013ef9e4418e98d55968a1e3b93704ee5dd9238 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:24:00 +0000 Subject: [PATCH 002/139] [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: 7.0.5 → 7.1.0 - isaaclab_newton: 1.0.4 → 1.1.0 - isaaclab_ov: 0.4.6 → 0.5.0 - isaaclab_physx: 2.0.2 → 2.1.0 - isaaclab_tasks: 8.0.4 → 8.0.5 - isaaclab_visualizers: 1.0.1 → 1.0.2 --- .../isaaclab/changelog.d/bump-newton-pin.rst | 7 ----- .../changelog.d/jmart-cartpole-rtx.minor.rst | 12 ------- .../isaaclab/changelog.d/jmart-dr-reset.rst | 9 ------ source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 31 +++++++++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../changelog.d/bump-newton-pin.rst | 7 ----- .../changelog.d/jmart-cartpole-rtx.minor.rst | 7 ----- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 20 ++++++++++++ source/isaaclab_newton/pyproject.toml | 2 +- .../changelog.d/jmart-cartpole-rtx.minor.rst | 6 ---- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 11 +++++++ source/isaaclab_ov/pyproject.toml | 2 +- .../changelog.d/bump-newton-pin.rst | 7 ----- .../changelog.d/jmart-cartpole-rtx.minor.rst | 6 ---- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 19 ++++++++++++ source/isaaclab_physx/pyproject.toml | 2 +- .../changelog.d/jmart-cartpole-rtx.rst | 13 -------- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 18 +++++++++++ source/isaaclab_tasks/pyproject.toml | 2 +- .../changelog.d/bump-newton-pin.rst | 7 ----- .../config/extension.toml | 2 +- .../isaaclab_visualizers/docs/CHANGELOG.rst | 12 +++++++ source/isaaclab_visualizers/pyproject.toml | 2 +- 28 files changed, 123 insertions(+), 93 deletions(-) delete mode 100644 source/isaaclab/changelog.d/bump-newton-pin.rst delete mode 100644 source/isaaclab/changelog.d/jmart-cartpole-rtx.minor.rst delete mode 100644 source/isaaclab/changelog.d/jmart-dr-reset.rst delete mode 100644 source/isaaclab_newton/changelog.d/bump-newton-pin.rst delete mode 100644 source/isaaclab_newton/changelog.d/jmart-cartpole-rtx.minor.rst delete mode 100644 source/isaaclab_ov/changelog.d/jmart-cartpole-rtx.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/bump-newton-pin.rst delete mode 100644 source/isaaclab_physx/changelog.d/jmart-cartpole-rtx.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/jmart-cartpole-rtx.rst delete mode 100644 source/isaaclab_visualizers/changelog.d/bump-newton-pin.rst diff --git a/source/isaaclab/changelog.d/bump-newton-pin.rst b/source/isaaclab/changelog.d/bump-newton-pin.rst deleted file mode 100644 index 90a5b53e3bc..00000000000 --- a/source/isaaclab/changelog.d/bump-newton-pin.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed the wheel-builder ``newton[sim]`` dependency pin to use Newton commit - ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the - RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 - `_. diff --git a/source/isaaclab/changelog.d/jmart-cartpole-rtx.minor.rst b/source/isaaclab/changelog.d/jmart-cartpole-rtx.minor.rst deleted file mode 100644 index c7b1de77a76..00000000000 --- a/source/isaaclab/changelog.d/jmart-cartpole-rtx.minor.rst +++ /dev/null @@ -1,12 +0,0 @@ -Added -^^^^^ - -* Added the :meth:`~isaaclab.physics.physics_manager.PhysicsManager.provides_implicit_damping` and - :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.provides_temporal_camera_data` capability - classmethods on the runtime backend bases, so physics and renderer backends declare whether a - camera observation carries the temporal information a policy needs to infer velocity (used to - decide frame stacking). Base defaults: physics has implicit damping (``True``); a renderer - provides no temporal data (``False``). -* Added :meth:`~isaaclab.renderers.Renderer.resolve_class` to resolve a renderer's implementation - class from its configuration without instantiating it (so tasks can query the above classmethod - before a simulation exists). diff --git a/source/isaaclab/changelog.d/jmart-dr-reset.rst b/source/isaaclab/changelog.d/jmart-dr-reset.rst deleted file mode 100644 index 9088b9800e1..00000000000 --- a/source/isaaclab/changelog.d/jmart-dr-reset.rst +++ /dev/null @@ -1,9 +0,0 @@ -Fixed -^^^^^ - -* Fixed a crash where :class:`~isaaclab.envs.mdp.randomize_visual_color` used in ``reset`` mode - raised ``AttributeError: 'NoneType' object has no attribute 'link_count'`` during environment - startup on the PhysX backend. The randomizer authored USD (``SetInstanceable`` and material - binding) on the articulation root prim, which invalidated the PhysX articulation view so that - the subsequent at-play body-name resolution dereferenced a ``None`` metatype. It now scopes to - descendant visual prims, mirroring :class:`~isaaclab.envs.mdp.randomize_visual_texture_material`. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 6fd0197bebd..bb4ba3ffbdf 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 = "7.0.5" +version = "7.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 ba7727cba56..b08a87ffdea 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,37 @@ Changelog --------- +7.1.0 (2026-06-24) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the :meth:`~isaaclab.physics.physics_manager.PhysicsManager.provides_implicit_damping` and + :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.provides_temporal_camera_data` capability + classmethods on the runtime backend bases, so physics and renderer backends declare whether a + camera observation carries the temporal information a policy needs to infer velocity (used to + decide frame stacking). Base defaults: physics has implicit damping (``True``); a renderer + provides no temporal data (``False``). +* Added :meth:`~isaaclab.renderers.Renderer.resolve_class` to resolve a renderer's implementation + class from its configuration without instantiating it (so tasks can query the above classmethod + before a simulation exists). + +Fixed +^^^^^ + +* Fixed a crash where :class:`~isaaclab.envs.mdp.randomize_visual_color` used in ``reset`` mode + raised ``AttributeError: 'NoneType' object has no attribute 'link_count'`` during environment + startup on the PhysX backend. The randomizer authored USD (``SetInstanceable`` and material + binding) on the articulation root prim, which invalidated the PhysX articulation view so that + the subsequent at-play body-name resolution dereferenced a ``None`` metatype. It now scopes to + descendant visual prims, mirroring :class:`~isaaclab.envs.mdp.randomize_visual_texture_material`. +* Fixed the wheel-builder ``newton[sim]`` dependency pin to use Newton commit + ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the + RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 + `_. + + 7.0.5 (2026-06-23) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 03511cf9f6d..e3169ab0dc3 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "7.0.5" +version = "7.1.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_newton/changelog.d/bump-newton-pin.rst b/source/isaaclab_newton/changelog.d/bump-newton-pin.rst deleted file mode 100644 index e38454e5107..00000000000 --- a/source/isaaclab_newton/changelog.d/bump-newton-pin.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed the ``newton[sim]`` dependency pin to use Newton commit - ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the - RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 - `_. diff --git a/source/isaaclab_newton/changelog.d/jmart-cartpole-rtx.minor.rst b/source/isaaclab_newton/changelog.d/jmart-cartpole-rtx.minor.rst deleted file mode 100644 index fc7a261e769..00000000000 --- a/source/isaaclab_newton/changelog.d/jmart-cartpole-rtx.minor.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Overrode :meth:`provides_implicit_damping` on :class:`NewtonManager` to return ``False`` (its - symplectic integrator has no implicit damping) and :meth:`provides_temporal_camera_data` on - :class:`NewtonWarpRenderer` to return ``False`` (the rasterizer accumulates no temporal data), - so camera tasks can auto-enable frame stacking for the Newton combos that need it. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index e634a251cfc..d2e4ba3d446 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 = "1.0.4" +version = "1.1.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 1e92f80043e..45dfdd21f71 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,26 @@ Changelog --------- +1.1.0 (2026-06-24) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Overrode :meth:`provides_implicit_damping` on :class:`NewtonManager` to return ``False`` (its + symplectic integrator has no implicit damping) and :meth:`provides_temporal_camera_data` on + :class:`NewtonWarpRenderer` to return ``False`` (the rasterizer accumulates no temporal data), + so camera tasks can auto-enable frame stacking for the Newton combos that need it. + +Fixed +^^^^^ + +* Fixed the ``newton[sim]`` dependency pin to use Newton commit + ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the + RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 + `_. + + 1.0.4 (2026-06-23) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index a59ec501540..68c9fbae4eb 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_newton" -version = "1.0.4" +version = "1.1.0" description = "Extension providing IsaacLab with Newton specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ov/changelog.d/jmart-cartpole-rtx.minor.rst b/source/isaaclab_ov/changelog.d/jmart-cartpole-rtx.minor.rst deleted file mode 100644 index 86cfb672bf0..00000000000 --- a/source/isaaclab_ov/changelog.d/jmart-cartpole-rtx.minor.rst +++ /dev/null @@ -1,6 +0,0 @@ -Added -^^^^^ - -* Overrode :meth:`provides_temporal_camera_data` on :class:`OVRTXRenderer` to return ``True`` - only for the ``rgb``/``rgba`` beauty buffer (temporally accumulated by DLSS), matching Isaac RTX; - other AOVs return ``False``. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index e4ec845231d..5c240c1cf43 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.4.6" +version = "0.5.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 4307aa7707a..b6bc15626d6 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,17 @@ Changelog --------- +0.5.0 (2026-06-24) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Overrode :meth:`provides_temporal_camera_data` on :class:`OVRTXRenderer` to return ``True`` + only for the ``rgb``/``rgba`` beauty buffer (temporally accumulated by DLSS), matching Isaac RTX; + other AOVs return ``False``. + + 0.4.6 (2026-06-16) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/pyproject.toml b/source/isaaclab_ov/pyproject.toml index e24cd26ee96..6a8b4704efd 100644 --- a/source/isaaclab_ov/pyproject.toml +++ b/source/isaaclab_ov/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ov" -version = "0.4.6" +version = "0.5.0" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/bump-newton-pin.rst b/source/isaaclab_physx/changelog.d/bump-newton-pin.rst deleted file mode 100644 index 676868e37a8..00000000000 --- a/source/isaaclab_physx/changelog.d/bump-newton-pin.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed the optional ``newton[sim]`` dependency pin to use Newton commit - ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the - RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 - `_. diff --git a/source/isaaclab_physx/changelog.d/jmart-cartpole-rtx.minor.rst b/source/isaaclab_physx/changelog.d/jmart-cartpole-rtx.minor.rst deleted file mode 100644 index 1f2a1c956e4..00000000000 --- a/source/isaaclab_physx/changelog.d/jmart-cartpole-rtx.minor.rst +++ /dev/null @@ -1,6 +0,0 @@ -Added -^^^^^ - -* Overrode :meth:`provides_temporal_camera_data` on :class:`IsaacRtxRenderer` to return ``True`` - only for the ``rgb``/``rgba`` beauty buffer (temporally accumulated by DLSS); the depth, albedo, - simple_shading, and segmentation AOVs return ``False`` as they bypass DLSS. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 519859d53fc..1640fb94caf 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 = "2.0.2" +version = "2.1.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 6f7c55b3b0b..a503a8c4ca4 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,25 @@ Changelog --------- +2.1.0 (2026-06-24) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Overrode :meth:`provides_temporal_camera_data` on :class:`IsaacRtxRenderer` to return ``True`` + only for the ``rgb``/``rgba`` beauty buffer (temporally accumulated by DLSS); the depth, albedo, + simple_shading, and segmentation AOVs return ``False`` as they bypass DLSS. + +Fixed +^^^^^ + +* Fixed the optional ``newton[sim]`` dependency pin to use Newton commit + ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the + RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 + `_. + + 2.0.2 (2026-06-17) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index ffc37e686cf..305c6d17d1b 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "2.0.2" +version = "2.1.0" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/jmart-cartpole-rtx.rst b/source/isaaclab_tasks/changelog.d/jmart-cartpole-rtx.rst deleted file mode 100644 index c25a345722f..00000000000 --- a/source/isaaclab_tasks/changelog.d/jmart-cartpole-rtx.rst +++ /dev/null @@ -1,13 +0,0 @@ -Fixed -^^^^^ - -* Fixed the camera-based Cartpole task failing to converge under Newton physics with the RTX - ``depth``, ``albedo``, and ``simple_shading`` AOV observations. These AOVs bypass DLSS temporal - accumulation, so the observation carried no temporal cue for the policy to infer velocity from - (Newton's symplectic integrator has no implicit damping). The ``frame_stack`` default resolver - now enables 2-frame stacking for these Newton + RTX AOVs, matching the existing Newton + Warp - behavior; Newton + RTX ``rgb`` keeps single-frame observations as DLSS already supplies the cue. - The resolver reads backend capability classmethods - (:meth:`~isaaclab.physics.physics_manager.PhysicsManager.provides_implicit_damping`, - :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.provides_temporal_camera_data`) resolved - from the configs, instead of hard-coding backend types in the task. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 781b51f6cca..e05d772e49a 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 = "8.0.4" +version = "8.0.5" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 540363429b2..aff3e0f1909 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,24 @@ Changelog --------- +8.0.5 (2026-06-24) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed the camera-based Cartpole task failing to converge under Newton physics with the RTX + ``depth``, ``albedo``, and ``simple_shading`` AOV observations. These AOVs bypass DLSS temporal + accumulation, so the observation carried no temporal cue for the policy to infer velocity from + (Newton's symplectic integrator has no implicit damping). The ``frame_stack`` default resolver + now enables 2-frame stacking for these Newton + RTX AOVs, matching the existing Newton + Warp + behavior; Newton + RTX ``rgb`` keeps single-frame observations as DLSS already supplies the cue. + The resolver reads backend capability classmethods + (:meth:`~isaaclab.physics.physics_manager.PhysicsManager.provides_implicit_damping`, + :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.provides_temporal_camera_data`) resolved + from the configs, instead of hard-coding backend types in the task. + + 8.0.4 (2026-06-23) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index 988ef496692..00bf52e4cf6 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.0.4" +version = "8.0.5" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_visualizers/changelog.d/bump-newton-pin.rst b/source/isaaclab_visualizers/changelog.d/bump-newton-pin.rst deleted file mode 100644 index d8cb1021d48..00000000000 --- a/source/isaaclab_visualizers/changelog.d/bump-newton-pin.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed the visualizer extras' ``newton[sim]`` dependency pin to use Newton - commit ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the - RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 - `_. diff --git a/source/isaaclab_visualizers/config/extension.toml b/source/isaaclab_visualizers/config/extension.toml index e02c29a67ca..4fe46a909a0 100644 --- a/source/isaaclab_visualizers/config/extension.toml +++ b/source/isaaclab_visualizers/config/extension.toml @@ -1,7 +1,7 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "1.0.1" +version = "1.0.2" # Description category = "isaaclab" diff --git a/source/isaaclab_visualizers/docs/CHANGELOG.rst b/source/isaaclab_visualizers/docs/CHANGELOG.rst index cd4c8d8a641..a6860528227 100644 --- a/source/isaaclab_visualizers/docs/CHANGELOG.rst +++ b/source/isaaclab_visualizers/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +1.0.2 (2026-06-24) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed the visualizer extras' ``newton[sim]`` dependency pin to use Newton + commit ``79e95bf5571d70a0a46c8eaedc80644531d27368``, including the + RenderContext triangle-mesh construction fix from `newton-physics/newton#3199 + `_. + + 1.0.1 (2026-06-14) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_visualizers/pyproject.toml b/source/isaaclab_visualizers/pyproject.toml index 1dda46c96e2..970de1d7942 100644 --- a/source/isaaclab_visualizers/pyproject.toml +++ b/source/isaaclab_visualizers/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab-visualizers" -version = "1.0.1" +version = "1.0.2" description = "Visualizer backends for Isaac Lab (Kit, Newton, Rerun, Viser)." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From e2d279904e9d26a8845d2d622d411853031daa66 Mon Sep 17 00:00:00 2001 From: rilei-nvidia Date: Wed, 24 Jun 2026 21:03:34 +1200 Subject: [PATCH 003/139] Add distance_to_camera and distance_to_image_plane camera presets to cartpole, dexsuite_kuka, and shadow_hand with test coverage (#6101) # Description Add distance_to_camera and distance_to_image_plane camera presets to cartpole, dexsuite_kuka, and shadow_hand with test coverage. ## 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/extend-test-distance-aovs.rst | 6 ++ .../isaaclab_ov/renderers/ovrtx_renderer.py | 2 +- .../isaaclab_ov/renderers/ovrtx_usd.py | 6 +- .../changelog.d/extend-test-distance-aovs.rst | 7 ++ .../shadow_hand/shadow_hand_camera_env_cfg.py | 5 +- ...aacsim_rtx_renderer-distance_to_camera.png | 3 + ...m_rtx_renderer-distance_to_image_plane.png | 3 + ...wton-ovrtx_renderer-distance_to_camera.png | 3 + ...ovrtx_renderer-distance_to_image_plane.png | 3 + ...hysx-ovrtx_renderer-distance_to_camera.png | 3 + ...ovrtx_renderer-distance_to_image_plane.png | 3 + ...aacsim_rtx_renderer-distance_to_camera.png | 3 + ...m_rtx_renderer-distance_to_image_plane.png | 3 + ...aacsim_rtx_renderer-distance_to_camera.png | 3 + ...m_rtx_renderer-distance_to_image_plane.png | 3 + ...wton-ovrtx_renderer-distance_to_camera.png | 3 + ...ovrtx_renderer-distance_to_image_plane.png | 3 + ...aacsim_rtx_renderer-distance_to_camera.png | 3 + ...m_rtx_renderer-distance_to_image_plane.png | 3 + ...aacsim_rtx_renderer-distance_to_camera.png | 3 + ...m_rtx_renderer-distance_to_image_plane.png | 3 + ...wton-ovrtx_renderer-distance_to_camera.png | 3 + ...ovrtx_renderer-distance_to_image_plane.png | 3 + ...aacsim_rtx_renderer-distance_to_camera.png | 3 + ...m_rtx_renderer-distance_to_image_plane.png | 3 + ...aacsim_rtx_renderer-distance_to_camera.png | 3 + ...m_rtx_renderer-distance_to_image_plane.png | 3 + ...wton-ovrtx_renderer-distance_to_camera.png | 3 + ...ovrtx_renderer-distance_to_image_plane.png | 3 + ...aacsim_rtx_renderer-distance_to_camera.png | 3 + ...m_rtx_renderer-distance_to_image_plane.png | 3 + .../test/rendering_test_utils.py | 84 +++++++++++++++++-- 32 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/extend-test-distance-aovs.rst create mode 100644 source/isaaclab_tasks/changelog.d/extend-test-distance-aovs.rst create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-distance_to_image_plane.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-distance_to_image_plane.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-distance_to_image_plane.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-distance_to_image_plane.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-distance_to_image_plane.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-distance_to_image_plane.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-distance_to_image_plane.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-distance_to_image_plane.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-distance_to_image_plane.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-distance_to_image_plane.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-distance_to_image_plane.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-distance_to_image_plane.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-distance_to_camera.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-distance_to_image_plane.png diff --git a/source/isaaclab_ov/changelog.d/extend-test-distance-aovs.rst b/source/isaaclab_ov/changelog.d/extend-test-distance-aovs.rst new file mode 100644 index 00000000000..e2a7880fc16 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/extend-test-distance-aovs.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed ``distance_to_camera`` incorrectly mapping to ``DistanceToImagePlaneSD`` in the OVRTX + renderer backend. It now correctly uses ``DistanceToCameraSD``, matching the intended semantics + of eye-space ray length versus perpendicular image-plane distance. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 08d5855f33c..359e1c4bd36 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -757,7 +757,7 @@ def _process_render_frame(self, render_data: OVRTXRenderData, frame, output_buff tiled_data = wp.from_dlpack(mapping.tensor) self._extract_rgba_tiles(render_data, tiled_data, output_buffers, buffer_key) - for depth_var in ["DistanceToImagePlaneSD", "DepthSD"]: + for depth_var in ["DistanceToCameraSD", "DistanceToImagePlaneSD", "DepthSD"]: if depth_var not in frame.render_vars: continue with frame.render_vars[depth_var].map(device=Device.CUDA) as mapping: diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py index e774703dc68..be91d46f14e 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py @@ -18,13 +18,17 @@ def get_render_var_config(data_types: list[str]) -> tuple[str, str, str]: """Return (render_var_path, render_var_name, source_name) from data_types.""" use_depth = any(dt in ["depth", "distance_to_image_plane", "distance_to_camera"] for dt in data_types) + use_distance_to_camera = "distance_to_camera" in data_types and not any( + dt in ["depth", "distance_to_image_plane"] for dt in data_types + ) use_albedo = "albedo" in data_types use_semantic = "semantic_segmentation" in data_types use_rgb = any(dt in ["rgb", "rgba"] for dt in data_types) use_hdr = "rgb_hdr" in data_types if use_depth and not (use_rgb or use_albedo or use_semantic): - return "/Render/Vars/depth", "depth", "DistanceToImagePlaneSD" + source = "DistanceToCameraSD" if use_distance_to_camera else "DistanceToImagePlaneSD" + return "/Render/Vars/depth", "depth", source if use_albedo and not (use_rgb or use_semantic): return "/Render/Vars/albedo", "albedo", "DiffuseAlbedoSD" if use_semantic and not (use_rgb or use_albedo): diff --git a/source/isaaclab_tasks/changelog.d/extend-test-distance-aovs.rst b/source/isaaclab_tasks/changelog.d/extend-test-distance-aovs.rst new file mode 100644 index 00000000000..735bf4d2427 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/extend-test-distance-aovs.rst @@ -0,0 +1,7 @@ +Added +^^^^^ + +* Added golden-image rendering tests for ``distance_to_camera`` and ``distance_to_image_plane`` + AOV types across the Cartpole, DexSuite Kuka, and Shadow Hand camera environments. + Test-local subclasses of the relevant env and camera configs are used so the production + task API remains unchanged. diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env_cfg.py index 9811bfa8526..55066df4e8d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env_cfg.py @@ -134,7 +134,10 @@ def validate_config(self): "Choose a compatible preset, e.g. presets=newton_renderer,rgb." ) - if set(self.tiled_camera.data_types) == {"depth"} and self.feature_extractor.enabled: + non_depth_data_types = set(self.tiled_camera.data_types).difference( + {"depth", "distance_to_image_plane", "distance_to_camera"} + ) + if self.tiled_camera.data_types and not non_depth_data_types and self.feature_extractor.enabled: raise ValueError( "Depth-only camera data type is intended for benchmarking only. " "The keypoint-regression CNN cannot be meaningfully trained from depth alone. " diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..0a26486d7f8 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8929af0b155ac73e4dd74dd70dbc502fb78bd962248e5c46f88972a0a7e3e80e +size 538 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..0387686a7a7 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea4e6ba2251666e4df0c937fbd123489f2a06939bda9e674d5f84eb5c8831f7d +size 422 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..0a26486d7f8 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8929af0b155ac73e4dd74dd70dbc502fb78bd962248e5c46f88972a0a7e3e80e +size 538 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..0387686a7a7 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea4e6ba2251666e4df0c937fbd123489f2a06939bda9e674d5f84eb5c8831f7d +size 422 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..0a26486d7f8 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8929af0b155ac73e4dd74dd70dbc502fb78bd962248e5c46f88972a0a7e3e80e +size 538 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..0387686a7a7 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea4e6ba2251666e4df0c937fbd123489f2a06939bda9e674d5f84eb5c8831f7d +size 422 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..0a26486d7f8 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8929af0b155ac73e4dd74dd70dbc502fb78bd962248e5c46f88972a0a7e3e80e +size 538 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..0387686a7a7 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea4e6ba2251666e4df0c937fbd123489f2a06939bda9e674d5f84eb5c8831f7d +size 422 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..a4626ba44f3 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41ecbb1dbb608dbc9aa4b172fcbff57333a741e36b85ea937930b593eba774c1 +size 588 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..d823d5d9699 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f401da10698c1025957c57b8ca5ee359dab0449a78613941e3d9841df595794 +size 568 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..d823d5d9699 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f401da10698c1025957c57b8ca5ee359dab0449a78613941e3d9841df595794 +size 568 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..d823d5d9699 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f401da10698c1025957c57b8ca5ee359dab0449a78613941e3d9841df595794 +size 568 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..a4626ba44f3 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41ecbb1dbb608dbc9aa4b172fcbff57333a741e36b85ea937930b593eba774c1 +size 588 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..d823d5d9699 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f401da10698c1025957c57b8ca5ee359dab0449a78613941e3d9841df595794 +size 568 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..d87fea10d44 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:084f049a7b700ea75716f77e0c80e307c38f414b5da68be34ebad3c1ebe453a9 +size 551 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..bf4b0e0290d --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c003b10810539f538464992860c74ee3bf531b8b4e9b6e0ebe84041d42dba643 +size 532 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..bf4b0e0290d --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c003b10810539f538464992860c74ee3bf531b8b4e9b6e0ebe84041d42dba643 +size 532 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..bf4b0e0290d --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c003b10810539f538464992860c74ee3bf531b8b4e9b6e0ebe84041d42dba643 +size 532 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..d87fea10d44 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:084f049a7b700ea75716f77e0c80e307c38f414b5da68be34ebad3c1ebe453a9 +size 551 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..bf4b0e0290d --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c003b10810539f538464992860c74ee3bf531b8b4e9b6e0ebe84041d42dba643 +size 532 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..2b472cbaac9 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b991b49bc8061c5cb0fa33f4f940547c532919179c7c7fa384b8c6c796dfa949 +size 4039 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..3893ecf7729 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d249747e56b41ada8e507c138979791ef6f558043eb23c9f65d6826220eac5f1 +size 3752 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..2b472cbaac9 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b991b49bc8061c5cb0fa33f4f940547c532919179c7c7fa384b8c6c796dfa949 +size 4039 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..3893ecf7729 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d249747e56b41ada8e507c138979791ef6f558043eb23c9f65d6826220eac5f1 +size 3752 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-distance_to_camera.png new file mode 100644 index 00000000000..0987d3f567e --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-distance_to_camera.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ace1d37f1fa54bd692808ead80ef19470aa3da2efd4c43d7ef1f3ed1b3155bf7 +size 4025 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-distance_to_image_plane.png new file mode 100644 index 00000000000..a38beb8f053 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-distance_to_image_plane.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ddd7a52ad1de3f636d29d53bb156d04ac222d1318fc9574617712b53d796508 +size 3681 diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index 2e35bc1f0d5..6fd98dbbd9c 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -83,6 +83,8 @@ "simple_shading_full_mdl", "semantic_segmentation", "depth", + "distance_to_camera", + "distance_to_image_plane", ) @@ -621,12 +623,27 @@ def rendering_test_shadow_hand( if physics_backend == "ovphysx": pytest.skip("ovphysx is not supported yet.") + from isaaclab.utils.configclass import configclass + from isaaclab_tasks.core.reorient.config.shadow_hand.shadow_hand_camera_env import ShadowHandCameraEnv - from isaaclab_tasks.core.reorient.config.shadow_hand.shadow_hand_camera_env_cfg import ShadowHandCameraEnvCfg + from isaaclab_tasks.core.reorient.config.shadow_hand.shadow_hand_camera_env_cfg import ( + ShadowHandCameraEnvCfg, + ShadowHandTiledCameraCfg, + _ShadowHandBaseTiledCameraCfg, + ) + + @configclass + class _ShadowHandTiledCameraTestCfg(ShadowHandTiledCameraCfg): + distance_to_camera = _ShadowHandBaseTiledCameraCfg(data_types=["distance_to_camera"]) + distance_to_image_plane = _ShadowHandBaseTiledCameraCfg(data_types=["distance_to_image_plane"]) + + @configclass + class _ShadowHandCameraTestEnvCfg(ShadowHandCameraEnvCfg): + tiled_camera = _ShadowHandTiledCameraTestCfg() override_args = [f"presets={_physics_preset_name(physics_backend)},{renderer},{data_type}"] - env_cfg = ShadowHandCameraEnvCfg() + env_cfg = _ShadowHandCameraTestEnvCfg() env_cfg = _apply_overrides_to_env_cfg(env_cfg, override_args) env_cfg.scene.num_envs = 4 @@ -634,7 +651,7 @@ def rendering_test_shadow_hand( if renderer == "ovrtx_renderer": _redirect_ovrtx_renderer_log_to_stdout(env_cfg) - if data_type == "depth": + if data_type in {"depth", "distance_to_camera", "distance_to_image_plane"}: # Disable CNN forward pass as it cannot be meaningfully trained from depth alone and will raise a ValueError. env_cfg.feature_extractor.enabled = False @@ -667,10 +684,28 @@ def rendering_test_cartpole( data_type: str, comparison_scores: list[dict], ) -> None: + from isaaclab.utils.configclass import configclass + from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env import CartpoleCameraEnv - from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env_cfg import CartpoleCameraEnvCfg + from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env_cfg import CartpoleCameraEnvCfg, CartpoleTiledCameraCfg + + @configclass + class _CartpoleTiledCameraTestCfg(CartpoleTiledCameraCfg): + distance_to_camera = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg(data_types=["distance_to_camera"]) + distance_to_image_plane = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg( + data_types=["distance_to_image_plane"] + ) + + @configclass + class _CartpoleCameraTestEnvCfg(CartpoleCameraEnvCfg): + distance_to_camera = CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg( + observation_space=[1, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() + ) + distance_to_image_plane = CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg( + observation_space=[1, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() + ) - env_cfg = CartpoleCameraEnvCfg() + env_cfg = _CartpoleCameraTestEnvCfg() env_cfg = _apply_overrides_to_env_cfg( env_cfg, [f"presets={_physics_preset_name(physics_backend)},{renderer},{data_type}"] ) @@ -713,11 +748,48 @@ def rendering_test_dexsuite_kuka( pytest.skip("ovphysx is not supported yet.") from isaaclab.envs import ManagerBasedRLEnv + from isaaclab.sensors import CameraCfg + from isaaclab.utils.configclass import configclass + from isaaclab_tasks.core.dexsuite.config.kuka_allegro.camera_cfg import ( + BASE_CAMERA_CFG, + BaseTiledCameraCfg, + SingleCameraObservationsCfg, + ) from isaaclab_tasks.core.dexsuite.config.kuka_allegro.dexsuite_kuka_allegro_camera_env_cfg import ( + _SCENE_KWARGS, DexsuiteKukaAllegroLiftCameraEnvCfg, + SingleCameraSceneCfg, + ) + from isaaclab_tasks.core.dexsuite.config.kuka_allegro.dexsuite_kuka_allegro_env_cfg import ( + DexsuiteKukaAllegroLiftEnvCfg, ) + @configclass + class _DexsuiteBaseTiledCameraTestCfg(BaseTiledCameraCfg): + distance_to_camera64 = BASE_CAMERA_CFG.replace(data_types=["distance_to_camera"], width=64, height=64) + distance_to_camera128 = BASE_CAMERA_CFG.replace(data_types=["distance_to_camera"], width=128, height=128) + distance_to_camera256 = BASE_CAMERA_CFG.replace(data_types=["distance_to_camera"], width=256, height=256) + distance_to_image_plane64 = BASE_CAMERA_CFG.replace(data_types=["distance_to_image_plane"], width=64, height=64) + distance_to_image_plane128 = BASE_CAMERA_CFG.replace( + data_types=["distance_to_image_plane"], width=128, height=128 + ) + distance_to_image_plane256 = BASE_CAMERA_CFG.replace( + data_types=["distance_to_image_plane"], width=256, height=256 + ) + + @configclass + class _DexsuiteSingleCameraTestSceneCfg(SingleCameraSceneCfg): + base_camera: CameraCfg = _DexsuiteBaseTiledCameraTestCfg() + + @configclass + class _DexsuiteKukaAllegroLiftCameraTestEnvCfg(DexsuiteKukaAllegroLiftCameraEnvCfg): + single_camera = DexsuiteKukaAllegroLiftEnvCfg( + scene=_DexsuiteSingleCameraTestSceneCfg(**_SCENE_KWARGS), + observations=SingleCameraObservationsCfg(), + ) + default = single_camera + override_arg = f"presets={_physics_preset_name(physics_backend)},{renderer},{data_type}64,single_camera" # The default setup uses heterogeneous environments with multiple asset spawner to place random objects. @@ -725,7 +797,7 @@ def rendering_test_dexsuite_kuka( if setup_homogeneous_envs: override_arg += ",cube" - env_cfg = DexsuiteKukaAllegroLiftCameraEnvCfg() + env_cfg = _DexsuiteKukaAllegroLiftCameraTestEnvCfg() env_cfg = _apply_overrides_to_env_cfg(env_cfg, [override_arg]) env_cfg.scene.num_envs = 4 From baf3ae6ee67670190f3d6bf3c2ad02b7fa6d8515 Mon Sep 17 00:00:00 2001 From: rilei-nvidia Date: Thu, 25 Jun 2026 01:02:39 +1200 Subject: [PATCH 004/139] Gate Isaac RTX per-env scene partitioning behind environment variable (#6238) # Description Add ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION environment variable to opt in to authoring primvars:omni:scenePartition and omni:scenePartition on the USD stage. Previously this was always on; now it is off by default. This is to address an OOM in one of our IsaacSim RTX benchmark suites related to primvar inheritance of the scene partition attributes while the root cause/fix is under investigation. ## Testing We have a separate issue which only visualizes env_0 in kit when scene partitioning is enabled... so I tested with: ```bash ./isaaclab.sh train --rl_library rsl_rl \ --task Isaac-Lift-KukaAllegro-Camera \ presets=newton_mjwarp,isaacsim_rtx_renderer,rgb64,single_camera \ --seed 42 \ --num_envs 4 \ --max_iterations 1000 \ --visualizer kit ``` ... and it shows all the envs - checked all the env Xforms and camera and they don't have the scenePartition attribute/primvars: image Conversely, tested with the environment variable enabled: ```bash ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1 ./isaaclab.sh train --rl_library rsl_rl \ --task Isaac-Lift-KukaAllegro-Camera \ presets=newton_mjwarp,isaacsim_rtx_renderer,rgb64,single_camera \ --seed 42 \ --num_envs 4 \ --max_iterations 1000 \ --visualizer kit ``` And we get the old behavior of only env_0 showing. @fatimaanes keen to hear if this helps mitigate the benchmark OOM you are seeing. ## 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 --- ...ac-rtx-per-env-scene-partition-env-var.rst | 7 +++ source/isaaclab/isaaclab/utils/renderers.py | 18 ++++++++ ...ac-rtx-per-env-scene-partition-env-var.rst | 7 +++ .../renderers/isaac_rtx_renderer.py | 19 +++++++- ...t_isaac_rtx_renderer_scene_partitioning.py | 44 ++++++++++++++++++- ...ac-rtx-per-env-scene-partition-env-var.rst | 6 +++ source/isaaclab_tasks/test/conftest.py | 8 ++++ .../test/core/test_rendering_cartpole.py | 2 +- .../core/test_rendering_registered_tasks.py | 2 +- ...ac-rtx-per-env-scene-partition-env-var.rst | 7 +++ .../kit/kit_visualizer.py | 17 +++++++ 11 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 source/isaaclab/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst create mode 100644 source/isaaclab/isaaclab/utils/renderers.py create mode 100644 source/isaaclab_physx/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst create mode 100644 source/isaaclab_tasks/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst create mode 100644 source/isaaclab_visualizers/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst diff --git a/source/isaaclab/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst b/source/isaaclab/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst new file mode 100644 index 00000000000..d868abb3fc1 --- /dev/null +++ b/source/isaaclab/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst @@ -0,0 +1,7 @@ +Added +^^^^^ + +* Added :func:`~isaaclab.utils.renderers.isaac_rtx_per_env_scene_partition_enabled` to + query whether per-environment Isaac RTX scene partitioning is enabled. Set + ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` to enable authoring of + ``primvars:omni:scenePartition`` and ``omni:scenePartition`` on the USD stage. diff --git a/source/isaaclab/isaaclab/utils/renderers.py b/source/isaaclab/isaaclab/utils/renderers.py new file mode 100644 index 00000000000..171204e9501 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/renderers.py @@ -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 + +"""Renderer-related utility helpers.""" + +import os + + +def isaac_rtx_per_env_scene_partition_enabled() -> bool: + """Return whether per-environment RTX scene partitioning is enabled. + + Partitioning is opt-in: set ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` + to enable authoring of ``primvars:omni:scenePartition`` and ``omni:scenePartition`` + on the USD stage. + """ + return os.environ.get("ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION", "0") == "1" diff --git a/source/isaaclab_physx/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst b/source/isaaclab_physx/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst new file mode 100644 index 00000000000..826584fd2db --- /dev/null +++ b/source/isaaclab_physx/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* Changed :meth:`~isaaclab_physx.renderers.IsaacRtxRenderer.prepare_stage` to skip authoring + ``primvars:omni:scenePartition`` and ``omni:scenePartition`` by default. Set the environment + variable ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` to re-enable + per-environment scene partitioning for Isaac RTX rendering. 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 6fa530c5251..5eecd87242b 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -22,6 +22,7 @@ from isaaclab.app.settings_manager import get_settings_manager from isaaclab.renderers import BaseRenderer, RenderBufferKind, RenderBufferSpec from isaaclab.renderers.camera_render_spec import CameraRenderSpec +from isaaclab.utils.renderers import isaac_rtx_per_env_scene_partition_enabled 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 @@ -187,12 +188,28 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: def prepare_stage(self, stage: Usd.Stage, num_envs: int) -> None: """Author per-env ``omni:scenePartition`` attributes for RTX cull-by-env rendering. - For each ``/World/envs/env_{i}`` root, writes the inheriting primvar + Authoring is only performed when + ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` is set. + When the variable is absent the method is a no-op and no ``primvars:omni:scenePartition`` + or ``omni:scenePartition`` attributes are written to the stage. + + When enabled, for each ``/World/envs/env_{i}`` root, writes the inheriting primvar ``primvars:omni:scenePartition`` (token ``env_{i}``) on the root and the matching non-primvar ``omni:scenePartition`` token on every :class:`UsdGeom.Camera` descendant. RTX honors primvar inheritance, so the env-root primvar propagates to all descendant geometry and isolates each env's render tile. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.prepare_stage`.""" + + if not isaac_rtx_per_env_scene_partition_enabled(): + return + + logger.debug( + "Per-environment RTX scene partitioning is enabled" + " (ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1)." + " Authoring primvars:omni:scenePartition on %d env(s).", + num_envs, + ) + root_layer = stage.GetRootLayer() token_type = Sdf.ValueTypeNames.Token with Sdf.ChangeBlock(): diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_scene_partitioning.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_scene_partitioning.py index c54bf9ee85e..22c3ae59a75 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_scene_partitioning.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_scene_partitioning.py @@ -17,6 +17,12 @@ ``isaacsim_ci`` test file in its own app, so keeping these tests isolated here gives them a clean renderer and exercises the real single-scene use case. +Per-env scene partitioning is gated behind the +``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION`` environment variable and is off +by default. Tests that verify partitioning is *active* use the ``enable_scene_partition`` +fixture, which sets the variable for the duration of the test and restores the previous +state afterwards. + Launch Isaac Sim Simulator first. """ @@ -32,6 +38,7 @@ import pytest import torch import warp as wp +from isaaclab_physx.renderers.isaac_rtx_renderer import IsaacRtxRenderer, IsaacRtxRendererCfg import isaaclab.sim as sim_utils from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg @@ -42,9 +49,42 @@ from isaaclab_assets.robots.kuka_allegro import KUKA_ALLEGRO_CFG +_ENV_VAR = "ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION" + + +@pytest.fixture() +def enable_scene_partition(monkeypatch): + """Set ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` for the duration of one test.""" + monkeypatch.setenv(_ENV_VAR, "1") + + +@pytest.mark.isaacsim_ci +def test_partitioning_disabled_by_default(monkeypatch): + """``primvars:omni:scenePartition`` must NOT be authored when the env var is absent. + + The feature is off by default; this test confirms that :meth:`IsaacRtxRenderer.prepare_stage` + is a no-op without ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1``. + """ + from pxr import Usd + + monkeypatch.delenv(_ENV_VAR, raising=False) + + stage = Usd.Stage.CreateInMemory() + world = stage.DefinePrim("/World", "Xform") # noqa: F841 + env0 = stage.DefinePrim("/World/envs/env_0", "Xform") # noqa: F841 + + renderer = object.__new__(IsaacRtxRenderer) + renderer.cfg = IsaacRtxRendererCfg() + renderer.prepare_stage(stage, num_envs=1) + + prim = stage.GetPrimAtPath("/World/envs/env_0") + assert not prim.HasAttribute("primvars:omni:scenePartition"), ( + "primvars:omni:scenePartition must not be authored when partitioning is disabled." + ) + @pytest.mark.isaacsim_ci -def test_partitioning_isolates_rigid_object(): +def test_partitioning_isolates_rigid_object(enable_scene_partition): """Per-env :class:`~isaaclab.assets.RigidObject` instances at unique world positions render as visibly different per-env tiles when RTX honors ``primvars:omni:scenePartition``.""" @@ -111,7 +151,7 @@ class _Scene(InteractiveSceneCfg): @pytest.mark.isaacsim_ci -def test_partitioning_isolates_articulation(): +def test_partitioning_isolates_articulation(enable_scene_partition): """Per-env :class:`~isaaclab.assets.Articulation` instances driven to wildly different joint poses render as visibly different per-env tiles when RTX honors top-level scene partitions.""" diff --git a/source/isaaclab_tasks/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst b/source/isaaclab_tasks/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst new file mode 100644 index 00000000000..0731b9c88cb --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst @@ -0,0 +1,6 @@ +Added +^^^^^ + +* Added ``enable_scene_partition`` pytest fixture and enabled Isaac RTX per-environment scene + partitioning in rendering correctness tests for cartpole and registered camera tasks as a + temporary workaround. diff --git a/source/isaaclab_tasks/test/conftest.py b/source/isaaclab_tasks/test/conftest.py index 4cc0cca4550..b37173affaa 100644 --- a/source/isaaclab_tasks/test/conftest.py +++ b/source/isaaclab_tasks/test/conftest.py @@ -13,4 +13,12 @@ import os import sys +import pytest + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + +@pytest.fixture() +def enable_scene_partition(monkeypatch): + """Set ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` for the duration of one test.""" + monkeypatch.setenv("ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION", "1") diff --git a/source/isaaclab_tasks/test/core/test_rendering_cartpole.py b/source/isaaclab_tasks/test/core/test_rendering_cartpole.py index 6f10a80a1d4..5ac49d76df3 100644 --- a/source/isaaclab_tasks/test/core/test_rendering_cartpole.py +++ b/source/isaaclab_tasks/test/core/test_rendering_cartpole.py @@ -32,6 +32,6 @@ @pytest.mark.parametrize("physics_backend,renderer,data_type", PHYSICS_RENDERER_AOV_COMBINATIONS) -def test_rendering_cartpole(physics_backend, renderer, data_type): +def test_rendering_cartpole(physics_backend, renderer, data_type, enable_scene_partition): """Test cartpole environment rendering correctness.""" rendering_test_cartpole(physics_backend, renderer, data_type, _COMPARISON_SCORES) diff --git a/source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py b/source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py index e66538c46c1..fd2f7535a7d 100644 --- a/source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py +++ b/source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py @@ -86,7 +86,7 @@ def _collect_camera_outputs(env: object) -> dict[str, dict[str, torch.Tensor]]: @pytest.mark.parametrize("task_id, presets, env_name", _RENDER_CORRECTNESS_TASK_IDS) -def test_rendering_registered_tasks(task_id: str, presets: str | None, env_name: str): +def test_rendering_registered_tasks(task_id: str, presets: str | None, env_name: str, enable_scene_partition): """Test registered tasks rendering correctness.""" env = None diff --git a/source/isaaclab_visualizers/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst b/source/isaaclab_visualizers/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst new file mode 100644 index 00000000000..664c13b13b6 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* Changed :class:`~isaaclab_visualizers.kit.KitVisualizer` to skip authoring the + ``omni:scenePartition`` attribute on the viewport camera by default. Set + ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` to re-enable per-environment + scene partitioning for the Kit viewport camera. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 1f8bec44fbc..4aa0d40892d 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -30,6 +30,7 @@ resolve_tiled_env_indices, ) from isaaclab.utils.math import create_rotation_matrix_from_view, quat_from_matrix +from isaaclab.utils.renderers import isaac_rtx_per_env_scene_partition_enabled from isaaclab.visualizers.base_visualizer import BaseVisualizer from isaaclab_visualizers.newton_adapter import resolve_visible_env_indices @@ -513,9 +514,25 @@ def _apply_viewport_camera_scene_partition(self, usd_stage: Usd.Stage, num_envs: ``omni:scenePartition`` token. Interactive viewport cameras live outside ``/World/envs`` and are created by Kit, so they do not inherit the env-root primvar authored by :class:`~isaaclab.scene.InteractiveScene`. + + This method is a no-op unless ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1``, + matching the opt-in behaviour of + :meth:`~isaaclab_physx.renderers.IsaacRtxRenderer.prepare_stage`. """ + + if not isaac_rtx_per_env_scene_partition_enabled(): + return + if num_envs <= 0 or self._controlled_camera_path is None: return + + logger.debug( + "[KitVisualizer] Per-environment Isaac RTX scene partitioning is enabled" + " (ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1)." + " Authoring omni:scenePartition attribute onto viewport camera '%s'.", + self._controlled_camera_path, + ) + env_id = self._resolved_visible_env_ids[0] if self._resolved_visible_env_ids else 0 camera_prim = usd_stage.GetPrimAtPath(self._controlled_camera_path) if not camera_prim.IsValid() or not camera_prim.IsA(UsdGeom.Camera): From 08b44c71a142e11a0f68ea024827e76bdb5f853a Mon Sep 17 00:00:00 2001 From: vidurv-nvidia Date: Wed, 24 Jun 2026 16:14:52 -0500 Subject: [PATCH 005/139] Adding RigidBody USD data classes and writers (#5976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description ## Summary (PR 1 of 2 — additive, no behavior change) Introduces single-namespace schema **fragments** so a prim can carry rigid-body properties from multiple USD namespaces at once (`physics:`, `physxRigidBody:`, `mjc:`), with core naming no backend. **Purely additive** — existing cfgs and the deprecated `RigidBodyPropertiesCfg` class are untouched. - **core:** `SchemaFragment`, `RigidBodyFragment`, `UsdPhysicsRigidBodyCfg`; `apply_namespaced` (generic per-fragment writer) and `apply_rigid_body_properties` (applies a fragment list with `UsdPhysics.RigidBodyAPI` as the implicit anchor). - **spawner:** `rigid_props` accepts a fragment list; legacy single cfgs keep working via a transition bridge (shapes/meshes/from_files/mesh_converter). - **backends:** `PhysxRigidBodyCfg` (physx), `MujocoRigidBodyCfg` (newton). - new `test_schema_fragments.py` + exports. **PR 2** (stacked) flips `RigidBodyPropertiesCfg` to a deprecation factory and migrates all call sites: vidurv-nvidia/IsaacLab#4 (review after this lands). ## Test Plan - [x] `test_schema_fragments.py` — 7 passed - [x] pre-commit clean · `./isaaclab.sh -d` docs build clean - [ ] CI First slice of the staged physics schema-cfg refactor (collision / mass / joint-drive / … follow later). Draft for review of the fragment API shape. ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots N/A — no user-facing visual 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 (`ruff` + `ruff-format`) on the changed files - [ ] I have made corresponding changes to the documentation (the public API reference is auto-generated via `./isaaclab.sh -d`; not separately authored) - [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 - [ ] I have added my name to `CONTRIBUTORS.md` --------- Co-authored-by: ooctipus --- CONTRIBUTORS.md | 1 + .../vidurv-schema-fragments.minor.rst | 19 ++ source/isaaclab/isaaclab/sim/__init__.pyi | 232 +++++++++-------- .../isaaclab/sim/converters/mesh_converter.py | 8 +- .../isaaclab/sim/schemas/__init__.pyi | 10 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 84 ++++++- .../isaaclab/sim/schemas/schemas_cfg.py | 70 ++++++ .../sim/spawners/from_files/from_files.py | 21 +- .../isaaclab/sim/spawners/meshes/meshes.py | 8 +- .../isaaclab/sim/spawners/shapes/shapes.py | 7 +- .../isaaclab/sim/spawners/spawner_cfg.py | 11 +- .../test/sim/test_schema_fragments.py | 237 ++++++++++++++++++ .../vidurv-schema-fragments.minor.rst | 7 + .../isaaclab_newton/sim/schemas/__init__.pyi | 2 + .../sim/schemas/schemas_cfg.py | 26 ++ .../vidurv-schema-fragments.minor.rst | 8 + .../isaaclab_physx/sim/schemas/__init__.pyi | 2 + .../isaaclab_physx/sim/schemas/schemas_cfg.py | 59 +++++ 18 files changed, 691 insertions(+), 121 deletions(-) create mode 100644 source/isaaclab/changelog.d/vidurv-schema-fragments.minor.rst create mode 100644 source/isaaclab/test/sim/test_schema_fragments.py create mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-fragments.minor.rst create mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-fragments.minor.rst diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7c63d42f56e..ee6967f09fc 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -184,6 +184,7 @@ Guidelines for modifications: * Tsz Ki GAO * Tyler Lum * Victor Khaustov +* Vidur Vij * Virgilio Gómez Lambo * Vladimir Fokow * Wei Yang diff --git a/source/isaaclab/changelog.d/vidurv-schema-fragments.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-fragments.minor.rst new file mode 100644 index 00000000000..49cea07c006 --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-fragments.minor.rst @@ -0,0 +1,19 @@ +Added +^^^^^ + +* Added the single-namespace schema-fragment API: :class:`~isaaclab.sim.schemas.SchemaFragment`, + the :class:`~isaaclab.sim.schemas.RigidBodyFragment` marker, and + :class:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg`. Each fragment carries + ``_usd_namespace`` / ``_usd_applied_schema`` metadata and a ``func`` applier so a prim can + carry rigid-body properties from multiple USD namespaces at once. +* Added :func:`~isaaclab.sim.schemas.apply_namespaced` (generic fragment writer) and + :func:`~isaaclab.sim.schemas.apply_rigid_body_properties` (applies a list of rigid-body + fragments with ``UsdPhysics.RigidBodyAPI`` as the implicit anchor). + +Changed +^^^^^^^ + +* Changed the spawner ``rigid_props`` slot + (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.rigid_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.RigidBodyFragment` fragments. Legacy single cfgs continue to + work through a transition bridge in the spawn writers. diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 05b76be4270..e0365d3e759 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -59,6 +59,11 @@ __all__ = [ "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyPropertiesCfg", "RigidBodyBaseCfg", + "RigidBodyFragment", + "SchemaFragment", + "UsdPhysicsRigidBodyCfg", + "apply_namespaced", + "apply_rigid_body_properties", "SDFMeshPropertiesCfg", "SpatialTendonPropertiesCfg", "TriangleMeshPropertiesCfg", @@ -192,8 +197,6 @@ __all__ = [ "XformPrimView", ] -from .simulation_cfg import RenderCfg, SimulationCfg -from .simulation_context import SimulationContext, build_simulation_context from .converters import ( AssetConverterBase, AssetConverterBaseCfg, @@ -208,22 +211,6 @@ from .schemas import ( MESH_APPROXIMATION_TOKENS, PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, - 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, - modify_mesh_collision_properties, - modify_rigid_body_properties, - modify_spatial_tendon_properties, ArticulationRootPropertiesCfg, BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, @@ -239,11 +226,34 @@ from .schemas import ( PhysxJointDrivePropertiesCfg, PhysxRigidBodyPropertiesCfg, RigidBodyBaseCfg, + RigidBodyFragment, + SchemaFragment, SDFMeshPropertiesCfg, SpatialTendonPropertiesCfg, TriangleMeshPropertiesCfg, TriangleMeshSimplificationPropertiesCfg, + UsdPhysicsRigidBodyCfg, + activate_contact_sensors, + apply_namespaced, + apply_rigid_body_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, + modify_mesh_collision_properties, + modify_rigid_body_properties, + modify_spatial_tendon_properties, ) +from .simulation_cfg import RenderCfg, SimulationCfg +from .simulation_context import SimulationContext, build_simulation_context # Forwarded to isaaclab_newton.sim.schemas via __getattr__ shim MujocoJointDrivePropertiesCfg = ... @@ -256,46 +266,22 @@ NewtonMeshCollisionPropertiesCfg = ... NewtonRigidBodyPropertiesCfg = ... NewtonSDFCollisionPropertiesCfg = ... from .spawners import ( - SpawnerCfg, - RigidObjectSpawnerCfg, - DeformableObjectSpawnerCfg, - spawn_from_mjcf, - spawn_from_urdf, - spawn_from_usd, - spawn_from_usd_with_compliant_contact_material, - spawn_ground_plane, - GroundPlaneCfg, - MjcfFileCfg, - UrdfFileCfg, - UsdFileCfg, - UsdFileWithCompliantContactCfg, - spawn_light, + CapsuleCfg, + ConeCfg, + CuboidCfg, + CylinderCfg, CylinderLightCfg, + DeformableBodyMaterialBaseCfg, + DeformableBodyMaterialCfg, + DeformableObjectSpawnerCfg, DiskLightCfg, DistantLightCfg, DomeLightCfg, - LightCfg, - SphereLightCfg, - spawn_rigid_body_material, - spawn_deformable_body_material, - PhysicsMaterialCfg, - RigidBodyMaterialCfg, - DeformableBodyMaterialBaseCfg, - DeformableBodyMaterialCfg, - SurfaceDeformableBodyMaterialBaseCfg, - SurfaceDeformableBodyMaterialCfg, - spawn_from_mdl_file, - spawn_preview_surface, + FisheyeCameraCfg, GlassMdlCfg, + GroundPlaneCfg, + LightCfg, MdlFileCfg, - PreviewSurfaceCfg, - VisualMaterialCfg, - spawn_mesh_capsule, - spawn_mesh_cone, - spawn_mesh_cuboid, - spawn_mesh_cylinder, - spawn_mesh_rectangle, - spawn_mesh_sphere, MeshCapsuleCfg, MeshCfg, MeshConeCfg, @@ -303,83 +289,111 @@ from .spawners import ( MeshCylinderCfg, MeshRectangleCfg, MeshSphereCfg, - spawn_camera, - spawn_sensor_frame, - FisheyeCameraCfg, + MjcfFileCfg, + MultiAssetSpawnerCfg, + MultiUsdFileCfg, + PhysicsMaterialCfg, PinholeCameraCfg, + PreviewSurfaceCfg, + RigidBodyMaterialCfg, + RigidObjectSpawnerCfg, SensorFrameCfg, + ShapeCfg, + SpawnerCfg, + SphereCfg, + SphereLightCfg, + SurfaceDeformableBodyMaterialBaseCfg, + SurfaceDeformableBodyMaterialCfg, + UrdfFileCfg, + UsdFileCfg, + UsdFileWithCompliantContactCfg, + VisualMaterialCfg, + spawn_camera, spawn_capsule, spawn_cone, spawn_cuboid, spawn_cylinder, - spawn_sphere, - CapsuleCfg, - ConeCfg, - CuboidCfg, - CylinderCfg, - ShapeCfg, - SphereCfg, + spawn_deformable_body_material, + spawn_from_mdl_file, + spawn_from_mjcf, + spawn_from_urdf, + spawn_from_usd, + spawn_from_usd_with_compliant_contact_material, + spawn_ground_plane, + spawn_light, + spawn_mesh_capsule, + spawn_mesh_cone, + spawn_mesh_cuboid, + spawn_mesh_cylinder, + spawn_mesh_rectangle, + spawn_mesh_sphere, spawn_multi_asset, spawn_multi_usd_file, - MultiAssetSpawnerCfg, - MultiUsdFileCfg, + spawn_preview_surface, + spawn_rigid_body_material, + spawn_sensor_frame, + spawn_sphere, ) from .utils import ( + add_labels, add_reference_to_stage, - get_stage_up_axis, - traverse_stage, - get_prim_at_path, - get_prim_path, - is_prim_path_valid, - define_prim, - get_prim_type_name, - get_next_free_path, + add_usd_reference, + apply_nested, + bind_physics_material, + bind_visual_material, + change_prim_property, + check_missing_labels, + clear_stage, + clone, + close_stage, + convert_world_pose_to_local, + count_total_labels, + create_new_stage, create_prim, + define_prim, delete_prim, - make_uninstanceable, - set_prim_visibility, - safe_set_attribute_on_usd_schema, - safe_set_attribute_on_usd_prim, - change_prim_property, export_prim_to_file, - apply_nested, - clone, - bind_visual_material, - bind_physics_material, - add_usd_reference, - get_usd_references, - select_usd_variants, - get_next_free_prim_path, - get_first_matching_ancestor_prim, - get_first_matching_child_prim, - get_all_matching_child_prims, find_first_matching_prim, - find_matching_prims, - matches_path_expr_prefix, - resolve_matching_prims_from_source, - find_matching_prim_paths, find_global_fixed_joint_prim, - add_labels, + find_matching_prim_paths, + find_matching_prims, + get_all_matching_child_prims, + get_current_stage, + get_current_stage_id, + get_first_matching_ancestor_prim, + get_first_matching_child_prim, get_labels, - remove_labels, - check_missing_labels, - count_total_labels, - resolve_paths, - create_new_stage, + get_next_free_path, + get_next_free_prim_path, + get_prim_at_path, + get_prim_path, + get_prim_type_name, + get_stage_up_axis, + get_usd_references, is_current_stage_in_memory, + is_prim_path_valid, + make_uninstanceable, + matches_path_expr_prefix, open_stage, - use_stage, - update_stage, + remove_labels, + resolve_matching_prims_from_source, + resolve_paths, + resolve_prim_pose, + resolve_prim_scale, + safe_set_attribute_on_usd_prim, + safe_set_attribute_on_usd_schema, save_stage, - close_stage, - clear_stage, - get_current_stage, - get_current_stage_id, + select_usd_variants, + set_prim_visibility, standardize_xform_ops, + traverse_stage, + update_stage, + use_stage, validate_standard_xform_ops, - resolve_prim_pose, - resolve_prim_scale, - convert_world_pose_to_local, ) -from .views import BaseFrameView, UsdFrameView, FrameView -from .views import XformPrimView # deprecated alias +from .views import ( + BaseFrameView, + FrameView, + UsdFrameView, + XformPrimView, # deprecated alias +) diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py index 74ba8b470c3..f814e4fdb87 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py @@ -185,9 +185,13 @@ def _convert_asset(self, cfg: MeshConverterCfg): # apply mass properties if cfg.mass_props is not None: schemas.define_mass_properties(prim_path=xform_prim.GetPath(), cfg=cfg.mass_props, stage=stage) - # apply rigid body properties + # apply rigid body properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.rigid_props is not None: - schemas.define_rigid_body_properties(prim_path=xform_prim.GetPath(), cfg=cfg.rigid_props, stage=stage) + rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] + if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): + schemas.apply_rigid_body_properties(str(xform_prim.GetPath()), rigid_frags, stage=stage) + else: + schemas.define_rigid_body_properties(prim_path=xform_prim.GetPath(), cfg=cfg.rigid_props, stage=stage) # Save changes to USD stage stage.Save() diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index 49eff741c8c..af153a60fc6 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -8,6 +8,8 @@ __all__ = [ "PHYSX_MESH_COLLISION_CFGS", "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", + "apply_namespaced", + "apply_rigid_body_properties", "define_actuator_properties", "define_articulation_root_properties", "define_collision_properties", @@ -33,6 +35,9 @@ __all__ = [ "JointDriveBaseCfg", "MassPropertiesCfg", "MeshCollisionBaseCfg", + "RigidBodyFragment", + "SchemaFragment", + "UsdPhysicsRigidBodyCfg", "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyPropertiesCfg", "NewtonArticulationRootPropertiesCfg", @@ -50,6 +55,8 @@ from .schemas import ( PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, activate_contact_sensors, + apply_namespaced, + apply_rigid_body_properties, define_articulation_root_properties, define_collision_properties, define_deformable_body_properties, @@ -80,6 +87,9 @@ from .schemas_cfg import ( MassPropertiesCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, + RigidBodyFragment, + SchemaFragment, + UsdPhysicsRigidBodyCfg, ) # Forwarded to isaaclab_newton.sim.schemas via __getattr__ shim diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 23f53b105cb..6617543cad6 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -9,6 +9,7 @@ import dataclasses import logging import math +from collections.abc import Iterable import numpy as np import warp as wp @@ -16,7 +17,7 @@ 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 isaaclab.utils.string import string_to_callable, to_camel_case from ..utils import ( apply_nested, @@ -214,6 +215,53 @@ class that declares it (walking the MRO). Each group writes under that class's safe_set_attribute_on_usd_prim(prim, f"{namespace}:{usd_attr}", value, camel_case=False) +def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Default fragment applier: apply the fragment's schema and write its namespaced attrs. + + Reads :attr:`~isaaclab.sim.schemas.SchemaFragment._usd_namespace` / + :attr:`~isaaclab.sim.schemas.SchemaFragment._usd_applied_schema` from the cfg's class. If the + fragment owns an applied schema, it is applied (once). Each non-``None`` dataclass field is + written as ``:``; the ``func`` field is skipped. ``None`` fields + are left unchanged on the prim (partial update). + + Args: + cfg: The fragment instance carrying ``_usd_namespace`` / ``_usd_applied_schema`` metadata. + prim_path: The prim path to author on. + 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. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the legacy define_/modify_ writers) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + namespace = type(cfg)._usd_namespace + applied = type(cfg)._usd_applied_schema + # every fragment field is a namespaced USD attribute, so a namespace is required + if namespace is None: + raise ValueError( + f"Fragment '{type(cfg).__name__}' has no '_usd_namespace' set. Every fragment field is" + " authored as ':', so a USD namespace is required; non-USD state must" + " live on the spawner cfg or be passed as a writer keyword argument, not as a fragment" + " field." + ) + if applied and applied not in prim.GetAppliedSchemas(): + prim.AddAppliedSchema(applied) + for f in dataclasses.fields(cfg): + # ``func`` is the only non-USD field; non-scalar values raise in the setter + if f.name == "func": + continue + value = getattr(cfg, f.name) + if value is None: + continue + safe_set_attribute_on_usd_prim(prim, f"{namespace}:{to_camel_case(f.name, 'cC')}", value, camel_case=False) + return True + + """ Articulation root properties. """ @@ -386,6 +434,40 @@ def modify_articulation_root_properties( """ +def apply_rigid_body_properties( + prim_path: str, fragments: Iterable[schemas_cfg.RigidBodyFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of rigid-body fragments to a prim. + + Applies ``UsdPhysics.RigidBodyAPI`` as the implicit anchor (the defining schema for a rigid + body), then dispatches each fragment via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. + Backend fragments carry backend-specific funcs, so core never imports a backend. + + Args: + prim_path: The prim path to apply the rigid-body schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.RigidBodyFragment` instances. + 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. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the legacy define_rigid_body_properties writer) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + if not UsdPhysics.RigidBodyAPI(prim): + UsdPhysics.RigidBodyAPI.Apply(prim) + # aggregate per-fragment results so a reported failure is not masked by the always-applied anchor + success = True + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + success = bool(func(cfg, prim_path, stage)) and success + return success + + 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. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index 4eaec2004e9..ac5d8b22c02 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -6,6 +6,7 @@ from __future__ import annotations import warnings +from collections.abc import Callable from typing import ClassVar, Literal from isaaclab.utils.configclass import configclass @@ -106,6 +107,75 @@ def _deprecate_field_alias(cfg, alias: str, canonical: str) -> None: setattr(cfg, alias, None) +@configclass +class SchemaFragment: + """Base for a single-namespace USD-schema config fragment. + + Each subclass mirrors exactly one USD applied schema. The fragment carries class-level + metadata describing which USD namespace its fields write to (:attr:`_usd_namespace`) and + which applied schema, if any, it owns (:attr:`_usd_applied_schema`). The :attr:`func` + field names the callable that applies the fragment to a prim; the default generic applier + (:func:`~isaaclab.sim.schemas.apply_namespaced`) reads the metadata and writes each + non-``None`` field as ``:``. Irregular APIs override + :attr:`func` with a custom applier. + + .. note:: + A fragment present in a spawner slot means its schema is applied. ``None`` fields are + left unchanged on the prim (partial update). + + .. important:: + Every dataclass field other than :attr:`func` is authored as a USD attribute + ``<_usd_namespace>:``. A fragment must not carry non-USD/bookkeeping + fields -- such state belongs on the spawner cfg or as a writer keyword argument (this is + why ``fix_root_link`` / ``ensure_drives_exist`` are not fragment fields). The generic + applier (:func:`~isaaclab.sim.schemas.apply_namespaced`) enforces the invariant: it raises + when a fragment has no ``_usd_namespace``, and unsupported (non-scalar) value types raise + when written. + """ + + # -- Class metadata (not dataclass fields) -- + _usd_namespace: ClassVar[str | None] = None + _usd_applied_schema: ClassVar[str | None] = None + + func: Callable | str = "isaaclab.sim.schemas:apply_namespaced" + """Callable (or its ``module:attr`` import string) that applies this fragment to a prim. + + Resolved via :func:`~isaaclab.utils.string.string_to_callable` when a string. The callable + signature is ``func(cfg, prim_path, stage)``. + """ + + +@configclass +class RigidBodyFragment(SchemaFragment): + """Marker base for rigid-body fragments; types the ``rigid_props`` slot.""" + + pass + + +@configclass +class UsdPhysicsRigidBodyCfg(RigidBodyFragment): + """``physics:*`` rigid-body attributes from `UsdPhysics.RigidBodyAPI`_. + + The ``UsdPhysics.RigidBodyAPI`` schema is applied as the implicit anchor by the rigid-body + family writer, so this fragment owns no applied schema of its own. + + .. _UsdPhysics.RigidBodyAPI: https://openusd.org/dev/api/class_usd_physics_rigid_body_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None # RigidBodyAPI applied by the family anchor + + rigid_body_enabled: bool | None = None + """Whether to enable or disable the rigid body.""" + + kinematic_enabled: bool | None = None + """Determines whether the body is kinematic or not. + + A kinematic body is moved through animated or user-defined poses; the simulation still + derives velocities for it based on the external motion. + """ + + @configclass class ArticulationRootBaseCfg: """Solver-common properties to apply to the root of an articulation. 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 3a6fa939a63..4545eb23e11 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -344,7 +344,12 @@ def _spawn_from_usd_file( # modify rigid body properties if cfg.rigid_props is not None: - schemas.modify_rigid_body_properties(prim_path, cfg.rigid_props) + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> modify_* + rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] + if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): + schemas.apply_rigid_body_properties(prim_path, rigid_frags) + else: + schemas.modify_rigid_body_properties(prim_path, cfg.rigid_props) # modify collision properties if cfg.collision_props is not None: schemas.modify_collision_properties(prim_path, cfg.collision_props) @@ -368,10 +373,18 @@ def _spawn_from_usd_file( # 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 + from isaaclab_newton.sim.schemas.schemas_cfg import ( + MujocoJointDrivePropertiesCfg, + MujocoRigidBodyCfg, + MujocoRigidBodyPropertiesCfg, + ) - body_gravcomp_unset = ( - not isinstance(cfg.rigid_props, MujocoRigidBodyPropertiesCfg) or cfg.rigid_props.gravcomp is None + # gravcomp may be authored either via the legacy MujocoRigidBodyPropertiesCfg or via a + # MujocoRigidBodyCfg fragment in a rigid_props list. Treat either as "already set". + rigid_props_list = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] + body_gravcomp_unset = not any( + isinstance(f, (MujocoRigidBodyPropertiesCfg, MujocoRigidBodyCfg)) and f.gravcomp is not None + for f in rigid_props_list ) if ( isinstance(cfg.joint_drive_props, MujocoJointDrivePropertiesCfg) diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index cfc7f51b9ff..27f815343c0 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -444,5 +444,9 @@ def _spawn_mesh_geom_from_mesh( # apply mass properties if cfg.mass_props is not None: schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) - # apply rigid properties - schemas.define_rigid_body_properties(prim_path, cfg.rigid_props, stage=stage) + # apply rigid properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) + rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] + if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): + schemas.apply_rigid_body_properties(prim_path, rigid_frags, stage=stage) + else: + schemas.define_rigid_body_properties(prim_path, cfg.rigid_props, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index 9e8eafc1c57..0601cbed541 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -322,4 +322,9 @@ def _spawn_geom_from_prim_type( schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) # apply rigid body properties if cfg.rigid_props is not None: - schemas.define_rigid_body_properties(prim_path, cfg.rigid_props, stage=stage) + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> define_* + rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] + if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): + schemas.apply_rigid_body_properties(prim_path, rigid_frags, stage=stage) + else: + schemas.define_rigid_body_properties(prim_path, cfg.rigid_props, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py index 3f1eef72a2f..1d52d451ba1 100644 --- a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py @@ -85,11 +85,18 @@ class RigidObjectSpawnerCfg(SpawnerCfg): mass_props: schemas.MassPropertiesCfg | None = None """Mass properties.""" - rigid_props: schemas.RigidBodyBaseCfg | None = None + rigid_props: schemas.RigidBodyBaseCfg | schemas.RigidBodyFragment | list[schemas.RigidBodyFragment] | None = None """Rigid body properties. + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`) or a + list of :class:`~isaaclab.sim.schemas.RigidBodyFragment` fragments + (e.g. ``[UsdPhysicsRigidBodyCfg(...), PhysxRigidBodyCfg(...)]``). When a fragment list is given, + ``UsdPhysics.RigidBodyAPI`` is applied as the implicit anchor and each fragment writes its own + namespace. + 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. + (or :attr:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg.kinematic_enabled`) as True. This will + make the object static and will not be affected by gravity or other forces. """ collision_props: schemas.CollisionPropertiesCfg | None = None diff --git a/source/isaaclab/test/sim/test_schema_fragments.py b/source/isaaclab/test/sim/test_schema_fragments.py new file mode 100644 index 00000000000..b76239963da --- /dev/null +++ b/source/isaaclab/test/sim/test_schema_fragments.py @@ -0,0 +1,237 @@ +# 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 pytest + +from pxr import UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + + +def _make_xform(stage, path="/World/Body"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# SchemaFragment base, RigidBodyFragment marker, UsdPhysicsRigidBodyCfg +# ------------------------------------------------------------------------------------- + + +def test_fragment_metadata_defaults(): + from isaaclab.sim.schemas import RigidBodyFragment, SchemaFragment, UsdPhysicsRigidBodyCfg + + cfg = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + assert isinstance(cfg, RigidBodyFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physics" + assert type(cfg)._usd_applied_schema is None # anchor applies RigidBodyAPI, not the fragment + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.rigid_body_enabled is True and cfg.kinematic_enabled is None + + +# ------------------------------------------------------------------------------------- +# apply_namespaced generic applier +# ------------------------------------------------------------------------------------- + + +def test_apply_namespaced_writes_only_set_fields(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage) + UsdPhysics.RigidBodyAPI.Apply(prim) + apply_namespaced(UsdPhysicsRigidBodyCfg(rigid_body_enabled=True), "/World/Body", stage) + assert prim.GetAttribute("physics:rigidBodyEnabled").Get() is True + # ``kinematicEnabled`` is a RigidBodyAPI fallback attr (so HasAttribute is True), but the + # None field must not be authored by apply_namespaced. + assert not prim.GetAttribute("physics:kinematicEnabled").HasAuthoredValue() + + +# ------------------------------------------------------------------------------------- +# PhysxRigidBodyCfg (isaaclab_physx) +# ------------------------------------------------------------------------------------- + + +def test_physx_rigid_body_fragment_writes_physx_namespace(): + from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/B2") + UsdPhysics.RigidBodyAPI.Apply(prim) + apply_namespaced(PhysxRigidBodyCfg(linear_damping=0.1, disable_gravity=True), "/World/B2", stage) + assert abs(prim.GetAttribute("physxRigidBody:linearDamping").Get() - 0.1) < 1e-6 + assert prim.GetAttribute("physxRigidBody:disableGravity").Get() is True + + +# ------------------------------------------------------------------------------------- +# MujocoRigidBodyCfg (isaaclab_newton) +# ------------------------------------------------------------------------------------- + + +def test_mujoco_rigid_body_fragment_writes_mjc_namespace(): + from isaaclab_newton.sim.schemas import MujocoRigidBodyCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/B3") + UsdPhysics.RigidBodyAPI.Apply(prim) + apply_namespaced(MujocoRigidBodyCfg(gravcomp=1.0), "/World/B3", stage) + assert abs(prim.GetAttribute("mjc:gravcomp").Get() - 1.0) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# apply_rigid_body_properties dispatch (implicit anchor + multi-namespace) +# ------------------------------------------------------------------------------------- + + +def test_apply_rigid_body_properties_composes_namespaces(): + from isaaclab_newton.sim.schemas import MujocoRigidBodyCfg + from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg + + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_rigid_body_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/B4") + apply_rigid_body_properties( + "/World/B4", + [ + UsdPhysicsRigidBodyCfg(rigid_body_enabled=True), + PhysxRigidBodyCfg(linear_damping=0.2), + MujocoRigidBodyCfg(gravcomp=1.0), + ], + stage, + ) + prim = stage.GetPrimAtPath("/World/B4") + assert bool(UsdPhysics.RigidBodyAPI(prim)) # implicit anchor applied + assert prim.GetAttribute("physics:rigidBodyEnabled").Get() is True + assert abs(prim.GetAttribute("physxRigidBody:linearDamping").Get() - 0.2) < 1e-6 + assert abs(prim.GetAttribute("mjc:gravcomp").Get() - 1.0) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# spawner slot accepts a fragment list + transition routing +# ------------------------------------------------------------------------------------- + + +def test_spawn_shape_with_rigid_fragment_list(): + from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg + + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + rigid_props=[UsdPhysicsRigidBodyCfg(rigid_body_enabled=True), PhysxRigidBodyCfg(linear_damping=0.3)], + ) + cfg.func("/World/Cube", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube") + assert bool(UsdPhysics.RigidBodyAPI(prim)) + assert abs(prim.GetAttribute("physxRigidBody:linearDamping").Get() - 0.3) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_newton.sim.schemas import MujocoRigidBodyCfg # noqa: F401 + from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg # noqa: F401 + + from isaaclab.sim.schemas import ( # noqa: F401 + RigidBodyFragment, + SchemaFragment, + UsdPhysicsRigidBodyCfg, + apply_namespaced, + apply_rigid_body_properties, + ) + + +# ------------------------------------------------------------------------------------- +# Review follow-ups -- prim-validity guard, aggregated return, namespace invariant guard +# ------------------------------------------------------------------------------------- + + +def test_apply_namespaced_raises_on_invalid_prim(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # no prim authored at this path -> GetPrimAtPath returns an invalid prim + with pytest.raises(ValueError): + apply_namespaced(UsdPhysicsRigidBodyCfg(rigid_body_enabled=True), "/World/DoesNotExist", stage) + + +def test_apply_rigid_body_properties_raises_on_invalid_prim(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_rigid_body_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_rigid_body_properties("/World/DoesNotExist", [UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)], stage) + + +def test_apply_rigid_body_properties_aggregates_fragment_results(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_rigid_body_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Agg") + + # a fragment whose applier reports failure must make the aggregate return False + failing = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + failing.func = lambda cfg, prim_path, stage=None: False + assert apply_rigid_body_properties("/World/Agg", [failing], stage) is False + + # all-succeeding fragments return True + ok = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + assert apply_rigid_body_properties("/World/Agg", [ok], stage) is True + + +def test_apply_namespaced_raises_without_namespace(): + from typing import ClassVar + + from isaaclab.sim.schemas import RigidBodyFragment, apply_namespaced + from isaaclab.utils import configclass + + @configclass + class _NoNamespaceFragment(RigidBodyFragment): + # deliberately leaves ``_usd_namespace`` as None, violating the fragment invariant that + # every field is authored as a namespaced USD attribute + _usd_namespace: ClassVar[str | None] = None + rigid_body_enabled: bool | None = None + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/NoNs") + UsdPhysics.RigidBodyAPI.Apply(prim) + with pytest.raises(ValueError): + apply_namespaced(_NoNamespaceFragment(rigid_body_enabled=True), "/World/NoNs", stage) diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-fragments.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-fragments.minor.rst new file mode 100644 index 00000000000..a4cb8ef1ddd --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-fragments.minor.rst @@ -0,0 +1,7 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.MujocoRigidBodyCfg`, the ``mjc:*`` single-namespace + rigid-body fragment (``mjc:gravcomp``) for Newton's MuJoCo solver. It composes with + :class:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxRigidBodyCfg` in a ``rigid_props`` fragment list. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi index e546498ee4a..89ca2d068af 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -5,6 +5,7 @@ __all__ = [ "MujocoJointDrivePropertiesCfg", + "MujocoRigidBodyCfg", "MujocoRigidBodyPropertiesCfg", "NewtonArticulationRootPropertiesCfg", "NewtonCollisionPropertiesCfg", @@ -18,6 +19,7 @@ __all__ = [ from .schemas_cfg import ( MujocoJointDrivePropertiesCfg, + MujocoRigidBodyCfg, MujocoRigidBodyPropertiesCfg, NewtonArticulationRootPropertiesCfg, NewtonCollisionPropertiesCfg, 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 a7b0d008257..4c99379e1ad 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -14,6 +14,7 @@ JointDriveBaseCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, + RigidBodyFragment, ) from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg from isaaclab.utils.configclass import configclass @@ -84,6 +85,31 @@ class MujocoRigidBodyPropertiesCfg(NewtonRigidBodyPropertiesCfg): """ +@configclass +class MujocoRigidBodyCfg(RigidBodyFragment): + """``mjc:*`` rigid-body attributes for Newton's MuJoCo solver. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) carrying + body-level gravity compensation. The ``mjc`` namespace has no applied schema; the + ``UsdPhysics.RigidBodyAPI`` anchor is applied by + :func:`~isaaclab.sim.schemas.apply_rigid_body_properties`. + + .. note:: + A ``newton:*`` rigid-body fragment is reserved but currently empty (Newton has no native + ``newton:`` rigid-body attributes today). + """ + + _usd_namespace: ClassVar[str | None] = "mjc" + _usd_applied_schema: ClassVar[str | None] = None + + gravcomp: float | None = None + """Gravity compensation scale for the body [dimensionless]. + + ``0.0`` = no compensation; ``1.0`` = full compensation. Written to ``mjc:gravcomp``. Body-level + gravcomp must be set for joint-level ``actuatorgravcomp`` to have any effect. + """ + + @configclass class NewtonJointDrivePropertiesCfg(JointDriveBaseCfg): """Newton-targeted joint drive properties. diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-fragments.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-fragments.minor.rst new file mode 100644 index 00000000000..6bd8bcac8b8 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-fragments.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.schemas.PhysxRigidBodyCfg`, the ``physxRigidBody:*`` + single-namespace rigid-body fragment (PhysX ``PhysxRigidBodyAPI``). It carries the PhysX + damping / velocity-limit / solver-iteration / sleep fields plus ``disable_gravity``, and + composes with :class:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg` in a ``rigid_props`` + fragment list. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index b542edf9f45..10d2502ddf2 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -23,6 +23,7 @@ __all__ = [ "PhysxDeformableCollisionPropertiesCfg", "PhysxFixedTendonPropertiesCfg", "PhysxJointDrivePropertiesCfg", + "PhysxRigidBodyCfg", "PhysxRigidBodyPropertiesCfg", "PhysxSDFMeshPropertiesCfg", "PhysxSpatialTendonPropertiesCfg", @@ -57,6 +58,7 @@ from .schemas_cfg import ( PhysxDeformableCollisionPropertiesCfg, PhysxFixedTendonPropertiesCfg, PhysxJointDrivePropertiesCfg, + PhysxRigidBodyCfg, PhysxRigidBodyPropertiesCfg, PhysxSDFMeshPropertiesCfg, PhysxSpatialTendonPropertiesCfg, 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 2eacd969e94..18a5de023f9 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -15,6 +15,7 @@ JointDriveBaseCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, + RigidBodyFragment, ) from isaaclab.utils.configclass import configclass @@ -261,6 +262,64 @@ class PhysxRigidBodyPropertiesCfg(RigidBodyBaseCfg): """The mass-normalized kinetic energy threshold below which an actor may participate in stabilization.""" +@configclass +class PhysxRigidBodyCfg(RigidBodyFragment): + """``physxRigidBody:*`` rigid-body attributes from `PhysxRigidBodyAPI`_. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) for the + PhysX rigid-body add-on schema. Applied alongside :class:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg` + via :func:`~isaaclab.sim.schemas.apply_rigid_body_properties`. + + .. _PhysxRigidBodyAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_rigid_body_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physxRigidBody" + _usd_applied_schema: ClassVar[str | None] = "PhysxRigidBodyAPI" + + linear_damping: float | None = None + """Linear damping coefficient for the body [1/s].""" + + angular_damping: float | None = None + """Angular damping coefficient for the body [1/s].""" + + max_linear_velocity: float | None = None + """Maximum linear velocity for the body [m/s].""" + + max_angular_velocity: float | None = None + """Maximum angular velocity for the body [deg/s].""" + + max_depenetration_velocity: float | None = None + """Maximum depenetration velocity permitted to be introduced by the solver [m/s].""" + + max_contact_impulse: float | None = None + """The limit on the impulse that may be applied at a contact [N·s].""" + + 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 [m²/s²].""" + + stabilization_threshold: float | None = None + """Mass-normalized kinetic energy threshold below which an actor may participate in stabilization [m²/s²].""" + + disable_gravity: bool | None = None + """Disable gravity for the body. + + PhysX honors this per-body via ``physxRigidBody:disableGravity``: setting True excludes the + body from world gravity integration. + """ + + @configclass class RigidBodyPropertiesCfg(PhysxRigidBodyPropertiesCfg): """Deprecated: use :class:`PhysxRigidBodyPropertiesCfg` or :class:`~isaaclab.sim.schemas.RigidBodyBaseCfg`. From e17287e2c00b50253b5d6c5580160a32381d374f Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Wed, 24 Jun 2026 18:04:44 -0400 Subject: [PATCH 006/139] OMPE-96696: Using ISAAC_LAB_SAVE_STAGES when running tests corrupts http:// asset URLs in saved USDs (#6255) # Description Preserve URI asset paths in save_stage Avoid resolving asset paths with explicit URI schemes as filesystem paths during stage save path remapping. This keeps remote assets such as DomeLight texture URLs from being rewritten into invalid relative paths. Fixes # ([OMPE-96696](https://jirasw.nvidia.com/browse/OMPE-96696)) ## 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 --- .../changelog.d/fix-stage-uri-paths.rst | 5 ++++ source/isaaclab/isaaclab/sim/utils/stage.py | 15 +++++++++- source/isaaclab/test/sim/test_utils_stage.py | 29 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab/changelog.d/fix-stage-uri-paths.rst diff --git a/source/isaaclab/changelog.d/fix-stage-uri-paths.rst b/source/isaaclab/changelog.d/fix-stage-uri-paths.rst new file mode 100644 index 00000000000..0ebd862325b --- /dev/null +++ b/source/isaaclab/changelog.d/fix-stage-uri-paths.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed :func:`~isaaclab.sim.save_stage` rewriting URI asset paths, such as + DomeLight texture URLs, into relative filesystem paths. diff --git a/source/isaaclab/isaaclab/sim/utils/stage.py b/source/isaaclab/isaaclab/sim/utils/stage.py index 2a9cf12ffdd..d4c87f4c2e7 100644 --- a/source/isaaclab/isaaclab/sim/utils/stage.py +++ b/source/isaaclab/isaaclab/sim/utils/stage.py @@ -59,6 +59,17 @@ def _check_ancestral_node(node) -> bool: return _check_ancestral_node(prim_index.rootNode) +def _is_uri_path(asset_path: str) -> bool: + """Return whether an asset path has an explicit URI scheme.""" + scheme = asset_path.split("://", 1)[0] + return ( + scheme != asset_path + and len(scheme) > 1 + and scheme[0].isalpha() + and all(c.isalnum() or c in "+-." for c in scheme[1:]) + ) + + def resolve_paths( src_layer_identifier: str, dst_layer_identifier: str, @@ -105,9 +116,11 @@ def resolve_paths( dst_dir = os.path.dirname(dst_layer.realPath or dst_layer.identifier) def _modify_path(asset_path: str) -> str: - if not asset_path: + if not asset_path or _is_uri_path(asset_path): return asset_path resolved = src_layer.ComputeAbsolutePath(asset_path) + if resolved and _is_uri_path(resolved): + return resolved if store_relative_path and resolved and dst_dir: try: return os.path.relpath(resolved, dst_dir) diff --git a/source/isaaclab/test/sim/test_utils_stage.py b/source/isaaclab/test/sim/test_utils_stage.py index f6d73d51866..6126720ff34 100644 --- a/source/isaaclab/test/sim/test_utils_stage.py +++ b/source/isaaclab/test/sim/test_utils_stage.py @@ -203,6 +203,35 @@ def test_save_stage_invalid_path(): sim_utils.save_stage("/tmp/test.invalid") +@pytest.mark.parametrize( + "scheme, scheme_end", + [ + ("http://", "example.com/file.usd"), + ("https://", "example.com/assets/textures/sky.hdr"), + ("omniverse://", "path/to/file.usd"), + ("s3://", "bucket-name/path/to/asset.usd"), + ], +) +def test_save_stage_preserves_uri_asset_paths(scheme, scheme_end): + """Test saving stage preserves URI asset paths.""" + from pxr import UsdLux + + with tempfile.TemporaryDirectory() as temp_dir: + stage = sim_utils.create_new_stage() + dome_light = UsdLux.DomeLight.Define(stage, "/World/skyLight") + ref = scheme + scheme_end + dome_light.CreateTextureFileAttr().Set(ref) + + save_path = Path(temp_dir) / "test_stage.usda" + result = sim_utils.save_stage(str(save_path), save_and_reload_in_place=False) + + assert result is True + saved_stage = save_path.read_text(encoding="utf-8") + assert f"@{ref}@" in saved_stage + assert f"../{scheme}" not in saved_stage + assert rf"..\{scheme}" not in saved_stage + + def test_close_stage(): """Test closing the current stage.""" # Create a stage From da251eaaaafd81aa8291c21d6df51413bda1a1fb Mon Sep 17 00:00:00 2001 From: Piotr Barejko Date: Wed, 24 Jun 2026 18:34:40 -0700 Subject: [PATCH 007/139] Update Camera Annotators for rendering backends (#6257) 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 --- .../overview/core-concepts/sensors/camera.rst | 90 +++++++++++++++---- 1 file changed, 74 insertions(+), 16 deletions(-) diff --git a/docs/source/overview/core-concepts/sensors/camera.rst b/docs/source/overview/core-concepts/sensors/camera.rst index 9505dd74b18..70aab02843e 100644 --- a/docs/source/overview/core-concepts/sensors/camera.rst +++ b/docs/source/overview/core-concepts/sensors/camera.rst @@ -38,19 +38,19 @@ The renderer used by a camera is configured via the ``renderer_cfg`` field on - Supported data types * - ``IsaacRtxRendererCfg`` *(default)* - Yes - - rgb, rgba, depth, normals, motion vectors, semantic/instance segmentation, and all other annotators + - All annotators (rgb, rgba, depth, distances, normals, motion vectors, semantic/instance segmentation) * - ``NewtonWarpRendererCfg`` - No (kit-less) - - ``rgb``, ``depth`` only + - rgb, rgba, rgb_hdr, depth, normals, instance segmentation * - ``OVRTXRendererCfg`` - No (+ ``isaaclab_ov``) - - ``rgb``, ``depth`` only + - rgb, rgba, rgb_hdr, depth, distances, semantic segmentation .. note:: - The Newton Warp renderer currently supports only **``rgb``** and **``depth``** data types. - Annotators such as segmentation, normals, and motion vectors are Isaac RTX-specific features and - require :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg`. + Backends differ in which annotators they produce. See + :ref:`the support matrix ` below for the + per-annotator breakdown across the Isaac RTX, OVRTX, and Newton Warp renderers. Tiled Rendering @@ -177,20 +177,15 @@ When using the RTX renderer, add ``--enable_cameras`` when launching: --task=Isaac-Cartpole-Camera-Direct --enable_cameras -Annotators (RTX only) -~~~~~~~~~~~~~~~~~~~~~ - -.. note:: +Annotators +~~~~~~~~~~ - Annotators are a feature of the **Isaac RTX renderer** (``IsaacRtxRendererCfg``). - They are **not** available with the Newton Warp renderer or ovrtx, which - support only ``rgb`` and ``depth``. - -:class:`~sensors.Camera` exposes the following annotator -data types when using the RTX renderer: +:class:`~sensors.Camera` exposes the following annotator data types. Not every +backend produces every annotator — see the support matrix below. * ``"rgb"``: A 3-channel rendered color image. * ``"rgba"``: A 4-channel rendered color image with alpha channel. +* ``"rgb_hdr"``: A 3-channel scene-linear HDR color image. * ``"distance_to_camera"``: Distance to the camera optical center per pixel. * ``"distance_to_image_plane"``: Distance along the camera's Z-axis per pixel. * ``"depth"``: Alias for ``"distance_to_image_plane"``. @@ -200,6 +195,69 @@ data types when using the RTX renderer: * ``"instance_segmentation_fast"``: Instance segmentation data. * ``"instance_id_segmentation_fast"``: Instance ID segmentation data. +.. _camera-supported-annotators: + +Supported annotators by renderer backend +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The following matrix shows which annotators each renderer backend can produce. +``Isaac RTX`` is :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg`, ``OVRTX`` +is :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`, and ``Newton Warp`` is +:class:`~isaaclab_newton.renderers.NewtonWarpRendererCfg`. + +.. list-table:: + :header-rows: 1 + :widths: 40 20 20 20 + + * - Data type + - Isaac RTX + - OVRTX + - Newton Warp + * - ``rgb`` + - ✅ + - ✅ + - ✅ + * - ``rgba`` + - ✅ + - ✅ + - ✅ + * - ``rgb_hdr`` + - ✅ + - ✅ + - ✅ + * - ``distance_to_camera`` + - ✅ + - ✅ + - ❌ + * - ``distance_to_image_plane`` + - ✅ + - ✅ + - ❌ + * - ``depth`` + - ✅ + - ✅ + - ✅ + * - ``normals`` + - ✅ + - ❌ + - ✅ + * - ``motion_vectors`` + - ✅ + - ❌ + - ❌ + * - ``semantic_segmentation`` + - ✅ + - ✅ + - ❌ + * - ``instance_segmentation_fast`` + - ✅ + - ❌ + - ✅ + * - ``instance_id_segmentation_fast`` + - ✅ + - ❌ + - ❌ + RGB and RGBA ~~~~~~~~~~~~ From 8e7f45561b299a136fb65ae4ded14585b86de025 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 06:26:43 +0000 Subject: [PATCH 008/139] [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: 7.1.0 → 7.2.0 - isaaclab_newton: 1.1.0 → 1.2.0 - isaaclab_ov: 0.5.0 → 0.5.1 - isaaclab_physx: 2.1.0 → 2.2.0 - isaaclab_tasks: 8.0.5 → 8.0.6 - isaaclab_visualizers: 1.0.2 → 1.0.3 --- .../changelog.d/fix-stage-uri-paths.rst | 5 --- ...ac-rtx-per-env-scene-partition-env-var.rst | 7 ---- .../vidurv-schema-fragments.minor.rst | 19 ----------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 34 +++++++++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../vidurv-schema-fragments.minor.rst | 7 ---- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 12 +++++++ source/isaaclab_newton/pyproject.toml | 2 +- .../changelog.d/extend-test-distance-aovs.rst | 6 ---- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 11 ++++++ source/isaaclab_ov/pyproject.toml | 2 +- ...ac-rtx-per-env-scene-partition-env-var.rst | 7 ---- .../vidurv-schema-fragments.minor.rst | 8 ----- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 21 ++++++++++++ source/isaaclab_physx/pyproject.toml | 2 +- .../changelog.d/extend-test-distance-aovs.rst | 7 ---- ...ac-rtx-per-env-scene-partition-env-var.rst | 6 ---- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 15 ++++++++ source/isaaclab_tasks/pyproject.toml | 2 +- ...ac-rtx-per-env-scene-partition-env-var.rst | 7 ---- .../config/extension.toml | 2 +- .../isaaclab_visualizers/docs/CHANGELOG.rst | 12 +++++++ source/isaaclab_visualizers/pyproject.toml | 2 +- 28 files changed, 117 insertions(+), 91 deletions(-) delete mode 100644 source/isaaclab/changelog.d/fix-stage-uri-paths.rst delete mode 100644 source/isaaclab/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst delete mode 100644 source/isaaclab/changelog.d/vidurv-schema-fragments.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-fragments.minor.rst delete mode 100644 source/isaaclab_ov/changelog.d/extend-test-distance-aovs.rst delete mode 100644 source/isaaclab_physx/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst delete mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-fragments.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/extend-test-distance-aovs.rst delete mode 100644 source/isaaclab_tasks/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst delete mode 100644 source/isaaclab_visualizers/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst diff --git a/source/isaaclab/changelog.d/fix-stage-uri-paths.rst b/source/isaaclab/changelog.d/fix-stage-uri-paths.rst deleted file mode 100644 index 0ebd862325b..00000000000 --- a/source/isaaclab/changelog.d/fix-stage-uri-paths.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed :func:`~isaaclab.sim.save_stage` rewriting URI asset paths, such as - DomeLight texture URLs, into relative filesystem paths. diff --git a/source/isaaclab/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst b/source/isaaclab/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst deleted file mode 100644 index d868abb3fc1..00000000000 --- a/source/isaaclab/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Added :func:`~isaaclab.utils.renderers.isaac_rtx_per_env_scene_partition_enabled` to - query whether per-environment Isaac RTX scene partitioning is enabled. Set - ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` to enable authoring of - ``primvars:omni:scenePartition`` and ``omni:scenePartition`` on the USD stage. diff --git a/source/isaaclab/changelog.d/vidurv-schema-fragments.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-fragments.minor.rst deleted file mode 100644 index 49cea07c006..00000000000 --- a/source/isaaclab/changelog.d/vidurv-schema-fragments.minor.rst +++ /dev/null @@ -1,19 +0,0 @@ -Added -^^^^^ - -* Added the single-namespace schema-fragment API: :class:`~isaaclab.sim.schemas.SchemaFragment`, - the :class:`~isaaclab.sim.schemas.RigidBodyFragment` marker, and - :class:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg`. Each fragment carries - ``_usd_namespace`` / ``_usd_applied_schema`` metadata and a ``func`` applier so a prim can - carry rigid-body properties from multiple USD namespaces at once. -* Added :func:`~isaaclab.sim.schemas.apply_namespaced` (generic fragment writer) and - :func:`~isaaclab.sim.schemas.apply_rigid_body_properties` (applies a list of rigid-body - fragments with ``UsdPhysics.RigidBodyAPI`` as the implicit anchor). - -Changed -^^^^^^^ - -* Changed the spawner ``rigid_props`` slot - (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.rigid_props`) to also accept a list of - :class:`~isaaclab.sim.schemas.RigidBodyFragment` fragments. Legacy single cfgs continue to - work through a transition bridge in the spawn writers. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index bb4ba3ffbdf..526b40a09a2 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 = "7.1.0" +version = "7.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 b08a87ffdea..715fd93b27e 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,40 @@ Changelog --------- +7.2.0 (2026-06-25) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :func:`~isaaclab.utils.renderers.isaac_rtx_per_env_scene_partition_enabled` to + query whether per-environment Isaac RTX scene partitioning is enabled. Set + ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` to enable authoring of + ``primvars:omni:scenePartition`` and ``omni:scenePartition`` on the USD stage. +* Added the single-namespace schema-fragment API: :class:`~isaaclab.sim.schemas.SchemaFragment`, + the :class:`~isaaclab.sim.schemas.RigidBodyFragment` marker, and + :class:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg`. Each fragment carries + ``_usd_namespace`` / ``_usd_applied_schema`` metadata and a ``func`` applier so a prim can + carry rigid-body properties from multiple USD namespaces at once. +* Added :func:`~isaaclab.sim.schemas.apply_namespaced` (generic fragment writer) and + :func:`~isaaclab.sim.schemas.apply_rigid_body_properties` (applies a list of rigid-body + fragments with ``UsdPhysics.RigidBodyAPI`` as the implicit anchor). + +Changed +^^^^^^^ + +* Changed the spawner ``rigid_props`` slot + (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.rigid_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.RigidBodyFragment` fragments. Legacy single cfgs continue to + work through a transition bridge in the spawn writers. + +Fixed +^^^^^ + +* Fixed :func:`~isaaclab.sim.save_stage` rewriting URI asset paths, such as + DomeLight texture URLs, into relative filesystem paths. + + 7.1.0 (2026-06-24) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index e3169ab0dc3..6951b216a78 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "7.1.0" +version = "7.2.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-fragments.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-fragments.minor.rst deleted file mode 100644 index a4cb8ef1ddd..00000000000 --- a/source/isaaclab_newton/changelog.d/vidurv-schema-fragments.minor.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_newton.sim.schemas.MujocoRigidBodyCfg`, the ``mjc:*`` single-namespace - rigid-body fragment (``mjc:gravcomp``) for Newton's MuJoCo solver. It composes with - :class:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg` and - :class:`~isaaclab_physx.sim.schemas.PhysxRigidBodyCfg` in a ``rigid_props`` fragment list. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index d2e4ba3d446..17ce08be4fa 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 = "1.1.0" +version = "1.2.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 45dfdd21f71..741050bea56 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +1.2.0 (2026-06-25) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.MujocoRigidBodyCfg`, the ``mjc:*`` single-namespace + rigid-body fragment (``mjc:gravcomp``) for Newton's MuJoCo solver. It composes with + :class:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxRigidBodyCfg` in a ``rigid_props`` fragment list. + + 1.1.0 (2026-06-24) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index 68c9fbae4eb..d5eaf633989 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_newton" -version = "1.1.0" +version = "1.2.0" description = "Extension providing IsaacLab with Newton specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ov/changelog.d/extend-test-distance-aovs.rst b/source/isaaclab_ov/changelog.d/extend-test-distance-aovs.rst deleted file mode 100644 index e2a7880fc16..00000000000 --- a/source/isaaclab_ov/changelog.d/extend-test-distance-aovs.rst +++ /dev/null @@ -1,6 +0,0 @@ -Fixed -^^^^^ - -* Fixed ``distance_to_camera`` incorrectly mapping to ``DistanceToImagePlaneSD`` in the OVRTX - renderer backend. It now correctly uses ``DistanceToCameraSD``, matching the intended semantics - of eye-space ray length versus perpendicular image-plane distance. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 5c240c1cf43..e40d1954bcd 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.5.0" +version = "0.5.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 b6bc15626d6..0a3c77eafcc 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,17 @@ Changelog --------- +0.5.1 (2026-06-25) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed ``distance_to_camera`` incorrectly mapping to ``DistanceToImagePlaneSD`` in the OVRTX + renderer backend. It now correctly uses ``DistanceToCameraSD``, matching the intended semantics + of eye-space ray length versus perpendicular image-plane distance. + + 0.5.0 (2026-06-24) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/pyproject.toml b/source/isaaclab_ov/pyproject.toml index 6a8b4704efd..9c135dda288 100644 --- a/source/isaaclab_ov/pyproject.toml +++ b/source/isaaclab_ov/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ov" -version = "0.5.0" +version = "0.5.1" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst b/source/isaaclab_physx/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst deleted file mode 100644 index 826584fd2db..00000000000 --- a/source/isaaclab_physx/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst +++ /dev/null @@ -1,7 +0,0 @@ -Changed -^^^^^^^ - -* Changed :meth:`~isaaclab_physx.renderers.IsaacRtxRenderer.prepare_stage` to skip authoring - ``primvars:omni:scenePartition`` and ``omni:scenePartition`` by default. Set the environment - variable ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` to re-enable - per-environment scene partitioning for Isaac RTX rendering. diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-fragments.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-fragments.minor.rst deleted file mode 100644 index 6bd8bcac8b8..00000000000 --- a/source/isaaclab_physx/changelog.d/vidurv-schema-fragments.minor.rst +++ /dev/null @@ -1,8 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_physx.sim.schemas.PhysxRigidBodyCfg`, the ``physxRigidBody:*`` - single-namespace rigid-body fragment (PhysX ``PhysxRigidBodyAPI``). It carries the PhysX - damping / velocity-limit / solver-iteration / sleep fields plus ``disable_gravity``, and - composes with :class:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg` in a ``rigid_props`` - fragment list. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 1640fb94caf..b5c4680e9f3 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 = "2.1.0" +version = "2.2.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 a503a8c4ca4..9ace25ecd86 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,27 @@ Changelog --------- +2.2.0 (2026-06-25) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.schemas.PhysxRigidBodyCfg`, the ``physxRigidBody:*`` + single-namespace rigid-body fragment (PhysX ``PhysxRigidBodyAPI``). It carries the PhysX + damping / velocity-limit / solver-iteration / sleep fields plus ``disable_gravity``, and + composes with :class:`~isaaclab.sim.schemas.UsdPhysicsRigidBodyCfg` in a ``rigid_props`` + fragment list. + +Changed +^^^^^^^ + +* Changed :meth:`~isaaclab_physx.renderers.IsaacRtxRenderer.prepare_stage` to skip authoring + ``primvars:omni:scenePartition`` and ``omni:scenePartition`` by default. Set the environment + variable ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` to re-enable + per-environment scene partitioning for Isaac RTX rendering. + + 2.1.0 (2026-06-24) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index 305c6d17d1b..28ad44e0d2b 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "2.1.0" +version = "2.2.0" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/extend-test-distance-aovs.rst b/source/isaaclab_tasks/changelog.d/extend-test-distance-aovs.rst deleted file mode 100644 index 735bf4d2427..00000000000 --- a/source/isaaclab_tasks/changelog.d/extend-test-distance-aovs.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Added golden-image rendering tests for ``distance_to_camera`` and ``distance_to_image_plane`` - AOV types across the Cartpole, DexSuite Kuka, and Shadow Hand camera environments. - Test-local subclasses of the relevant env and camera configs are used so the production - task API remains unchanged. diff --git a/source/isaaclab_tasks/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst b/source/isaaclab_tasks/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst deleted file mode 100644 index 0731b9c88cb..00000000000 --- a/source/isaaclab_tasks/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst +++ /dev/null @@ -1,6 +0,0 @@ -Added -^^^^^ - -* Added ``enable_scene_partition`` pytest fixture and enabled Isaac RTX per-environment scene - partitioning in rendering correctness tests for cartpole and registered camera tasks as a - temporary workaround. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index e05d772e49a..007095dd6cf 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 = "8.0.5" +version = "8.0.6" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index aff3e0f1909..0c9659104d3 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,21 @@ Changelog --------- +8.0.6 (2026-06-25) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added golden-image rendering tests for ``distance_to_camera`` and ``distance_to_image_plane`` + AOV types across the Cartpole, DexSuite Kuka, and Shadow Hand camera environments. + Test-local subclasses of the relevant env and camera configs are used so the production + task API remains unchanged. +* Added ``enable_scene_partition`` pytest fixture and enabled Isaac RTX per-environment scene + partitioning in rendering correctness tests for cartpole and registered camera tasks as a + temporary workaround. + + 8.0.5 (2026-06-24) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index 00bf52e4cf6..f55da039202 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.0.5" +version = "8.0.6" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_visualizers/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst b/source/isaaclab_visualizers/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst deleted file mode 100644 index 664c13b13b6..00000000000 --- a/source/isaaclab_visualizers/changelog.d/isaac-rtx-per-env-scene-partition-env-var.rst +++ /dev/null @@ -1,7 +0,0 @@ -Changed -^^^^^^^ - -* Changed :class:`~isaaclab_visualizers.kit.KitVisualizer` to skip authoring the - ``omni:scenePartition`` attribute on the viewport camera by default. Set - ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` to re-enable per-environment - scene partitioning for the Kit viewport camera. diff --git a/source/isaaclab_visualizers/config/extension.toml b/source/isaaclab_visualizers/config/extension.toml index 4fe46a909a0..9ae35ecda75 100644 --- a/source/isaaclab_visualizers/config/extension.toml +++ b/source/isaaclab_visualizers/config/extension.toml @@ -1,7 +1,7 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "1.0.2" +version = "1.0.3" # Description category = "isaaclab" diff --git a/source/isaaclab_visualizers/docs/CHANGELOG.rst b/source/isaaclab_visualizers/docs/CHANGELOG.rst index a6860528227..7b673de95fc 100644 --- a/source/isaaclab_visualizers/docs/CHANGELOG.rst +++ b/source/isaaclab_visualizers/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +1.0.3 (2026-06-25) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed :class:`~isaaclab_visualizers.kit.KitVisualizer` to skip authoring the + ``omni:scenePartition`` attribute on the viewport camera by default. Set + ``ISAAC_LAB_ENABLE_ISAAC_RTX_PER_ENV_SCENE_PARTITION=1`` to re-enable per-environment + scene partitioning for the Kit viewport camera. + + 1.0.2 (2026-06-24) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_visualizers/pyproject.toml b/source/isaaclab_visualizers/pyproject.toml index 970de1d7942..3f632d689f0 100644 --- a/source/isaaclab_visualizers/pyproject.toml +++ b/source/isaaclab_visualizers/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab-visualizers" -version = "1.0.2" +version = "1.0.3" description = "Visualizer backends for Isaac Lab (Kit, Newton, Rerun, Viser)." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 4847764327c733909f27c2cd38bc66340b6c0101 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Thu, 25 Jun 2026 11:00:18 +0200 Subject: [PATCH 009/139] Add OvPhysxView: a string-keyed binding manager for OVPhysX tensor bindings (view series, part 1) (#6224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **📦 Part 1 of a 5-part series** adding an OvPhysX binding view and migrating the assets onto it: > 1. **This PR** — add `OvPhysxView` (the API) + tests. > 2. Migrate the OvPhysX **Articulation** to `root_view`. > 3. Migrate the **RigidObject**. > 4. Migrate the **RigidObjectCollection** (uses the fused `prim_paths`+`key_aliases` path). > 5. Migrate the **sensors** (pose tracking; contact forces use a separate `ContactBinding` API and may not fully adopt the view). > > Parts 2–5 each depend only on this PR and can be reviewed independently. --- # Description Adds **`OvPhysxView`**, a string-keyed binding-management layer over the OVPhysX tensor bindings, in `isaaclab_ovphysx`. **Why.** OVPhysX is the odd backend out: it exposes physics attributes as a loose `dict[TensorType, TensorBinding]` with **no view object**, whereas Newton has `selection.ArticulationView` and PhysX has typed tensor views. Today callers must hold the right `TensorType` enum member, manage a destination buffer, and create bindings against a USD glob — and the `create_tensor_binding` calls are scattered across the asset classes behind a private `_get_binding`. `OvPhysxView` gives OVPhysX a single, discoverable surface as pleasant as Newton's selection API, and one owner of binding creation/caching that the asset/data classes can delegate to. It is the narrow, independently-useful slice carved out of the (shelved) cross-backend `get_property`/`set_property` effort; the full rationale and decision log live in `docs/superpowers/specs/2026-06-17-ovphysx-view-design.md`. **What it provides** - String-keyed access by lowercased `TensorType` name (auto-derived from the wheel enum — no hand-maintained table); a `TensorType` member is also accepted. - `get_attribute(name, out=)` — fresh-allocates a **typed** array (`wp.transformf` for poses, `wp.spatial_vectorf` for velocities, flat `float32` otherwise; via a maintained `_ATTR_DTYPE` map), no aliasing; **`read_into(name, dst)`** — zero-copy fill of a caller-owned, possibly structured-dtype buffer (`wp.transformf`, …) via a `float32` reinterpret view that is **cached per destination buffer** so the wheel's object-identity read cache stays warm even when callers hand a structured buffer each step; `set_attribute(name, values, indices=/mask=)` (structured sources reinterpreted); and a raw `binding_for(name)` accessor for adoption. - The fused multi-prim form (`prim_paths=[...]` + `key_aliases`) so `RigidObjectCollection` can use it. - Discoverability (`attribute_names`, `available_attributes`, `has_attribute`), metadata passthrough, and a nested error hierarchy (`UnknownAttribute` / `ReadOnlyAttribute` / `AttributeUnavailable` / `ShapeMismatch` / `DeviceMismatch`). **Device policy:** no implicit CPU↔GPU conversion — CPU-resident property types are read/written on CPU, and a buffer on the wrong device raises `DeviceMismatch` rather than being staged. **Scope / status:** this PR adds the view plus 48 mock-based unit tests (no live sim). The asset adoption that replaces `_get_binding`/`_binding_read`/`_binding_write` lives in the follow-up PRs (Articulation #6225, RigidObject #6226), which dogfood the view against a live sim on CPU and GPU and have fed fixes back here (e.g. `try_binding_for`, the read-view cache, typed `get_attribute`). Fixes # — N/A (internal design tracking; surviving slice of the shelved cross-backend view effort). ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots N/A — no user-facing visual change. ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my feature works (`test/sim/test_ovphysx_view.py`, 48 tests) - [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/index.rst | 1 + .../isaaclab_ovphysx.sim.views.rst | 4 + .../changelog.d/ovphysx-view.major.rst | 15 + .../isaaclab_ovphysx/sim/views/__init__.pyi | 2 + .../sim/views/ovphysx_view.py | 704 ++++++++++++++++++ .../test/sim/test_ovphysx_view.py | 587 +++++++++++++++ 6 files changed, 1313 insertions(+) create mode 100644 docs/source/api/lab_ovphysx/isaaclab_ovphysx.sim.views.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/ovphysx-view.major.rst create mode 100644 source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/ovphysx_view.py create mode 100644 source/isaaclab_ovphysx/test/sim/test_ovphysx_view.py diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 2ff0cf6174b..a0bfe388449 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -214,6 +214,7 @@ The following modules are available in the ``isaaclab_ovphysx`` extension: assets cloner physics + sim.views isaaclab_experimental extension diff --git a/docs/source/api/lab_ovphysx/isaaclab_ovphysx.sim.views.rst b/docs/source/api/lab_ovphysx/isaaclab_ovphysx.sim.views.rst new file mode 100644 index 00000000000..a2a93b474e5 --- /dev/null +++ b/docs/source/api/lab_ovphysx/isaaclab_ovphysx.sim.views.rst @@ -0,0 +1,4 @@ +isaaclab\_ovphysx.sim.views +=========================== + +.. automodule:: isaaclab_ovphysx.sim.views diff --git a/source/isaaclab_ovphysx/changelog.d/ovphysx-view.major.rst b/source/isaaclab_ovphysx/changelog.d/ovphysx-view.major.rst new file mode 100644 index 00000000000..a6c0e1be126 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/ovphysx-view.major.rst @@ -0,0 +1,15 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`, a string-keyed binding manager + over the OVPhysX tensor bindings. Attributes are addressed by the lowercased + ``TensorType`` name (e.g. ``view.get_attribute("articulation_dof_stiffness")``, + ``view.read_into("articulation_root_pose", buf)``, + ``view.set_attribute("rigid_body_pose", values, mask=...)``), bringing the OVPhysX + binding surface closer to the Newton selection API. The view reads/writes each binding + on its native device and raises on a device mismatch rather than staging between CPU + and GPU. :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.get_attribute` returns a typed + array for attributes with a structured layout (e.g. ``wp.transformf`` for poses, + ``wp.spatial_vectorf`` for velocities) and flat ``float32`` otherwise, and + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.read_into` reuses the ``float32`` + reinterpret of a destination buffer across calls so the wheel's read cache stays warm. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/__init__.pyi b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/__init__.pyi index cbb07417da4..b771ab472dc 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/__init__.pyi +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/__init__.pyi @@ -5,6 +5,8 @@ __all__ = [ "OvPhysxFrameView", + "OvPhysxView", ] from .ovphysx_frame_view import OvPhysxFrameView +from .ovphysx_view import OvPhysxView diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/ovphysx_view.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/ovphysx_view.py new file mode 100644 index 00000000000..099f4309e61 --- /dev/null +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/ovphysx_view.py @@ -0,0 +1,704 @@ +# 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 + +"""String-keyed view over OVPhysX ``TensorBinding`` handles. + +OVPhysX exposes physics attributes as a loose ``dict[TensorType, TensorBinding]`` +with no view object -- unlike Newton's ``selection.ArticulationView`` or PhysX's +typed tensor views. :class:`OvPhysxView` wraps those bindings for one prim pattern +(or an explicit ``prim_paths`` list) behind a string-keyed surface that mirrors +Newton's selection ergonomics, but is simpler: there is no ``Model``/``State``/ +``Control`` source object because the :class:`TensorType` already implies where the +data lives. + +Attributes are addressed by the lowercased ``TensorType`` enum member name (derived +directly from the wheel enum -- no hand-maintained table) or by the enum member +itself:: + + view.get_attribute("articulation_dof_stiffness") # allocates and returns + view.read_into("articulation_root_pose", root_pose_buf) # zero-copy into a caller buffer + view.set_attribute("rigid_body_pose", values, mask=env_mask) + +Design intent: be usable as the binding-management layer *inside* the OVPhysX asset +classes (see ``docs/superpowers/specs/2026-06-17-ovphysx-view-design.md`` §6), so it +exposes a raw :meth:`binding_for` accessor and a zero-copy :meth:`read_into` that +fills a caller-owned, possibly structured-dtype buffer via a ``float32`` reinterpret +view -- the same mechanism the data containers use today. + +**Device policy: no implicit CPU<->GPU conversion.** OVPhysX serves DOF/body +*property* tensor types from CPU memory even on a GPU sim (see :data:`_CPU_ONLY_NAMES`), +while *state* tensor types are device-resident. This view reads/writes each binding on +its native device and **raises** :class:`OvPhysxView.DeviceMismatch` if a caller hands +it a buffer on the wrong device. Staging a CPU property to/from the simulation device +is the caller's explicit responsibility, never hidden here. + +**Dtype: float32 only (interim).** The wheel's ``TensorBinding.read``/``write`` are +float32-only and expose no dtype metadata, so this view treats every binding as ``float32`` +(the structured dtypes in :data:`_ATTR_DTYPE`, e.g. ``wp.transformf``, are byte-compatible +views over float32, not a different scalar type). Every ``TensorType`` in the current wheel +is float32; a future non-float binding (e.g. a ``uint8``/``bool`` control tensor) could not be +read correctly here until the wheel exposes dtype metadata. Rather than guess a dtype-supported +subset, the public surface (:attr:`~OvPhysxView.attribute_names`) deliberately stays at +name-validity; narrowing it to a dtype-aware subset is deferred to wheel dtype metadata +(design doc §7 ask). +""" + +from __future__ import annotations + +import logging +import math +from typing import Any, Protocol + +import warp as wp + +from isaaclab_ovphysx._runtime import import_ovphysx +from isaaclab_ovphysx.tensor_types import _CPU_ONLY_TYPES + +logger = logging.getLogger(__name__) + +# Pure-Python enum (no native dependency); safe to import regardless of USD state. +TensorType = import_ovphysx("ovphysx.types").TensorType + +# Tensor types that cannot be written. The first group is read-only by PhysX +# convention (accelerations, inverse mass/inertia, projected joint force); the +# computed-dynamics group (jacobian, mass matrix, coriolis, gravity) is read-only +# in practice. +# +# TEMPORARY: this is a hand-maintained table and WILL drift as ``TensorType`` grows. +# The wheel has at least three access modes in practice -- read/write, read-only, and +# write-only control tensors -- and exposes no access metadata today. Replace this whole +# table once the wheel exposes a per-type ``access_mode`` enum (preferred over a boolean +# ``is_writable`` flag, so write-only control tensors stay distinguishable). +# TODO(ovphysx): source access mode from a wheel ``access_mode`` query (design doc §7 ask 3). +_READ_ONLY_NAMES: frozenset[str] = frozenset( + { + "rigid_body_acceleration", + "rigid_body_inv_mass", + "rigid_body_inv_inertia", + "articulation_link_acceleration", + "articulation_body_inv_mass", + "articulation_body_inv_inertia", + "articulation_dof_projected_joint_force", + "articulation_jacobian", + "articulation_mass_matrix", + "articulation_coriolis_and_centrifugal_force", + "articulation_gravity_force", + } +) + +# DOF/body property tensor types that are CPU-resident even on a GPU sim. Derived +# from the canonical, wheel-availability-gated set in ``isaaclab_ovphysx.tensor_types`` +# so the two never drift. +_CPU_ONLY_NAMES: frozenset[str] = frozenset(tt.name.lower() for tt in _CPU_ONLY_TYPES) + +# Structured Warp dtype for attributes whose flat trailing dimension has a fixed semantic +# layout: 7-float poses -> ``wp.transformf``, 6-float spatial vectors -> ``wp.spatial_vectorf``. +# :meth:`OvPhysxView.get_attribute` returns an array of this dtype, so callers get a typed +# ``[N, ...]`` array rather than a flat ``[N, ..., k]`` float32 one. Attributes absent from this +# map default to flat ``float32``. The wheel exposes only flat float32 shapes, so this map is +# hand-maintained. +# TODO(ovphysx): source structured layouts from a wheel dtype query if one is added. +_ATTR_DTYPE: dict[str, Any] = { + "articulation_root_pose": wp.transformf, + "articulation_link_pose": wp.transformf, + "articulation_body_com_pose": wp.transformf, + "rigid_body_pose": wp.transformf, + "rigid_body_com_pose": wp.transformf, + "articulation_root_velocity": wp.spatial_vectorf, + "articulation_link_velocity": wp.spatial_vectorf, + "articulation_link_acceleration": wp.spatial_vectorf, + "articulation_link_incoming_joint_force": wp.spatial_vectorf, + "rigid_body_velocity": wp.spatial_vectorf, + "rigid_body_acceleration": wp.spatial_vectorf, +} + + +class _BindingLike(Protocol): + """Structural type of an ovphysx ``TensorBinding`` as used by this view. + + ``read`` fills the passed array in place; ``write`` consumes ``indices``/``mask`` + for partial writes. ``shape`` is the binding's flat tensor shape; ``count`` is the + number of matched prims. + """ + + shape: tuple[int, ...] + count: int + prim_paths: list[str] + dof_names: list[str] + body_names: list[str] + joint_names: list[str] + dof_count: int + body_count: int + joint_count: int + is_fixed_base: bool + fixed_tendon_count: int + spatial_tendon_count: int + + def read(self, tensor: wp.array) -> None: ... + + def write(self, tensor: wp.array, indices: wp.array | None = None, mask: wp.array | None = None) -> None: ... + + +class _PhysXLike(Protocol): + """Structural type of the ovphysx ``PhysX`` instance this view depends on.""" + + def create_tensor_binding(self, *, tensor_type: Any, pattern: str = ..., prim_paths: list[str] = ...) -> Any: ... + + +# ----------------------------------------------------------------------------- +# Pure helpers (no native simulation required; testable against ``ovphysx.types``) +# ----------------------------------------------------------------------------- + + +def attribute_vocabulary() -> list[str]: + """Return every valid attribute name (sorted lowercased ``TensorType`` members).""" + return sorted(t.name.lower() for t in TensorType if t.name != "INVALID") + + +def resolve_tensor_type(name: str) -> Any: + """Resolve a lowercased attribute name to its :class:`TensorType` member. + + Args: + name: Lowercased enum name, e.g. ``"articulation_dof_stiffness"``. + + Returns: + The matching :class:`TensorType` member. + + Raises: + OvPhysxView.UnknownAttribute: If the name is not an addressable ``TensorType``. + """ + try: + tt = TensorType[name.upper()] + except KeyError: + raise OvPhysxView.UnknownAttribute( + f"Unknown attribute {name!r}. Valid names are the lowercased TensorType members, " + f"e.g. {attribute_vocabulary()[:4]} ... ({len(attribute_vocabulary())} total)." + ) from None + if tt.name == "INVALID": + raise OvPhysxView.UnknownAttribute(f"{name!r} is not an addressable attribute.") + return tt + + +def tensor_type_name(tensor_type: Any) -> str: + """Return the canonical lowercased name of a :class:`TensorType` member.""" + return tensor_type.name.lower() + + +def is_read_only(name: str) -> bool: + """Return whether an attribute name is read-only (cannot be written).""" + return name.lower() in _READ_ONLY_NAMES + + +def is_cpu_only(name: str) -> bool: + """Return whether an attribute is CPU-resident even on a GPU simulation.""" + return name.lower() in _CPU_ONLY_NAMES + + +# ----------------------------------------------------------------------------- +# The view +# ----------------------------------------------------------------------------- + + +class OvPhysxView: + """A string-keyed, generic view over OVPhysX ``TensorBinding`` handles for one prim set. + + Args: + physx: The OVPhysX ``PhysX`` instance exposing ``create_tensor_binding``. + pattern: An fnmatch glob selecting the prims this view addresses. Mutually + exclusive with ``prim_paths``. + device: Simulation device (e.g. ``"cuda:0"`` or ``"cpu"``). State bindings are + read/written on this device; CPU-only property bindings always use ``"cpu"``. + prim_paths: An explicit list of fnmatch globs for the fused multi-prim binding + form (``create_tensor_binding(prim_paths=[...])``). Mutually exclusive with + ``pattern``. + key_aliases: Optional mapping ``requested_type -> created_type`` so a binding can + be stored under a different :class:`TensorType` key than the one created + (e.g. a ``RigidObjectCollection`` stores ``rigid_body_pose`` under ``link_pose``). + This is an **internal IsaacLab adapter** for the fused-collection binding path, not + a general public API: the requested key and the created binding type deliberately + differ, so a caller reasoning from the visible key can get different runtime + semantics. A public form would instead carry descriptor metadata (requested key, + source tensor type, shape, native device, access mode); that is deferred to + wheel-exposed metadata (design doc §7 ask). Prefer not to rely on it outside the + collection adapter. + tensor_types: Explicit set of :class:`TensorType` members to instantiate eagerly. + Used only when ``eager`` is set; defaults to every applicable type. + eager: If ``True``, create bindings up front and raise if none could be created. + Defaults to ``False`` (lazy: bindings are created on first access). + """ + + class OvPhysxViewError(RuntimeError): + """Base class for all errors raised by :class:`OvPhysxView`.""" + + class UnknownAttribute(OvPhysxViewError): + """The attribute name does not resolve to an addressable ``TensorType``.""" + + class ReadOnlyAttribute(OvPhysxViewError): + """A write was attempted on a read-only attribute.""" + + class AttributeUnavailable(OvPhysxViewError): + """No binding could be created for the attribute on this view's prims.""" + + class ShapeMismatch(OvPhysxViewError): + """A supplied buffer does not match the binding's element count.""" + + class DtypeMismatch(OvPhysxViewError): + """A supplied buffer's scalar element type is not ``float32``.""" + + class DeviceMismatch(OvPhysxViewError): + """A supplied buffer is on a different device than the binding requires.""" + + def __init__( + self, + physx: _PhysXLike, + pattern: str | None = None, + device: str = "cpu", + *, + prim_paths: list[str] | None = None, + key_aliases: dict[Any, Any] | None = None, + tensor_types: list[Any] | None = None, + eager: bool = False, + ) -> None: + if (pattern is None) == (prim_paths is None): + raise ValueError("Provide exactly one of 'pattern' or 'prim_paths'.") + if pattern is not None and not pattern: + raise ValueError("'pattern' must be a non-empty glob string.") + if prim_paths is not None and not prim_paths: + raise ValueError("'prim_paths' must contain at least one glob.") + if tensor_types is not None and not eager: + raise ValueError("'tensor_types' is only honored with eager=True; pass eager=True or omit it.") + self._physx = physx + self._pattern = pattern + self._prim_paths = prim_paths + # Canonicalize the device so a "cuda" alias compares equal to a buffer's "cuda:0" + # (warp canonicalizes buffer devices). Fall back to the raw string when the device + # cannot be resolved here (e.g. constructing a cuda view on a CPU-only CI box) -- the + # string is only used for comparison, so construction must not fail on it. + try: + self._device = str(wp.get_device(device)) + except Exception: # noqa: BLE001 -- unresolvable device: keep the raw string for comparison + self._device = device + # Normalize key_aliases to TensorType members (accepts str names too) so string keys are + # honored rather than silently dropped, and reject aliases that cross the CPU/GPU residency + # or read-only boundary -- the device and read-only guards key on the requested type. + self._key_aliases: dict[Any, Any] = {} + for requested_type, created_type in (key_aliases or {}).items(): + req_tt, made_tt = self._resolve(requested_type), self._resolve(created_type) + if (req_tt in _CPU_ONLY_TYPES) != (made_tt in _CPU_ONLY_TYPES): + raise ValueError( + f"key_alias {tensor_type_name(req_tt)!r} -> {tensor_type_name(made_tt)!r} crosses the " + "CPU/GPU residency boundary; the device policy would apply to the wrong type." + ) + if is_read_only(tensor_type_name(req_tt)) != is_read_only(tensor_type_name(made_tt)): + raise ValueError( + f"key_alias {tensor_type_name(req_tt)!r} -> {tensor_type_name(made_tt)!r} mixes a read-only " + "and a writable type." + ) + self._key_aliases[req_tt] = made_tt + self._bindings: dict[Any, Any] = {} + # Cache of float32 reinterpret views for read_into / get_attribute, keyed by the + # destination buffer's id(). Reusing the same reinterpret object across calls keeps the + # wheel's object-identity read cache (the TensorBinding.read fast path) warm. + self._read_views: dict[int, wp.array] = {} + + if eager: + explicit = tensor_types is not None + requested = tensor_types if explicit else [t for t in TensorType if t.name != "INVALID"] + for tt in requested: + try: + self._binding(self._resolve(tt)) + except OvPhysxView.AttributeUnavailable: + if explicit: + raise # caller named this exact type; surface the failure rather than drop it + logger.debug("eager binding skipped for %s", tt) # default sweep: skip inapplicable types + if not self._bindings: + raise OvPhysxView.AttributeUnavailable( + f"Could not create any bindings for {self._target_repr()}; " + "the pattern/prim_paths likely match no prims." + ) + + # -- core: string-keyed get / set / read-into ------------------------------ + + def get_attribute(self, name: str | Any, *, out: wp.array | None = None) -> wp.array: + """Read the full attribute tensor. + + Reads are full-array (the wheel exposes no selective read); index into the + returned tensor for a subset. + + Args: + name: Lowercased ``TensorType`` name or the member itself. + out: Optional destination buffer to fill (must be on the binding's native + device and match its element count). If omitted, a freshly allocated + :class:`warp.array` on the native device is returned. + + Returns: + A :class:`warp.array` holding the attribute values, on the attribute's native + device -- ``cpu`` for CPU-only property types even on a GPU sim (see + :func:`is_cpu_only`). When ``out`` is omitted this is a fresh, caller-owned array; + its dtype is the attribute's structured Warp dtype when it has one (e.g. + ``wp.transformf`` for poses, ``wp.spatial_vectorf`` for velocities) and flat + ``float32`` otherwise (see :data:`_ATTR_DTYPE`). + """ + tt = self._resolve(name) + binding = self._binding(tt) + device = self._native_device(tt) + if out is not None: + self._check_device(out, device, tensor_type_name(tt), "destination") + binding.read(self._read_view(out, binding)) + return out + alloc_shape, dtype = self._attribute_dtype(tt, binding) + buf = wp.zeros(alloc_shape, dtype=dtype, device=device) + # ``buf`` is freshly allocated here, so it is never a persistent destination: route it + # through ``_as_binding_view`` directly rather than ``_read_view``. Caching by ``id(buf)`` + # could never hit on a later call and would leak one entry (and keep ``buf`` alive) per + # call in a step loop -- the read cache only pays off for a reused ``out``/``dst`` buffer. + binding.read(self._as_binding_view(buf, binding, "destination")) + return buf + + def read_into(self, name: str | Any, dst: wp.array) -> None: + """Fill ``dst`` in place from the attribute binding (zero-copy). + + ``dst`` may be a structured-dtype buffer (e.g. ``wp.transformf``); it is read + through a ``float32`` reinterpret view that matches the binding's flat shape, so + the structured GPU/CPU buffer is filled directly with no extra copy. This is the + path the asset data containers use. The reinterpret view for a given ``dst`` is + built once and reused across calls (see :meth:`_read_view`) so the wheel's + object-identity read cache stays warm -- callers can pass the structured buffer + directly each step without maintaining their own reinterpret cache. + + Args: + name: Lowercased ``TensorType`` name or the member itself. + dst: Caller-owned buffer on the binding's native device whose element count + matches the binding. + + Raises: + OvPhysxView.DeviceMismatch: If ``dst`` is not on the binding's native device. + OvPhysxView.DtypeMismatch: If ``dst``'s scalar element type is not ``float32``. + OvPhysxView.ShapeMismatch: If ``dst`` is non-contiguous or its element count does not match. + """ + tt = self._resolve(name) + binding = self._binding(tt) + self._check_device(dst, self._native_device(tt), tensor_type_name(tt), "destination") + binding.read(self._read_view(dst, binding)) + + def set_attribute( + self, + name: str | Any, + values: wp.array, + *, + indices: wp.array | None = None, + mask: wp.array | None = None, + ) -> None: + """Write a full attribute tensor; ``indices``/``mask`` select which rows apply. + + ``values`` may be a structured-dtype buffer (read through a ``float32`` + reinterpret view). If both ``indices`` and ``mask`` are given, ``mask`` wins and + the wheel emits a ``UserWarning`` -- this view forwards both verbatim to + ``TensorBinding.write`` and does not implement the precedence itself. + + Args: + name: Lowercased ``TensorType`` name or the member itself. + values: Source buffer on the binding's native device, matching its element count. + indices: Optional integer row indices to write. + mask: Optional boolean row mask to write. + + Raises: + OvPhysxView.ReadOnlyAttribute: If the attribute is read-only. + OvPhysxView.DeviceMismatch: If ``values`` is not on the binding's native device. + OvPhysxView.DtypeMismatch: If ``values``' scalar element type is not ``float32``. + OvPhysxView.ShapeMismatch: If ``values`` is non-contiguous or its element count does not match. + """ + tt = self._resolve(name) + attr = tensor_type_name(tt) + if attr in _READ_ONLY_NAMES: + raise OvPhysxView.ReadOnlyAttribute(f"Attribute {attr!r} is read-only and cannot be written.") + binding = self._binding(tt) + device = self._native_device(tt) + src = self._as_wp(values, device) + self._check_device(src, device, attr, "source") + binding.write(self._as_binding_view(src, binding, "source"), indices=indices, mask=mask) + + # -- raw binding access (for asset/data-container adoption) ---------------- + + def binding_for(self, name: str | Any) -> _BindingLike: + """Return the underlying ``TensorBinding`` for an attribute, creating it on first use. + + This is a raw escape hatch for asset-internal binding management: the returned + binding's ``read``/``write`` **bypass** the view's device, dtype-reinterpret, shape, + and read-only guards. Prefer :meth:`get_attribute` / :meth:`read_into` / + :meth:`set_attribute` unless you are deliberately managing bindings directly. + """ + return self._binding(self._resolve(name)) + + def try_binding_for(self, name: str | Any) -> _BindingLike | None: + """Like :meth:`binding_for`, but return ``None`` instead of raising when the attribute + is valid yet **not available for this view's prims** (e.g. tendon types on a + tendon-less articulation, or a not-yet-created optional binding). + + An invalid *name* still raises :class:`UnknownAttribute` -- that is a programming + error, not an availability question. Use this for the asset's ``binding or None`` + pattern over optional bindings. + """ + try: + return self._binding(self._resolve(name)) + except OvPhysxView.AttributeUnavailable: + return None + + # -- discoverability ------------------------------------------------------- + + @property + def attribute_names(self) -> list[str]: + """Every valid attribute name (the full ``TensorType`` vocabulary). + + This is name *validity*, not availability for this view's prims -- a rigid-body view + still lists ``"articulation_*"`` names. Use :attr:`available_attributes` for what is + actually instantiated. + + .. note:: + A listed name is **not** a promise of correct dtype handling. The view is + float32-only (see the module docstring); every ``TensorType`` in the current wheel + is float32, but a future non-float binding would still be listed here yet not be + correctly readable until the wheel exposes dtype metadata. Filtering this to a + dtype-aware supported subset is deferred to that metadata (design doc §7 ask). + """ + return attribute_vocabulary() + + @property + def available_attributes(self) -> list[str]: + """Names with a live binding instantiated for this view's prims.""" + return sorted(tensor_type_name(tt) for tt in self._bindings) + + def has_attribute(self, name: str | Any) -> bool: + """Return whether ``name`` is a valid attribute name (resolves to a ``TensorType``). + + This checks name *validity* for any view, not availability for these prims: it can + return ``True`` for a name whose binding does not apply to this view's prims (in which + case :meth:`get_attribute` raises :class:`AttributeUnavailable`). It likewise does not + promise dtype support -- the view is float32-only (see :attr:`attribute_names`). + """ + try: + self._resolve(name) + except OvPhysxView.UnknownAttribute: + return False + return True + + def __contains__(self, name: str | Any) -> bool: + return self.has_attribute(name) + + # -- metadata passthrough (from a sample binding) -------------------------- + + @property + def count(self) -> int: + """Number of prims matched by this view.""" + return self._sample().count + + @property + def prim_paths(self) -> list[str]: + """USD paths of the prims matched by this view.""" + return list(self._sample().prim_paths) + + @property + def dof_names(self) -> list[str]: + """Per-articulation DOF names (articulation views only).""" + return list(self._sample().dof_names) + + @property + def body_names(self) -> list[str]: + """Per-articulation body (link) names (articulation views only).""" + return list(self._sample().body_names) + + @property + def joint_names(self) -> list[str]: + """Per-articulation joint names (articulation views only).""" + return list(self._sample().joint_names) + + @property + def dof_count(self) -> int: + """Number of DOFs per articulation (articulation views only).""" + return self._sample().dof_count + + @property + def body_count(self) -> int: + """Number of bodies (links) per articulation (articulation views only).""" + return self._sample().body_count + + @property + def joint_count(self) -> int: + """Number of joints per articulation (articulation views only).""" + return self._sample().joint_count + + @property + def is_fixed_base(self) -> bool: + """Whether the articulation has a fixed base (articulation views only).""" + return self._sample().is_fixed_base + + @property + def fixed_tendon_count(self) -> int: + """Number of fixed tendons per articulation (articulation views only).""" + return self._sample().fixed_tendon_count + + @property + def spatial_tendon_count(self) -> int: + """Number of spatial tendons per articulation (articulation views only).""" + return self._sample().spatial_tendon_count + + # -- internals ------------------------------------------------------------- + + def _resolve(self, name: str | Any) -> Any: + """Resolve a string name or a ``TensorType`` member to a ``TensorType``.""" + if isinstance(name, str): + return resolve_tensor_type(name) + if isinstance(name, TensorType): + if name.name == "INVALID": # mirror the string path's INVALID rejection + raise OvPhysxView.UnknownAttribute(f"{name!r} is not an addressable attribute.") + return name + raise OvPhysxView.UnknownAttribute( + f"Attribute key must be a str name or a TensorType member, got {type(name).__name__}." + ) + + def _binding(self, tensor_type: Any) -> Any: + """Return the cached ``TensorBinding`` for ``tensor_type``, creating it on first use.""" + binding = self._bindings.get(tensor_type) + if binding is not None: + return binding + create_type = self._key_aliases.get(tensor_type, tensor_type) + kwargs: dict[str, Any] = {"tensor_type": create_type} + if self._prim_paths is not None: + kwargs["prim_paths"] = self._prim_paths + else: + kwargs["pattern"] = self._pattern + try: + binding = self._physx.create_tensor_binding(**kwargs) + except Exception as exc: # noqa: BLE001 -- wheel raises bare exceptions; surface the cause below + # The wheel raises both for "type not applicable to these prims" and for genuine + # failures (init/ABI/OOM); we can't tell them apart without a wheel-side exception + # type, so the underlying error is surfaced in the message (and chained) rather than + # hidden behind a generic "not available". TODO(ovphysx): a typed no-match error. + raise OvPhysxView.AttributeUnavailable( + f"Could not create the {tensor_type_name(tensor_type)!r} binding for " + f"{self._target_repr()}: create_tensor_binding raised {type(exc).__name__}: {exc}" + ) from exc + # The wheel returns a 0-count binding when nothing matches. Access ``count`` directly so a + # malformed binding (missing ``count``) surfaces as an error rather than a phantom no-match. + if binding is None or binding.count == 0: + raise OvPhysxView.AttributeUnavailable( + f"Attribute {tensor_type_name(tensor_type)!r} is not available for {self._target_repr()} " + "(no matching prims)." + ) + self._bindings[tensor_type] = binding + return binding + + def _sample(self) -> Any: + """Return any instantiated binding to read view-level metadata from.""" + if not self._bindings: + raise OvPhysxView.AttributeUnavailable( + "No bindings instantiated yet; access an attribute (or construct with eager=True) " + "before reading view metadata." + ) + return next(iter(self._bindings.values())) + + def _native_device(self, tensor_type: Any) -> str: + """Device a buffer for ``tensor_type`` must live on (CPU for CPU-only types).""" + return "cpu" if tensor_type in _CPU_ONLY_TYPES else self._device + + def _check_device(self, arr: wp.array, device: str, attr: str, role: str) -> None: + """Raise if ``arr`` is not on the binding's native device (no implicit conversion).""" + if str(arr.device) != device: + raise OvPhysxView.DeviceMismatch( + f"{role} for {attr!r} must be on device {device!r}, got {str(arr.device)!r}. " + "OvPhysxView does not stage between CPU and GPU; move the buffer yourself." + ) + + def _as_binding_view(self, arr: wp.array, binding: Any, role: str) -> wp.array: + """Return a ``float32`` view of ``arr`` matching the binding's flat shape. + + ``arr`` must have a ``float32`` scalar element type (``float32`` itself or a + composite built on it, e.g. ``wp.transformf``/``wp.vec3f``): the view + **reinterprets bits, not values**, so a non-``float32`` dtype (``int32``, + ``float64``, ``float16``) would corrupt the data and is rejected. Given a matching + scalar, validates the flat ``float32`` element count and returns ``arr`` directly + when it is already ``float32`` with the binding's shape, else a zero-copy + reinterpret view. + """ + scalar = getattr(arr.dtype, "_wp_scalar_type_", arr.dtype) + if scalar is not wp.float32: + raise OvPhysxView.DtypeMismatch( + f"{role} must have float32 scalar elements (got dtype " + f"{getattr(arr.dtype, '__name__', arr.dtype)}); the view reinterprets bits, " + "not values, so a non-float32 dtype would silently corrupt the buffer." + ) + if not arr.is_contiguous: + raise OvPhysxView.ShapeMismatch( + f"{role} must be a contiguous array; the view reinterprets the buffer's raw memory, " + "so a strided/sliced view would read or write the wrong elements." + ) + expected = math.prod(tuple(binding.shape)) + actual = arr.size * (wp.types.type_size_in_bytes(arr.dtype) // 4) # scalar is float32 -> exact + if actual != expected: + raise OvPhysxView.ShapeMismatch( + f"Shape mismatch for {role}: {actual} float32 elements, " + f"binding expects {expected} (shape {tuple(binding.shape)})." + ) + if arr.dtype == wp.float32 and tuple(arr.shape) == tuple(binding.shape): + return arr + return wp.array(ptr=arr.ptr, shape=tuple(binding.shape), dtype=wp.float32, device=str(arr.device), copy=False) + + def _read_view(self, dst: wp.array, binding: Any) -> wp.array: + """Return the ``float32`` view of ``dst`` to hand to ``binding.read``, reused across calls. + + The wheel's ``TensorBinding.read`` has an object-identity read cache: it skips DLPack + acquisition and the attribute-chain lookup when handed the *same* tensor object as the + previous read. To keep that cache warm, the ``float32`` reinterpret of a structured + ``dst`` is built once and reused for that destination buffer; a pointer-staleness guard + rebuilds it if the buffer's backing storage moved. A ``dst`` that is already flat + ``float32`` is its own stable identity, so it is returned directly (and not cached). + """ + cached = self._read_views.get(id(dst)) + if cached is not None and cached.ptr == dst.ptr: + return cached + view = self._as_binding_view(dst, binding, "destination") + if view is not dst: # structured dst -> cache the reinterpret; a flat float32 dst caches nothing + self._read_views[id(dst)] = view + return view + + def _attribute_dtype(self, tensor_type: Any, binding: Any) -> tuple[tuple[int, ...], Any]: + """Return ``(alloc_shape, dtype)`` for :meth:`get_attribute`. + + Maps an attribute to its structured Warp dtype (see :data:`_ATTR_DTYPE`) when the + binding's trailing dimension matches that dtype's ``float32`` count, dropping the + trailing dimension from the allocation shape (e.g. ``[N, 7] -> ([N], wp.transformf)``). + Falls back to the flat ``float32`` shape for unmapped attributes or a mismatched layout. + """ + dtype = _ATTR_DTYPE.get(tensor_type_name(tensor_type)) + shape = tuple(binding.shape) + if dtype is not None and shape and shape[-1] == wp.types.type_size_in_bytes(dtype) // 4: + return shape[:-1], dtype + return shape, wp.float32 + + def _as_wp(self, values: Any, device: str) -> wp.array: + """Coerce ``values`` to a :class:`warp.array`. + + A :class:`warp.array` is used as-is, keeping its own device (validated by the caller; + a mismatch raises and is never staged). Device-less host data (numpy arrays, lists) + carries no device, so it is materialized directly on ``device``. + + This view is Warp-native and does **not** special-case framework tensors: bridge a + Torch tensor on the caller side with ``view.set_attribute(name, wp.from_torch(t))``. + This keeps the device policy explicit and avoids an optional Torch dependency and the + fragile detection a built-in conversion would require. + """ + if isinstance(values, wp.array): + return values + return wp.array(values, device=device) + + def _target_repr(self) -> str: + return f"prim_paths={self._prim_paths!r}" if self._prim_paths is not None else f"pattern={self._pattern!r}" + + def __repr__(self) -> str: + return f"OvPhysxView({self._target_repr()}, device={self._device!r}, instantiated={len(self._bindings)})" + + +# Backward-compatible module-level alias for the error base class. +OvPhysxViewError = OvPhysxView.OvPhysxViewError diff --git a/source/isaaclab_ovphysx/test/sim/test_ovphysx_view.py b/source/isaaclab_ovphysx/test/sim/test_ovphysx_view.py new file mode 100644 index 00000000000..f5de647edc5 --- /dev/null +++ b/source/isaaclab_ovphysx/test/sim/test_ovphysx_view.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 + +"""Unit tests for the :class:`OvPhysxView` string-keyed binding manager. + +These exercise the pure-Python name<->enum logic and the view's get/set/read-into +dispatch (including the float32 reinterpret of structured buffers and the +no-implicit-conversion device policy) against a fake ``PhysX`` + fake ``TensorBinding``. + +Scope is intentionally the **API mechanics** against mock bindings. Live coverage against +real ovphysx bindings -- CPU-only properties on a GPU sim, read-only/write-only failures, +and structured ``read_into`` round-trips -- is provided by the asset-integration tests +(``test_articulation.py`` / ``test_rigid_object.py`` / ``test_rigid_object_collection.py``) +that adopt this view as ``root_view``; it is not (and is not meant to be) re-covered here. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +# The OVPhysX runtime wheel is optional. ``ovphysx.types`` is pure Python (no native +# dependency), so the import-skip guards only the wheel's presence. +pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") + +import warp as wp # noqa: E402 +from isaaclab_ovphysx.sim.views.ovphysx_view import ( # noqa: E402 + OvPhysxView, + OvPhysxViewError, + attribute_vocabulary, + is_cpu_only, + is_read_only, + resolve_tensor_type, + tensor_type_name, +) +from ovphysx.types import TensorType # noqa: E402 + +wp.init() +_HAS_CUDA = wp.get_cuda_device_count() > 0 + +# Per-type shapes used by the fakes (only the types touched by the tests). +_SHAPES = { + TensorType.RIGID_BODY_POSE: lambda n: (n, 7), + TensorType.RIGID_BODY_VELOCITY: lambda n: (n, 6), + TensorType.RIGID_BODY_MASS: lambda n: (n,), # CPU-only + TensorType.RIGID_BODY_ACCELERATION: lambda n: (n, 6), # read-only +} + + +class _FakeBinding: + """Minimal stand-in for an ovphysx ``TensorBinding``.""" + + def __init__(self, tensor_type, n: int): + self.tensor_type = tensor_type + self.shape = _SHAPES.get(tensor_type, lambda k: (k, 1))(n) + self.count = n + self.prim_paths = [f"/World/env_{i}/body" for i in range(n)] + self.dof_names: list[str] = [] + self.body_names = ["body"] + self.joint_names: list[str] = [] + self.dof_count = 0 + self.body_count = 1 + self.joint_count = 0 + self.is_fixed_base = True + self.fixed_tendon_count = 0 + self.spatial_tendon_count = 0 + self.read_calls = 0 + self.last_read: tuple | None = None + self.last_read_obj = None # the actual array object handed to read (for cache-warmth checks) + self.write_calls: list[tuple] = [] + + def read(self, dst) -> None: + self.read_calls += 1 + self.last_read = (dst.dtype, tuple(dst.shape), str(dst.device)) + self.last_read_obj = dst + + def write(self, tensor, indices=None, mask=None) -> None: + self.write_calls.append((tensor.dtype, tuple(tensor.shape), indices, mask)) + + +class _FakePhysX: + """Fake ``PhysX`` whose ``create_tensor_binding`` hands back ``_FakeBinding`` instances.""" + + def __init__(self, n: int = 3, unavailable: set | None = None, all_unavailable: bool = False): + self.n = n + self._unavailable = unavailable or set() + self._all_unavailable = all_unavailable + self.created: list[tuple] = [] + + def create_tensor_binding(self, *, tensor_type, pattern=None, prim_paths=None): + self.created.append((tensor_type, pattern, prim_paths)) + if self._all_unavailable or tensor_type in self._unavailable: + return _FakeBinding(tensor_type, 0) # wheel returns a 0-count binding on no match + return _FakeBinding(tensor_type, self.n) + + +def _make_view(n: int = 3, unavailable: set | None = None, device: str = "cpu") -> OvPhysxView: + return OvPhysxView(_FakePhysX(n=n, unavailable=unavailable), pattern="/World/env_*/body", device=device) + + +# ----------------------------------------------------------------------------- +# Pure helpers +# ----------------------------------------------------------------------------- + + +def test_vocabulary_is_lowercased_enum_without_invalid(): + vocab = attribute_vocabulary() + assert "articulation_dof_stiffness" in vocab + assert "rigid_body_pose" in vocab + assert "invalid" not in vocab + assert vocab == sorted(vocab) + + +def test_resolve_roundtrips_name_and_enum(): + tt = resolve_tensor_type("articulation_dof_stiffness") + assert tt is TensorType.ARTICULATION_DOF_STIFFNESS + assert tensor_type_name(tt) == "articulation_dof_stiffness" + assert resolve_tensor_type("RIGID_BODY_POSE") is TensorType.RIGID_BODY_POSE # case-insensitive + + +def test_resolve_unknown_name_raises(): + with pytest.raises(OvPhysxView.UnknownAttribute): + resolve_tensor_type("not_a_real_attribute") + + +def test_read_only_names_are_valid_vocabulary(): + # Every read-only name must resolve to a real TensorType, so the hand-maintained + # set stays coupled to the wheel enum (no dead names). + from isaaclab_ovphysx.sim.views import ovphysx_view as mod + + assert set(attribute_vocabulary()) >= mod._READ_ONLY_NAMES + + +def test_read_only_and_cpu_only_classification(): + assert is_read_only("articulation_jacobian") + assert is_read_only("rigid_body_acceleration") + assert not is_read_only("articulation_dof_stiffness") + assert is_cpu_only("articulation_dof_stiffness") + assert is_cpu_only("rigid_body_mass") + assert not is_cpu_only("rigid_body_pose") + + +def test_cpu_only_names_match_canonical_set(): + # The view derives its CPU-only set from tensor_types so the two cannot drift. + from isaaclab_ovphysx.sim.views import ovphysx_view as mod + from isaaclab_ovphysx.tensor_types import _CPU_ONLY_TYPES + + assert frozenset(tt.name.lower() for tt in _CPU_ONLY_TYPES) == mod._CPU_ONLY_NAMES + + +# ----------------------------------------------------------------------------- +# Construction +# ----------------------------------------------------------------------------- + + +def test_requires_exactly_one_of_pattern_or_prim_paths(): + with pytest.raises(ValueError): + OvPhysxView(_FakePhysX(), pattern="/p", prim_paths=["/p"], device="cpu") + with pytest.raises(ValueError): + OvPhysxView(_FakePhysX(), device="cpu") + + +def test_eager_creates_requested_and_exposes_metadata(): + view = OvPhysxView( + _FakePhysX(n=5), + pattern="/World/env_*/body", + device="cpu", + tensor_types=[TensorType.RIGID_BODY_POSE, TensorType.RIGID_BODY_MASS], + eager=True, + ) + assert view.available_attributes == ["rigid_body_mass", "rigid_body_pose"] + assert view.count == 5 # metadata works without an explicit get_attribute call + + +def test_eager_default_sweep_empty_view_raises(): + # Default sweep (no tensor_types) on a pattern that matches nothing -> aggregate raise. + physx = _FakePhysX(all_unavailable=True) + with pytest.raises(OvPhysxViewError, match="Could not create any bindings"): + OvPhysxView(physx, pattern="/no/match", device="cpu", eager=True) + + +# ----------------------------------------------------------------------------- +# get_attribute / read_into +# ----------------------------------------------------------------------------- + + +def test_get_attribute_allocates_fresh_typed_buffer_each_call(): + view = _make_view(n=4) + buf = view.get_attribute("rigid_body_pose") + # Pose maps to a structured dtype: an [N] transformf array (== [N, 7] float32). + assert tuple(buf.shape) == (4,) and buf.dtype == wp.transformf + binding = view._bindings[TensorType.RIGID_BODY_POSE] + assert binding.read_calls == 1 + buf2 = view.get_attribute("rigid_body_pose") + assert buf2 is not buf # no aliasing of view state + assert binding.read_calls == 2 + + +def test_get_attribute_no_out_does_not_grow_read_cache(): + # A no-`out` get_attribute allocates a fresh buffer each call, so caching its reinterpret by + # id() could never hit on a later call and would leak one entry (keeping the buffer alive) per + # call in a step loop. The structured-dtype path must reinterpret directly, never touching the + # cache -- the read cache only pays off for a reused `out`/`dst`. Pose is the structured case + # (transformf), the one that would have been cached before the fix. + view = _make_view(n=3) + for _ in range(5): + view.get_attribute("rigid_body_pose") + assert view._read_views == {} + + +def test_get_attribute_types_pose_and_velocity_falls_back_to_float32(): + view = _make_view(n=3) + assert view.get_attribute("rigid_body_pose").dtype == wp.transformf + assert view.get_attribute("rigid_body_velocity").dtype == wp.spatial_vectorf + # An attribute absent from the structured-dtype map stays flat float32. + mass = view.get_attribute("rigid_body_mass") + assert mass.dtype == wp.float32 and tuple(mass.shape) == (3,) + + +def test_read_into_reuses_reinterpret_view_across_calls(): + # The float32 reinterpret of a structured dst is built once and reused so the wheel's + # object-identity read cache stays warm across steps. + view = _make_view(n=3) + dst = wp.zeros((3,), dtype=wp.transformf, device="cpu") + view.read_into("rigid_body_pose", dst) + binding = view._bindings[TensorType.RIGID_BODY_POSE] + first = binding.last_read_obj + view.read_into("rigid_body_pose", dst) + assert binding.last_read_obj is first # same object handed to the wheel both times + assert first is not dst # it is the float32 reinterpret, not the transformf buffer + + +def test_read_into_passthrough_reuses_dst_object(): + # A flat float32 dst is its own stable identity -- passed straight through, not cached. + view = _make_view(n=3) + dst = wp.zeros((3, 7), dtype=wp.float32, device="cpu") + view.read_into("rigid_body_pose", dst) + binding = view._bindings[TensorType.RIGID_BODY_POSE] + assert binding.last_read_obj is dst + assert id(dst) not in view._read_views # float32 dst is not cached + + +def test_read_into_caches_per_destination_buffer(): + view = _make_view(n=3) + a = wp.zeros((3,), dtype=wp.transformf, device="cpu") + b = wp.zeros((3,), dtype=wp.transformf, device="cpu") + binding = view._bindings.get(TensorType.RIGID_BODY_POSE) or view.binding_for("rigid_body_pose") + view.read_into("rigid_body_pose", a) + view_a = binding.last_read_obj + view.read_into("rigid_body_pose", b) + view_b = binding.last_read_obj + assert view_a is not view_b # distinct reinterprets per destination buffer + assert view_a.ptr == a.ptr and view_b.ptr == b.ptr + + +def test_get_attribute_out_param_is_filled_and_returned(): + view = _make_view(n=2) + out = wp.zeros((2, 7), dtype=wp.float32, device="cpu") + ret = view.get_attribute("rigid_body_pose", out=out) + assert ret is out + assert view._bindings[TensorType.RIGID_BODY_POSE].read_calls == 1 + + +def test_read_into_reinterprets_structured_buffer(): + view = _make_view(n=3) + dst = wp.zeros((3,), dtype=wp.transformf, device="cpu") # [N] transformf == [N,7] float32 + view.read_into("rigid_body_pose", dst) + binding = view._bindings[TensorType.RIGID_BODY_POSE] + # The binding was handed a float32 view matching its flat shape, not the transformf buffer. + assert binding.last_read == (wp.float32, (3, 7), "cpu") + + +def test_read_into_passthrough_when_already_float32(): + view = _make_view(n=3) + dst = wp.zeros((3, 7), dtype=wp.float32, device="cpu") + view.read_into("rigid_body_pose", dst) + assert view._bindings[TensorType.RIGID_BODY_POSE].last_read == (wp.float32, (3, 7), "cpu") + + +def test_read_into_shape_mismatch_raises(): + view = _make_view(n=3) + wrong = wp.zeros((3, 6), dtype=wp.float32, device="cpu") + with pytest.raises(OvPhysxView.ShapeMismatch): + view.read_into("rigid_body_pose", wrong) + + +# ----------------------------------------------------------------------------- +# Device policy (no implicit CPU<->GPU conversion) +# ----------------------------------------------------------------------------- + + +def test_cpu_array_for_device_state_on_gpu_sim_raises(): + # GPU sim, but a CPU buffer is supplied for a device-resident state attribute. + view = _make_view(n=3, device="cuda:0") + cpu_buf = wp.zeros((3, 7), dtype=wp.float32, device="cpu") + with pytest.raises(OvPhysxView.DeviceMismatch, match="cuda:0"): + view.read_into("rigid_body_pose", cpu_buf) + + +def test_cpu_only_property_accepts_cpu_buffer_on_gpu_sim(): + # GPU sim, CPU-only property (mass): a CPU buffer is correct and must NOT raise. + view = _make_view(n=3, device="cuda:0") + cpu_buf = wp.zeros((3,), dtype=wp.float32, device="cpu") + view.read_into("rigid_body_mass", cpu_buf) # no raise + assert view._bindings[TensorType.RIGID_BODY_MASS].read_calls == 1 + + +@pytest.mark.skipif(not _HAS_CUDA, reason="needs a CUDA device to allocate a GPU buffer") +def test_gpu_array_for_cpu_only_property_raises(): + view = _make_view(n=3, device="cuda:0") + gpu_buf = wp.zeros((3,), dtype=wp.float32, device="cuda:0") + with pytest.raises(OvPhysxView.DeviceMismatch, match="cpu"): + view.read_into("rigid_body_mass", gpu_buf) + + +# ----------------------------------------------------------------------------- +# set_attribute +# ----------------------------------------------------------------------------- + + +def test_set_attribute_forwards_indices_and_mask(): + view = _make_view(n=3) + values = wp.zeros((3, 7), dtype=wp.float32, device="cpu") + idx = wp.array([0, 2], dtype=wp.int32, device="cpu") + view.set_attribute("rigid_body_pose", values, indices=idx) + dtype, shape, indices, mask = view._bindings[TensorType.RIGID_BODY_POSE].write_calls[0] + assert (dtype, shape, indices, mask) == (wp.float32, (3, 7), idx, None) + + +def test_set_attribute_reinterprets_structured_source(): + view = _make_view(n=3) + values = wp.zeros((3,), dtype=wp.transformf, device="cpu") + view.set_attribute("rigid_body_pose", values) + dtype, shape, _, _ = view._bindings[TensorType.RIGID_BODY_POSE].write_calls[0] + assert (dtype, shape) == (wp.float32, (3, 7)) + + +def test_set_attribute_read_only_raises_and_does_not_bind(): + view = _make_view(n=3) + values = wp.zeros((3, 6), dtype=wp.float32, device="cpu") + with pytest.raises(OvPhysxView.ReadOnlyAttribute, match="read-only"): + view.set_attribute("rigid_body_acceleration", values) + assert TensorType.RIGID_BODY_ACCELERATION not in view._bindings + + +def test_set_attribute_shape_mismatch_raises(): + view = _make_view(n=3) + wrong = wp.zeros((3, 6), dtype=wp.float32, device="cpu") + with pytest.raises(OvPhysxView.ShapeMismatch, match="Shape mismatch"): + view.set_attribute("rigid_body_pose", wrong) + + +def test_set_attribute_cpu_array_for_state_on_gpu_sim_raises(): + view = _make_view(n=3, device="cuda:0") + values = wp.zeros((3, 7), dtype=wp.float32, device="cpu") + with pytest.raises(OvPhysxView.DeviceMismatch): + view.set_attribute("rigid_body_pose", values) + + +def test_as_wp_accepts_numpy_float32_and_rejects_float64(): + view = _make_view(n=2) + # float32 host data is materialized on the native device and written. + view.set_attribute("rigid_body_pose", np.zeros((2, 7), dtype=np.float32)) + assert len(view._bindings[TensorType.RIGID_BODY_POSE].write_calls) == 1 + # float64 is not float32-bit-equivalent; reject rather than silently reinterpret. + with pytest.raises(OvPhysxView.DtypeMismatch, match="float32 scalar"): + view.set_attribute("rigid_body_pose", np.zeros((2, 7), dtype=np.float64)) + + +# ----------------------------------------------------------------------------- +# prim_paths + key aliases (RigidObjectCollection fused-binding shape) +# ----------------------------------------------------------------------------- + + +def test_prim_paths_with_key_alias_creates_remapped_type(): + physx = _FakePhysX(n=6) + view = OvPhysxView( + physx, + prim_paths=["/World/env_*/cube", "/World/env_*/sphere"], + device="cpu", + key_aliases={TensorType.ARTICULATION_LINK_POSE: TensorType.RIGID_BODY_POSE}, + ) + binding = view.binding_for("articulation_link_pose") + # Created as RIGID_BODY_POSE via prim_paths, cached under the requested LINK_POSE key. + created_type, pattern, prim_paths = physx.created[0] + assert created_type is TensorType.RIGID_BODY_POSE + assert pattern is None and prim_paths == ["/World/env_*/cube", "/World/env_*/sphere"] + assert view._bindings[TensorType.ARTICULATION_LINK_POSE] is binding + # And a write through the aliased key resolves to that same cached binding. + view.set_attribute("articulation_link_pose", wp.zeros((6, 7), dtype=wp.float32, device="cpu")) + assert len(binding.write_calls) == 1 and binding is view._bindings[TensorType.ARTICULATION_LINK_POSE] + + +# ----------------------------------------------------------------------------- +# Errors / discoverability / metadata +# ----------------------------------------------------------------------------- + + +def test_unknown_attribute_raises_on_access(): + view = _make_view() + with pytest.raises(OvPhysxView.UnknownAttribute): + view.get_attribute("totally_made_up") + + +def test_unavailable_binding_reports_clear_error(): + view = _make_view(n=3, unavailable={TensorType.RIGID_BODY_VELOCITY}) + with pytest.raises(OvPhysxView.AttributeUnavailable, match="not available"): + view.get_attribute("rigid_body_velocity") + + +def test_discoverability_surface(): + view = _make_view() + assert "rigid_body_pose" in view + assert view.has_attribute("articulation_dof_stiffness") + assert not view.has_attribute("nope") + assert "rigid_body_pose" in view.attribute_names + assert view.available_attributes == [] + view.get_attribute("rigid_body_pose") + assert view.available_attributes == ["rigid_body_pose"] + + +def test_metadata_passthrough_from_sample_binding(): + view = _make_view(n=5) + with pytest.raises(OvPhysxView.AttributeUnavailable): + _ = view.count # metadata before any access raises a clear error + view.get_attribute("rigid_body_pose") + assert view.count == 5 + assert len(view.prim_paths) == 5 + assert view.body_names == ["body"] + assert view.is_fixed_base is True + assert view.joint_count == 0 and view.fixed_tendon_count == 0 + + +# ----------------------------------------------------------------------------- +# dtype safety — the view reinterprets bits, so non-float32 scalars must be rejected +# ----------------------------------------------------------------------------- + + +def test_set_attribute_rejects_same_byte_size_wrong_dtype(): + # int32 has the same 4-byte width as float32: it would pass a byte-count-only guard + # and get bit-reinterpreted into garbage. It must be rejected, not silently written. + view = _make_view(n=3) + int_buf = wp.zeros((3, 7), dtype=wp.int32, device="cpu") + with pytest.raises(OvPhysxView.DtypeMismatch, match="float32 scalar"): + view.set_attribute("rigid_body_pose", int_buf) + assert view._bindings[TensorType.RIGID_BODY_POSE].write_calls == [] + + +def test_set_attribute_rejects_sub_4byte_dtype(): + view = _make_view(n=3) + half_buf = wp.zeros((3, 7), dtype=wp.float16, device="cpu") + with pytest.raises(OvPhysxView.DtypeMismatch, match="float32 scalar"): + view.set_attribute("rigid_body_pose", half_buf) + + +def test_set_attribute_forwards_both_indices_and_mask(): + # The view forwards both verbatim; the wheel resolves precedence (mask wins). + view = _make_view(n=3) + values = wp.zeros((3, 7), dtype=wp.float32, device="cpu") + idx = wp.array([0, 2], dtype=wp.int32, device="cpu") + mask = wp.array([True, False, True], dtype=wp.bool, device="cpu") + view.set_attribute("rigid_body_pose", values, indices=idx, mask=mask) + _, _, fwd_idx, fwd_mask = view._bindings[TensorType.RIGID_BODY_POSE].write_calls[0] + assert fwd_idx is idx and fwd_mask is mask + + +def test_get_attribute_out_on_wrong_device_raises(): + # `get_attribute(out=)` has its own device check distinct from read_into's. + view = _make_view(n=3, device="cuda:0") + cpu_out = wp.zeros((3, 7), dtype=wp.float32, device="cpu") + with pytest.raises(OvPhysxView.DeviceMismatch): + view.get_attribute("rigid_body_pose", out=cpu_out) + + +def test_resolve_rejects_non_str_non_tensortype(): + view = _make_view() + with pytest.raises(OvPhysxView.UnknownAttribute): + view.get_attribute(123) + + +def test_set_attribute_rejects_non_contiguous_source(): + # A strided slice has the right element count but non-contiguous memory; the ptr-based + # reinterpret would read the wrong elements, so it must be rejected. + view = _make_view(n=3) + base = wp.zeros((3, 14), dtype=wp.float32, device="cpu") + strided = base[:, :7] + assert not strided.is_contiguous + with pytest.raises(OvPhysxView.ShapeMismatch, match="contiguous"): + view.set_attribute("rigid_body_pose", strided) + + +# ----------------------------------------------------------------------------- +# API hardening — adversarial construction / resolution +# ----------------------------------------------------------------------------- + + +def test_invalid_tensortype_member_rejected(): + view = _make_view() + with pytest.raises(OvPhysxView.UnknownAttribute): + view.get_attribute(TensorType.INVALID) + + +def test_string_keyed_aliases_are_honored(): + # Passing string alias keys/values must be normalized to TensorType, not silently dropped. + physx = _FakePhysX(n=6) + view = OvPhysxView( + physx, + prim_paths=["/World/env_*/cube"], + device="cpu", + key_aliases={"articulation_link_pose": "rigid_body_pose"}, + ) + view.binding_for("articulation_link_pose") + assert physx.created[0][0] is TensorType.RIGID_BODY_POSE # alias applied + + +def test_key_alias_crossing_residency_is_rejected(): + # LINK_POSE is GPU state; RIGID_BODY_MASS is CPU-only -> the device guard would be wrong. + with pytest.raises(ValueError, match="residency"): + OvPhysxView( + _FakePhysX(), + pattern="/p", + device="cpu", + key_aliases={TensorType.ARTICULATION_LINK_POSE: TensorType.RIGID_BODY_MASS}, + ) + + +def test_tensor_types_without_eager_raises(): + with pytest.raises(ValueError, match="eager"): + OvPhysxView(_FakePhysX(), pattern="/p", device="cpu", tensor_types=[TensorType.RIGID_BODY_POSE]) + + +def test_empty_target_is_rejected(): + with pytest.raises(ValueError): + OvPhysxView(_FakePhysX(), prim_paths=[], device="cpu") + with pytest.raises(ValueError): + OvPhysxView(_FakePhysX(), pattern="", device="cpu") + + +def test_eager_explicit_unavailable_type_raises_loud(): + # When the caller names exact types, a failing one is surfaced (not silently dropped). + physx = _FakePhysX(n=3, unavailable={TensorType.RIGID_BODY_VELOCITY}) + with pytest.raises(OvPhysxView.AttributeUnavailable): + OvPhysxView( + physx, + pattern="/World/env_*/body", + device="cpu", + tensor_types=[TensorType.RIGID_BODY_POSE, TensorType.RIGID_BODY_VELOCITY], + eager=True, + ) + + +def test_get_attribute_cpu_only_property_returns_cpu_buffer_on_gpu_sim(): + # No-out allocation path must use the native device: CPU for a CPU-only property even + # though the sim device is a GPU. (CPU allocation -> runs without a GPU.) + view = _make_view(n=3, device="cuda:0") + buf = view.get_attribute("rigid_body_mass") + assert str(buf.device) == "cpu" + + +def test_binding_for_is_idempotent_and_unguarded(): + view = _make_view(n=3) + # Returns a binding even for a read-only attribute (raw access bypasses the write guard)... + b1 = view.binding_for("rigid_body_acceleration") + b2 = view.binding_for("rigid_body_acceleration") + assert b1 is b2 # cached / created once + assert len(view._physx.created) == 1 + + +def test_try_binding_for_returns_none_when_unavailable(): + view = _make_view(n=3, unavailable={TensorType.RIGID_BODY_VELOCITY}) + # Available for these prims -> the (cached) binding; unavailable -> None, no raise. + assert view.try_binding_for("rigid_body_pose") is view._bindings[TensorType.RIGID_BODY_POSE] + assert view.try_binding_for("rigid_body_velocity") is None + # An invalid name is still a hard error, not an availability question. + with pytest.raises(OvPhysxView.UnknownAttribute): + view.try_binding_for("not_a_real_attribute") + + +@pytest.mark.skipif(not _HAS_CUDA, reason="needs a CUDA device for the cuda:0 buffer") +def test_device_cuda_alias_is_canonicalized(): + # A view built with the bare "cuda" alias must accept a canonical "cuda:0" buffer. + view = _make_view(n=3, device="cuda") + gpu_buf = wp.zeros((3, 7), dtype=wp.float32, device="cuda:0") + view.read_into("rigid_body_pose", gpu_buf) # must not raise + assert view._bindings[TensorType.RIGID_BODY_POSE].read_calls == 1 From 205d5eeee151f150904b399fa75939a795164bd9 Mon Sep 17 00:00:00 2001 From: rilei-nvidia Date: Thu, 25 Jun 2026 22:48:37 +1200 Subject: [PATCH 010/139] Added support for normals in ovrtx and render correctness validation for normals for all renderers (#6261) # Description - Added support for the "normals" data type in the ovrtx renderer integration - Added render correctness test coverage for "normals" across applicable physics/renderer combinations ## Screenshots See new golden images ## 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 - [] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../overview/core-concepts/sensors/camera.rst | 4 +-- .../normals-rendering-validation.rst | 7 +++++ .../isaaclab_ov/renderers/ovrtx_renderer.py | 17 ++++++++++ .../isaaclab_ov/renderers/ovrtx_usd.py | 9 ++++-- .../test/test_ovrtx_renderer_contract.py | 1 + .../normals-rendering-validation.rst | 7 +++++ .../core/dexsuite/dexsuite_env_cfg.py | 2 +- .../shadow_hand/shadow_hand_camera_env_cfg.py | 2 +- .../newton-isaacsim_rtx_renderer-normals.png | 3 ++ .../newton-newton_renderer-normals.png | 3 ++ .../newton-ovrtx_renderer-normals.png | 3 ++ .../ovphysx-newton_renderer-normals.png | 3 ++ .../ovphysx-ovrtx_renderer-normals.png | 3 ++ .../physx-isaacsim_rtx_renderer-normals.png | 3 ++ .../physx-newton_renderer-normals.png | 3 ++ .../newton-isaacsim_rtx_renderer-normals.png | 3 ++ .../newton-newton_renderer-normals.png | 3 ++ .../newton-ovrtx_renderer-normals.png | 3 ++ .../physx-isaacsim_rtx_renderer-normals.png | 3 ++ .../physx-newton_renderer-normals.png | 3 ++ .../newton-isaacsim_rtx_renderer-normals.png | 3 ++ .../newton-newton_renderer-normals.png | 3 ++ .../newton-ovrtx_renderer-normals.png | 3 ++ .../physx-isaacsim_rtx_renderer-normals.png | 3 ++ .../physx-newton_renderer-normals.png | 3 ++ .../newton-isaacsim_rtx_renderer-normals.png | 3 ++ .../newton-newton_renderer-normals.png | 3 ++ .../newton-ovrtx_renderer-normals.png | 3 ++ .../physx-isaacsim_rtx_renderer-normals.png | 3 ++ .../physx-newton_renderer-normals.png | 3 ++ .../test/rendering_test_utils.py | 31 ++++++++++++++++++- 31 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/normals-rendering-validation.rst create mode 100644 source/isaaclab_tasks/changelog.d/normals-rendering-validation.rst create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-newton_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-newton_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/physx-newton_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-newton_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-newton_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-newton_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-newton_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-newton_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-normals.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-newton_renderer-normals.png diff --git a/docs/source/overview/core-concepts/sensors/camera.rst b/docs/source/overview/core-concepts/sensors/camera.rst index 70aab02843e..23a6f5f11da 100644 --- a/docs/source/overview/core-concepts/sensors/camera.rst +++ b/docs/source/overview/core-concepts/sensors/camera.rst @@ -44,7 +44,7 @@ The renderer used by a camera is configured via the ``renderer_cfg`` field on - rgb, rgba, rgb_hdr, depth, normals, instance segmentation * - ``OVRTXRendererCfg`` - No (+ ``isaaclab_ov``) - - rgb, rgba, rgb_hdr, depth, distances, semantic segmentation + - rgb, rgba, rgb_hdr, depth, distances, normals, semantic segmentation .. note:: @@ -239,7 +239,7 @@ is :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`, and ``Newton Warp`` is - ✅ * - ``normals`` - ✅ - - ❌ + - ✅ - ✅ * - ``motion_vectors`` - ✅ diff --git a/source/isaaclab_ov/changelog.d/normals-rendering-validation.rst b/source/isaaclab_ov/changelog.d/normals-rendering-validation.rst new file mode 100644 index 00000000000..c96fc90bb03 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/normals-rendering-validation.rst @@ -0,0 +1,7 @@ +Added +^^^^^ + +* Added ``"normals"`` support to :class:`~isaaclab_ov.renderers.OVRTXRenderer`. The renderer now + declares :attr:`~isaaclab.renderers.RenderBufferKind.NORMALS` in + :meth:`~isaaclab_ov.renderers.OVRTXRenderer.supported_output_types` (3-channel ``float32``) and + extracts the ``NormalSD`` AOV from each rendered frame into the output buffer. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 359e1c4bd36..1a515075f52 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -246,6 +246,7 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: 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), } @property @@ -804,6 +805,22 @@ def _process_render_frame(self, render_data: OVRTXRenderData, frame, output_buff suffix="semantic", ) + if "NormalSD" in frame.render_vars and "normals" in output_buffers: + with frame.render_vars["NormalSD"].map(device=Device.CUDA) as mapping: + tiled_normals_data = wp.from_dlpack(mapping.tensor) + wp.launch( + kernel=extract_all_rgb_float_tiles_kernel, + dim=(render_data.num_envs, render_data.height, render_data.width), + inputs=[ + tiled_normals_data, + output_buffers["normals"], + render_data.num_cols, + render_data.width, + render_data.height, + ], + device=self._device, + ) + def render(self, render_data: OVRTXRenderData) -> None: """Render the scene into the provided RenderData.""" if not self._initialized_scene: diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py index be91d46f14e..be89987d64d 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py @@ -23,16 +23,19 @@ def get_render_var_config(data_types: list[str]) -> tuple[str, str, str]: ) use_albedo = "albedo" in data_types use_semantic = "semantic_segmentation" in data_types + use_normals = "normals" in data_types use_rgb = any(dt in ["rgb", "rgba"] for dt in data_types) use_hdr = "rgb_hdr" in data_types - if use_depth and not (use_rgb or use_albedo or use_semantic): + if use_depth and not (use_rgb or use_albedo or use_semantic or use_normals): source = "DistanceToCameraSD" if use_distance_to_camera else "DistanceToImagePlaneSD" return "/Render/Vars/depth", "depth", source - if use_albedo and not (use_rgb or use_semantic): + if use_albedo and not (use_rgb or use_semantic or use_normals): return "/Render/Vars/albedo", "albedo", "DiffuseAlbedoSD" - if use_semantic and not (use_rgb or use_albedo): + if use_semantic and not (use_rgb or use_albedo or use_normals): return "/Render/Vars/semantic", "semantic", "SemanticSegmentation" + if use_normals and not (use_rgb or use_albedo or use_semantic or use_depth): + return "/Render/Vars/NormalSD", "NormalSD", "NormalSD" if use_hdr and not use_rgb: return "/Render/Vars/HdrColor", "HdrColor", "HdrColor" return "/Render/Vars/LdrColor", "LdrColor", "LdrColor" diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index bb54653669b..b1be655a42d 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -85,6 +85,7 @@ def test_ovrtx_supported_output_types_key_set(): RenderBufferKind.DEPTH, RenderBufferKind.DISTANCE_TO_IMAGE_PLANE, RenderBufferKind.DISTANCE_TO_CAMERA, + RenderBufferKind.NORMALS, } assert specs[RenderBufferKind.RGBA] == RenderBufferSpec(4, wp.uint8) assert specs[RenderBufferKind.RGB_HDR] == RenderBufferSpec(3, wp.float32) diff --git a/source/isaaclab_tasks/changelog.d/normals-rendering-validation.rst b/source/isaaclab_tasks/changelog.d/normals-rendering-validation.rst new file mode 100644 index 00000000000..39a8171a4d5 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/normals-rendering-validation.rst @@ -0,0 +1,7 @@ +Added +^^^^^ + +* Added ``"normals"`` to the rendering correctness validation suite. The ``normals`` data type + (float32 surface normal vectors in ``[-1, 1]``) is now included in + :data:`~rendering_test_utils._DEFAULT_SENSOR_DATA_TYPES` for all RTX-based renderer + combinations and as an explicit parameter for the Newton Warp renderer. diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/dexsuite/dexsuite_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/dexsuite/dexsuite_env_cfg.py index 019f1c9e07f..9330f3b225b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/dexsuite/dexsuite_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/dexsuite/dexsuite_env_cfg.py @@ -470,7 +470,7 @@ class DexsuiteReorientEnvCfg(ManagerBasedEnvCfg): def validate_config(self): """Check for invalid preset combinations after resolution.""" - warp_supported = {"rgb", "depth", "distance_to_image_plane"} + warp_supported = {"rgb", "depth", "distance_to_image_plane", "normals"} for cam_attr in ("base_camera", "wrist_camera"): cam = getattr(self.scene, cam_attr, None) if cam is None: diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env_cfg.py index 55066df4e8d..243cc7cbfad 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env_cfg.py @@ -124,7 +124,7 @@ class ShadowHandCameraEnvCfg(ShadowHandEnvCfg): def validate_config(self): """Check renderer/data-type and feature-extractor compatibility.""" renderer_type = getattr(self.tiled_camera.renderer_cfg, "renderer_type", None) - warp_supported = {"rgb", "depth"} + warp_supported = {"rgb", "depth", "normals"} if renderer_type == "newton_warp": unsupported = set(self.tiled_camera.data_types) - warp_supported if unsupported: diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-normals.png new file mode 100644 index 00000000000..64d86353128 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:516de455bbf77ceafc5c8496e5f7ec6e154ac9efb7c3b51ae3d3a144165be69b +size 764 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-newton_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-newton_renderer-normals.png new file mode 100644 index 00000000000..f420406f93a --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-newton_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e933536d04c93bc9df198a4ac3fcfa8ff4edc25d7c0ee67f23eaaa9aadb91f59 +size 766 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-normals.png new file mode 100644 index 00000000000..64d86353128 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:516de455bbf77ceafc5c8496e5f7ec6e154ac9efb7c3b51ae3d3a144165be69b +size 764 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-newton_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-newton_renderer-normals.png new file mode 100644 index 00000000000..f420406f93a --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-newton_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e933536d04c93bc9df198a4ac3fcfa8ff4edc25d7c0ee67f23eaaa9aadb91f59 +size 766 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-normals.png new file mode 100644 index 00000000000..64d86353128 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:516de455bbf77ceafc5c8496e5f7ec6e154ac9efb7c3b51ae3d3a144165be69b +size 764 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-normals.png new file mode 100644 index 00000000000..64d86353128 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:516de455bbf77ceafc5c8496e5f7ec6e154ac9efb7c3b51ae3d3a144165be69b +size 764 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-newton_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-newton_renderer-normals.png new file mode 100644 index 00000000000..f420406f93a --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-newton_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e933536d04c93bc9df198a4ac3fcfa8ff4edc25d7c0ee67f23eaaa9aadb91f59 +size 766 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-normals.png new file mode 100644 index 00000000000..979c2226c8e --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33cce7fdc53de5e4629ef9a45dc0ce850afeb3fcc0ed10ac2005442641ec5b6f +size 1941 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-newton_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-newton_renderer-normals.png new file mode 100644 index 00000000000..279d091dc50 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-newton_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4627d5925011dae9021c1b455030903c1e6145f41ca7cc5d3a720affdd6bfd48 +size 1966 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-normals.png new file mode 100644 index 00000000000..979c2226c8e --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33cce7fdc53de5e4629ef9a45dc0ce850afeb3fcc0ed10ac2005442641ec5b6f +size 1941 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-normals.png new file mode 100644 index 00000000000..c1f9ac74ad9 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d68551c1e2c1deca570da094a0515e700b3a5dcddd324807b228862a09d592e +size 1940 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-newton_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-newton_renderer-normals.png new file mode 100644 index 00000000000..8eb9b708a62 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-newton_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1af0e88caf3f25cff399dcab1f8f95a09e2f4890062c5666ca22c009978c16f +size 1956 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-normals.png new file mode 100644 index 00000000000..e5a5f7909d7 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:011b53a7a713c00dc5e1892285aa4785defe99b2bcbaa7e551dc05b9abe974de +size 1752 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-newton_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-newton_renderer-normals.png new file mode 100644 index 00000000000..9531591c331 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-newton_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3ba7f28c411e39f3094100c03fdd168f48115be28dd882c81a3d193eba50d60 +size 1754 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-normals.png new file mode 100644 index 00000000000..e5a5f7909d7 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:011b53a7a713c00dc5e1892285aa4785defe99b2bcbaa7e551dc05b9abe974de +size 1752 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-normals.png new file mode 100644 index 00000000000..f7e788a9655 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3009f0ef2b7e67bdeeeaef56d1b8bb800bd3159f3782d1c588f227137238f3e3 +size 1754 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-newton_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-newton_renderer-normals.png new file mode 100644 index 00000000000..8961b84a345 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-newton_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2cf94b13ed7c4246be2b09767d1be41338446bb0e016cdb0ee4534e6275b0e87 +size 1759 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-normals.png new file mode 100644 index 00000000000..2327fbcd069 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d891d4c2e8388e95d7e9afd659e14a35965b2357579b08bfdb965be36f4fd38 +size 10359 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-newton_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-newton_renderer-normals.png new file mode 100644 index 00000000000..dfef60e05a5 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-newton_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5c94c8085d0f8bbb74418447230b334f5426d5c1400ef3364157d4286dd66b3 +size 10029 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-normals.png new file mode 100644 index 00000000000..b012177673a --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7792ca32acace98625517017dddad0be21ce30d8373bd7f438f32638649180c2 +size 10434 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-normals.png new file mode 100644 index 00000000000..22caee983dc --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4e3743174a4c4bcde9d56b7bf3696fe15e6d5401b846a6c910967b4768bc69a +size 9152 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-newton_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-newton_renderer-normals.png new file mode 100644 index 00000000000..0254600c928 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-newton_renderer-normals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7dfc994325bd54c110328a5e79c09eee5cfea3aa2d27ae8e1fd980dcc48b4e2d +size 9150 diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index 6fd98dbbd9c..8c186b9d142 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -85,6 +85,7 @@ "depth", "distance_to_camera", "distance_to_image_plane", + "normals", ) @@ -121,6 +122,12 @@ def _make_sensor_data_type_params( "depth", id="physx-newton_warp-depth", ), + pytest.param( + "physx", + "newton_renderer", + "normals", + id="physx-newton_warp-normals", + ), ] KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS = [ @@ -139,6 +146,12 @@ def _make_sensor_data_type_params( "depth", id="ovphysx-newton_warp-depth", ), + pytest.param( + "ovphysx", + "newton_renderer", + "normals", + id="ovphysx-newton_warp-normals", + ), # newton + newton_renderer (warp) pytest.param( "newton", @@ -152,6 +165,12 @@ def _make_sensor_data_type_params( "depth", id="newton-newton_warp-depth", ), + pytest.param( + "newton", + "newton_renderer", + "normals", + id="newton-newton_warp-normals", + ), ] @@ -229,6 +248,8 @@ def _normalize_tensor(tensor: torch.Tensor, data_type: str) -> torch.Tensor: normalized = normalized / max_val elif data_type in {"albedo"}: normalized = normalized[..., :3] / 255.0 + elif data_type in {"normals"}: + normalized = (normalized + 1.0) * 0.5 else: normalized = normalized / 255.0 @@ -550,7 +571,7 @@ def validate_camera_outputs( 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() + max_val = corrected.abs().max() if max_val <= 0: failed_data_types[data_type] = f"Camera output '{data_type}' has no non-zero pixels." continue @@ -636,6 +657,7 @@ def rendering_test_shadow_hand( class _ShadowHandTiledCameraTestCfg(ShadowHandTiledCameraCfg): distance_to_camera = _ShadowHandBaseTiledCameraCfg(data_types=["distance_to_camera"]) distance_to_image_plane = _ShadowHandBaseTiledCameraCfg(data_types=["distance_to_image_plane"]) + normals = _ShadowHandBaseTiledCameraCfg(data_types=["normals"]) @configclass class _ShadowHandCameraTestEnvCfg(ShadowHandCameraEnvCfg): @@ -695,6 +717,7 @@ class _CartpoleTiledCameraTestCfg(CartpoleTiledCameraCfg): distance_to_image_plane = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg( data_types=["distance_to_image_plane"] ) + normals = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg(data_types=["normals"]) @configclass class _CartpoleCameraTestEnvCfg(CartpoleCameraEnvCfg): @@ -704,6 +727,9 @@ class _CartpoleCameraTestEnvCfg(CartpoleCameraEnvCfg): distance_to_image_plane = CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg( observation_space=[1, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() ) + normals = CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg( + observation_space=[3, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() + ) env_cfg = _CartpoleCameraTestEnvCfg() env_cfg = _apply_overrides_to_env_cfg( @@ -777,6 +803,9 @@ class _DexsuiteBaseTiledCameraTestCfg(BaseTiledCameraCfg): distance_to_image_plane256 = BASE_CAMERA_CFG.replace( data_types=["distance_to_image_plane"], width=256, height=256 ) + normals64 = BASE_CAMERA_CFG.replace(data_types=["normals"], width=64, height=64) + normals128 = BASE_CAMERA_CFG.replace(data_types=["normals"], width=128, height=128) + normals256 = BASE_CAMERA_CFG.replace(data_types=["normals"], width=256, height=256) @configclass class _DexsuiteSingleCameraTestSceneCfg(SingleCameraSceneCfg): From b84dd70da18e0efe68d5fba70af019d069d7d1a6 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Thu, 25 Jun 2026 11:17:45 -0400 Subject: [PATCH 011/139] Allow for automatic switching between ovrtx and isaac sim RTX (#6246) # Description Introduce a new general "RTX" preset, where a user does not need to understand the differences and caveats between Isaac Sim RTX vs. ovrtx. As a user, they can specify presets=rtx, and we will have logic to determine whether Isaac Sim RTX or ovrtx should be used. Users can still explicitly specify presets=ovrtx or presets=isaacsim_rtx, and we will produce an error if any invalid combinations are provided (e.g. ovrtx + isaac sim). If a user specifies presets=rtx, runs physx from isaac sim or uses the kit visualizer, we will use isaac sim RTX. If a user specifies presets=rtx, does not launch any isaac sim instances (e.g. no kit visualizer and no isaac sim physx), then we can use ovrtx (or whichever option is more performant) Fixes OMPE-92354 ## Type of change - New feature (non-breaking change which adds functionality) - Documentation update ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [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: Matthew Taylor Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- docs/source/features/hydra.rst | 2 + docs/source/overview/environments.rst | 16 ++-- docs/source/setup/quickstart_details.rst | 8 ++ .../changelog.d/mataylor-rtx-preset.rst | 5 + source/isaaclab/isaaclab/app/sim_launcher.py | 93 +++++++++++++++---- .../changelog.d/mataylor-rtx-preset.rst | 5 + .../isaaclab_tasks/utils/presets.py | 7 ++ .../test/core/test_preset_kit_decision.py | 73 ++++++++++++++- .../test/core/test_runtime_compatibility.py | 62 ++++++++++++- .../core/test_shadow_hand_camera_presets.py | 11 ++- .../test_sim_launcher_visualizer_intent.py | 1 + 11 files changed, 253 insertions(+), 30 deletions(-) create mode 100644 source/isaaclab/changelog.d/mataylor-rtx-preset.rst create mode 100644 source/isaaclab_tasks/changelog.d/mataylor-rtx-preset.rst diff --git a/docs/source/features/hydra.rst b/docs/source/features/hydra.rst index 711b224124a..9e75dd17db2 100644 --- a/docs/source/features/hydra.rst +++ b/docs/source/features/hydra.rst @@ -411,6 +411,8 @@ to make intent explicit on the command line. - Newton Warp renderer * - ``ovrtx_renderer`` - OV RTX renderer + * - ``rtx`` + - Automatic RTX renderer selection (Isaac Sim RTX when running with Isaac Sim, and OVRTX for kit-less) Domain presets (observation modes, camera configurations, etc.) are task-specific. Pass ``--task= --help`` to a training command to see all presets available diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index a9e6bc94cd2..fc7fdb0951e 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -68,7 +68,7 @@ modes. The **Presets** column in each table below is divided into three labeled (e.g. ``physx``, ``newton_mjwarp``, ``newton_kamino``, ``ovphysx``, ``newton_mjwarp_vbd``) * **renderer=** — renderer-backend name passed as ``renderer=NAME`` - (e.g. ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``) + (e.g. ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx``) * **presets=** — environment-specific (domain) preset name passed as ``presets=NAME[,NAME,...]`` (e.g. ``rgb``, ``depth``, ``single_camera``, ``duo_camera``) @@ -910,14 +910,14 @@ inferencing, including reading from an already trained checkpoint and disabling - Manager Based - **rl_games** (PPO, FEATURE), **rsl_rl** (PPO, FEATURE) - | **physics=** ``newton_kamino``, ``newton_mjwarp``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` | **presets=** ``albedo``, ``depth``, ``resnet18``, ``rgb``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl``, ``theia_tiny`` * - Isaac-Cartpole-Camera-Direct - - Direct - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) - | **physics=** ``newton_kamino``, ``newton_mjwarp``, ``ovphysx``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` | **presets=** ``albedo``, ``depth``, ``rgb``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl`` * - IsaacContrib-Cartpole-Camera-Showcase-Direct - @@ -1104,7 +1104,7 @@ inferencing, including reading from an already trained checkpoint and disabling - - Manager Based - **rsl_rl** (PPO) - - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` | **presets=** ``albedo128``, ``albedo256``, ``albedo64``, ``cube``, ``depth128``, ``depth256``, ``depth64``, ``duo_camera``, ``raycaster_depth128``, ``raycaster_depth256``, ``raycaster_depth64``, ``rgb128``, ``rgb256``, ``rgb64``, ``semantic_segmentation128``, ``semantic_segmentation256``, ``semantic_segmentation64``, ``shapes``, ``simple_shading_constant_diffuse128``, ``simple_shading_constant_diffuse256``, ``simple_shading_constant_diffuse64``, ``simple_shading_diffuse_mdl128``, ``simple_shading_diffuse_mdl256``, ``simple_shading_diffuse_mdl64``, ``simple_shading_full_mdl128``, ``simple_shading_full_mdl256``, ``simple_shading_full_mdl64``, ``single_camera`` * - Isaac-Lift-KukaAllegro-Play - @@ -1281,14 +1281,14 @@ inferencing, including reading from an already trained checkpoint and disabling - Direct - **rl_games** (PPO), **rsl_rl** (PPO) - | **physics=** ``newton_mjwarp``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` | **presets=** ``albedo``, ``depth``, ``full``, ``rgb``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl`` * - Isaac-Reorient-Cube-Shadow-Camera-Direct-Play - - Direct - **rl_games** (PPO), **rsl_rl** (PPO) - | **physics=** ``newton_mjwarp``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` | **presets=** ``albedo``, ``depth``, ``full``, ``rgb``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl`` * - Isaac-Reorient-Cube-Shadow-Direct - @@ -1314,7 +1314,7 @@ inferencing, including reading from an already trained checkpoint and disabling - - Manager Based - **rsl_rl** (PPO) - - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` | **presets=** ``albedo128``, ``albedo256``, ``albedo64``, ``cube``, ``depth128``, ``depth256``, ``depth64``, ``duo_camera``, ``raycaster_depth128``, ``raycaster_depth256``, ``raycaster_depth64``, ``rgb128``, ``rgb256``, ``rgb64``, ``semantic_segmentation128``, ``semantic_segmentation256``, ``semantic_segmentation64``, ``shapes``, ``simple_shading_constant_diffuse128``, ``simple_shading_constant_diffuse256``, ``simple_shading_constant_diffuse64``, ``simple_shading_diffuse_mdl128``, ``simple_shading_diffuse_mdl256``, ``simple_shading_diffuse_mdl64``, ``simple_shading_full_mdl128``, ``simple_shading_full_mdl256``, ``simple_shading_full_mdl64``, ``single_camera`` * - Isaac-Reorient-KukaAllegro-Play - @@ -1386,7 +1386,7 @@ inferencing, including reading from an already trained checkpoint and disabling - Manager Based - - | **physics=** ``newton_mjwarp``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer`` + | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` * - IsaacContrib-Stack-Cube-Galbot-Right-Arm-Suction-RmpFlow - - Manager Based diff --git a/docs/source/setup/quickstart_details.rst b/docs/source/setup/quickstart_details.rst index 7346f5614e6..7e3e8f504ce 100644 --- a/docs/source/setup/quickstart_details.rst +++ b/docs/source/setup/quickstart_details.rst @@ -84,16 +84,24 @@ Available Presets - ``isaacsim_rtx_renderer`` — Isaac Sim RTX (default with Isaac Sim) - ``newton_renderer`` — Newton Warp renderer - ``ovrtx_renderer`` — OV RTX renderer (kit-less) +- ``rtx`` — automatic RTX renderer selection **Domain presets** (``presets=NAME[,NAME,...]``) are task-specific — run ``--task= --help`` to list them. +Use ``renderer=rtx`` for automatic RTX selection on tasks that expose the +multi-backend renderer selector. The selection follows the runtime: it uses +Isaac Sim RTX when the run needs Isaac Sim/Kit, such as ``physics=physx`` or +``--visualizer kit``, and uses OVRTX when the run is fully kit-less. For example, +``physics=ovphysx renderer=rtx`` selects OVRTX; + Common combinations: .. code-block:: bash physics=newton_mjwarp renderer=newton_renderer presets=rgb physics=newton_mjwarp renderer=newton_renderer presets=depth + physics=newton_mjwarp renderer=rtx presets=rgb physics=physx renderer=isaacsim_rtx_renderer presets=rgb physics=physx renderer=isaacsim_rtx_renderer presets=depth physics=physx renderer=isaacsim_rtx_renderer presets=albedo diff --git a/source/isaaclab/changelog.d/mataylor-rtx-preset.rst b/source/isaaclab/changelog.d/mataylor-rtx-preset.rst new file mode 100644 index 00000000000..56d0e808801 --- /dev/null +++ b/source/isaaclab/changelog.d/mataylor-rtx-preset.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed automatic ``rtx`` launch-time renderer resolution to support the + ``renderer=rtx`` selector. diff --git a/source/isaaclab/isaaclab/app/sim_launcher.py b/source/isaaclab/isaaclab/app/sim_launcher.py index 39662d3e967..0a5ac08a110 100644 --- a/source/isaaclab/isaaclab/app/sim_launcher.py +++ b/source/isaaclab/isaaclab/app/sim_launcher.py @@ -22,8 +22,10 @@ from typing import Any from isaaclab_newton.physics import NewtonCfg +from isaaclab_ov.renderers import OVRTXRendererCfg from isaaclab_ovphysx.physics import OvPhysxCfg from isaaclab_physx.physics import PhysxCfg +from isaaclab_physx.renderers import IsaacRtxRendererCfg from isaaclab.physics.physics_manager_cfg import PhysicsCfg from isaaclab.renderers.renderer_cfg import RendererCfg @@ -78,6 +80,11 @@ def _is_ovrtx_renderer(node) -> bool: return isinstance(node, RendererCfg) and getattr(node, "renderer_type", None) == "ovrtx" +def _is_auto_rtx_renderer(node) -> bool: + """True when the node is an automatic RTX renderer placeholder.""" + return getattr(node, "renderer_type", None) == "auto_rtx" + + def _is_kit_camera(node) -> bool: """True for a CameraCfg whose renderer requires Kit (not Newton).""" if not isinstance(node, CameraCfg): @@ -85,6 +92,10 @@ def _is_kit_camera(node) -> bool: renderer_cfg = getattr(node, "renderer_cfg", None) if renderer_cfg is None: return True + if _is_auto_rtx_renderer(renderer_cfg): + # ``auto_rtx`` is resolved after the initial scan once physics and + # visualizer intent are known; ie. it may become OVRTX for a kitless run. + return False if isinstance(renderer_cfg, RendererCfg): return renderer_cfg.renderer_type in ("default", "isaac_rtx") # PresetCfg renderers (e.g. MultiBackendRendererCfg) are resolved during @@ -182,10 +193,12 @@ def _get_visualizer_intent(cfg) -> dict[str, bool]: class Scan: """Signals gathered from one walk of the config tree (see :func:`scan`). - Every field is a plain snapshot computed during that single walk; nothing here - is recomputed or mutated afterwards. ``needs_kit`` is the headline decision: a - Kit-renderer camera or non-kitless physics requires Kit (the launcher additionally - forces Kit when ``--visualizer kit`` is requested). + Every field starts as a plain snapshot computed during that single walk. + Automatic RTX renderer placeholders are also recorded so launch-time resolution + can update the renderer-related fields without traversing the config tree again. + ``needs_kit`` is the headline decision: a Kit-renderer camera or non-kitless + physics requires Kit (the launcher additionally forces Kit when ``--visualizer + kit`` is requested). """ resolved_physics_cfg: PhysicsCfg | None # first physics config in walk order (post --physics override) @@ -199,21 +212,30 @@ class Scan: needs_kit: bool -def scan(cfg, physics_str: str | None = None) -> Scan: +def scan(cfg, launcher_args: argparse.Namespace | dict | None = None) -> Scan: """Walk *cfg* once, collecting all launch signals and applying ``--physics``. - When *physics_str* is set, every physics config is replaced by the requested - backend (see :func:`make_physics_cfg`): nested configs in place, a root config - via :attr:`Scan.effective_cfg` (it cannot be mutated in place). + When the ``physics`` key is present in *launcher_args*, every physics config is + replaced by the requested backend (see :func:`make_physics_cfg`): nested configs + in place, a root config via :attr:`Scan.effective_cfg` (it cannot be mutated in + place). Automatic RTX renderer placeholders (``renderer_type="auto_rtx"``) are + also resolved at this stage using the full *launcher_args* context. """ + physics_str = _get_arg(launcher_args, "physics", None) physics_cfgs: list[PhysicsCfg] = [] effective_cfg: Any = cfg has_ovrtx = False + has_auto_rtx = False has_kit_camera = False + auto_rtx_locations: list[tuple[Any, Any, bool]] = [] # (parent, key, is_cam_renderer) for each auto RTX placeholder visited: set[int] = set() - def visit(node, parent, attr): - nonlocal effective_cfg, has_ovrtx, has_kit_camera + def visit(node, parent, key): + nonlocal effective_cfg, has_ovrtx, has_auto_rtx, has_kit_camera + if _is_auto_rtx_renderer(node): + has_auto_rtx = True + auto_rtx_locations.append((parent, key, isinstance(parent, CameraCfg) and key == "renderer_cfg")) + if id(node) in visited: return visited.add(id(node)) @@ -222,7 +244,7 @@ def visit(node, parent, attr): if physics_str: node = make_physics_cfg(physics_str) if parent is not None: - setattr(parent, attr, node) + setattr(parent, key, node) else: effective_cfg = node physics_cfgs.append(node) @@ -244,7 +266,7 @@ def visit(node, parent, attr): names = [type(pcfg).__name__ for pcfg in physics_cfgs] has_kitless_physics = any(name in _KITLESS_PHYSICS_CFGS for name in names) - return Scan( + config_scan = Scan( resolved_physics_cfg=physics_cfgs[0] if physics_cfgs else None, effective_cfg=effective_cfg, visualizer_intent=_get_visualizer_intent(cfg), @@ -256,20 +278,54 @@ def visit(node, parent, attr): needs_kit=has_kit_camera or not has_kitless_physics, ) + # Resolve recorded auto RTX renderer placeholders + if not has_auto_rtx: + return config_scan + + use_isaac_sim = _uses_isaac_sim_runtime(config_scan, launcher_args) + renderer_factory = IsaacRtxRendererCfg if use_isaac_sim else OVRTXRendererCfg + + # Resolve every auto RTX placeholder in place, tracking camera renderers that may require Kit. + has_auto_camera = False + for location in auto_rtx_locations: + if location[0] is None: + raise ValueError("Automatic RTX renderer placeholders cannot be resolved as the root config.") + setattr(location[0], location[1], renderer_factory()) + has_auto_camera = has_auto_camera or location[2] + + # Update the scan with the resolved auto RTX renderer type and Kit-camera status. + if use_isaac_sim: + config_scan.has_kit_camera = config_scan.has_kit_camera or has_auto_camera + else: + config_scan.has_ovrtx = True + config_scan.needs_kit = config_scan.has_kit_camera or not config_scan.has_kitless_physics + + return config_scan + """ Launch Decisions (derived purely from a scan). """ +def _has_kit_visualizer(config_scan: Scan, launcher_args: argparse.Namespace | dict | None) -> bool: + """Return whether the run requests the Kit visualizer through config or CLI.""" + visualizer_types = _get_visualizer_types(launcher_args) + return "kit" in visualizer_types or config_scan.visualizer_intent["has_kit_visualizer"] + + +def _uses_isaac_sim_runtime(config_scan: Scan, launcher_args: argparse.Namespace | dict | None) -> bool: + """Return whether the scanned config requires Isaac Sim / Kit.""" + return config_scan.needs_kit or _has_kit_visualizer(config_scan, launcher_args) + + def _validate_runtime(scan: Scan, launcher_args: argparse.Namespace | dict | None) -> None: """Raise if *scan*'s physics/renderer/visualizer combination is unsupported. OVRTX is kitless and cannot share a process with Kit-based runtimes (PhysX physics or the Kit visualizer); OvPhysX physics likewise cannot run with the Kit visualizer. """ - visualizer_types = _get_visualizer_types(launcher_args) - has_kit_visualizer = "kit" in visualizer_types or scan.visualizer_intent["has_kit_visualizer"] + has_kit_visualizer = _has_kit_visualizer(scan, launcher_args) if scan.has_ovphysx_physics and has_kit_visualizer: raise ValueError( @@ -352,11 +408,14 @@ def launch_simulation( Callers that do not need the value simply omit ``as``. """ + # Livestreaming implies a Kit visualizer; make that visible to auto RTX + # resolution during the single scan. + _ensure_livestream_kit_visualizer(launcher_args) + # The single walk: collect every signal and apply the --physics override. - config_scan = scan(cfg, _get_arg(launcher_args, "physics")) + config_scan = scan(cfg, launcher_args) effective_cfg = config_scan.effective_cfg physics_cfg = config_scan.resolved_physics_cfg - _ensure_livestream_kit_visualizer(launcher_args) visualizer_types = _get_visualizer_types(launcher_args) # ovrtx + Kit visualizer share conflicting RTX hydra libraries under different USD @@ -372,7 +431,7 @@ def launch_simulation( ) _validate_runtime(config_scan, launcher_args) - needs_kit = config_scan.needs_kit or "kit" in visualizer_types + needs_kit = _uses_isaac_sim_runtime(config_scan, launcher_args) _set_arg(launcher_args, "visualizer_intent", config_scan.visualizer_intent) if needs_kit and config_scan.has_kit_camera and launcher_args is not None: diff --git a/source/isaaclab_tasks/changelog.d/mataylor-rtx-preset.rst b/source/isaaclab_tasks/changelog.d/mataylor-rtx-preset.rst new file mode 100644 index 00000000000..64f639d3f88 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/mataylor-rtx-preset.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed :class:`~isaaclab_tasks.utils.presets.MultiBackendRendererCfg` to + expose automatic ``rtx`` selection through ``renderer=rtx``. diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py b/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py index 2aec60cce07..3d79ce5286e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py @@ -7,14 +7,21 @@ from isaaclab_ov.renderers import OVRTXRendererCfg from isaaclab_physx.renderers import IsaacRtxRendererCfg +from isaaclab.renderers.renderer_cfg import RendererCfg from isaaclab.utils.configclass import configclass from isaaclab_tasks.utils import PresetCfg +@configclass +class _AutoRtxRendererCfg(RendererCfg): + renderer_type: str = "auto_rtx" + + @configclass class MultiBackendRendererCfg(PresetCfg): default: IsaacRtxRendererCfg = IsaacRtxRendererCfg() + rtx: _AutoRtxRendererCfg = _AutoRtxRendererCfg() newton_renderer: NewtonWarpRendererCfg = NewtonWarpRendererCfg() ovrtx_renderer: OVRTXRendererCfg = OVRTXRendererCfg() isaacsim_rtx_renderer = default diff --git a/source/isaaclab_tasks/test/core/test_preset_kit_decision.py b/source/isaaclab_tasks/test/core/test_preset_kit_decision.py index bed01543053..e546adeb8e4 100644 --- a/source/isaaclab_tasks/test/core/test_preset_kit_decision.py +++ b/source/isaaclab_tasks/test/core/test_preset_kit_decision.py @@ -11,26 +11,42 @@ """ import sys +from argparse import Namespace + +from isaaclab_ov.renderers import OVRTXRendererCfg +from isaaclab_physx.renderers import IsaacRtxRendererCfg from isaaclab.app import scan import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils import resolve_task_config +from isaaclab_tasks.utils.preset_cli import enumerate_task_presets +from isaaclab_tasks.utils.preset_target import PresetTarget _CAMERA_PRESETS_TASK = "Isaac-Cartpole-Camera-Direct" def _resolve_with_presets(presets: str): """Resolve env_cfg with given presets. Modifies sys.argv temporarily.""" + return _resolve_with_args(f"presets={presets}") + + +def _resolve_with_args(*args: str): + """Resolve env_cfg with the given Hydra-style args. Modifies sys.argv temporarily.""" old_argv = sys.argv.copy() try: - sys.argv = [sys.argv[0], f"presets={presets}"] - env_cfg, agent_cfg = resolve_task_config(_CAMERA_PRESETS_TASK, "rl_games_cfg_entry_point") + sys.argv = [sys.argv[0], *args] + env_cfg, _ = resolve_task_config(_CAMERA_PRESETS_TASK, "rl_games_cfg_entry_point") return env_cfg finally: sys.argv = old_argv +def _resolve_runtime_renderer(env_cfg, launcher_args=None): + """Apply launch-time auto RTX renderer resolution and return the updated scan.""" + return scan(env_cfg, launcher_args) + + def test_resolve_task_config_applies_plain_scalar_override(): """Plain ``env.*=value`` overrides should resolve without requiring Hydra composition.""" old_argv = sys.argv.copy() @@ -43,6 +59,14 @@ def test_resolve_task_config_applies_plain_scalar_override(): assert env_cfg.scene.num_envs == 123 +def test_rtx_is_renderer_selector(): + """The automatic RTX selector is exposed as ``renderer=rtx``.""" + preset_map = enumerate_task_presets(_CAMERA_PRESETS_TASK) + + assert preset_map is not None + assert "rtx" in preset_map[PresetTarget.RENDERER] + + 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") @@ -50,6 +74,51 @@ def test_preset_mjwarp_ovrtx_does_not_need_kit(): assert needs_kit is False +def test_preset_rtx_with_physx_resolves_to_isaac_rtx_and_needs_kit(): + """The RTX preset uses Isaac RTX when PhysX requires Isaac Sim.""" + env_cfg = _resolve_with_presets("rtx") + config_scan = _resolve_runtime_renderer(env_cfg) + + assert isinstance(env_cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) + assert config_scan.needs_kit is True + + +def test_renderer_selector_rtx_with_physx_resolves_to_isaac_rtx_and_needs_kit(): + """The RTX renderer selector uses Isaac RTX when PhysX requires Isaac Sim.""" + env_cfg = _resolve_with_args("renderer=rtx") + config_scan = _resolve_runtime_renderer(env_cfg) + + assert isinstance(env_cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) + assert config_scan.needs_kit is True + + +def test_preset_mjwarp_rtx_resolves_to_ovrtx_without_kit(): + """The RTX preset uses OVRTX for a kitless Newton run.""" + env_cfg = _resolve_with_presets("newton_mjwarp,rtx") + config_scan = _resolve_runtime_renderer(env_cfg) + + assert isinstance(env_cfg.tiled_camera.renderer_cfg, OVRTXRendererCfg) + assert config_scan.needs_kit is False + + +def test_preset_mjwarp_rtx_resolves_to_isaac_rtx_with_kit_visualizer(): + """The RTX preset uses Isaac RTX when the Kit visualizer is requested.""" + env_cfg = _resolve_with_presets("newton_mjwarp,rtx") + config_scan = _resolve_runtime_renderer(env_cfg, Namespace(visualizer="kit")) + + assert isinstance(env_cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) + assert config_scan.needs_kit is True + + +def test_renderer_selector_mjwarp_rtx_resolves_to_ovrtx_without_kit(): + """The RTX renderer selector uses OVRTX for a kitless Newton run.""" + env_cfg = _resolve_with_args("physics=newton_mjwarp", "renderer=rtx") + config_scan = _resolve_runtime_renderer(env_cfg) + + assert isinstance(env_cfg.tiled_camera.renderer_cfg, OVRTXRendererCfg) + assert config_scan.needs_kit is False + + def test_preset_mjwarp_newton_renderer_does_not_need_kit(): """Newton + Newton Warp renderer is kitless.""" env_cfg = _resolve_with_presets("newton_mjwarp,newton_renderer") diff --git a/source/isaaclab_tasks/test/core/test_runtime_compatibility.py b/source/isaaclab_tasks/test/core/test_runtime_compatibility.py index e08eab376ad..7a1fa3140a8 100644 --- a/source/isaaclab_tasks/test/core/test_runtime_compatibility.py +++ b/source/isaaclab_tasks/test/core/test_runtime_compatibility.py @@ -16,9 +16,13 @@ import sys import pytest +from isaaclab_ov.renderers import OVRTXRendererCfg +from isaaclab_physx.renderers import IsaacRtxRendererCfg +import isaaclab.app.sim_launcher as sim_launcher_module +import isaaclab.utils as isaaclab_utils from isaaclab.app import scan -from isaaclab.app.sim_launcher import _validate_runtime +from isaaclab.app.sim_launcher import _validate_runtime, launch_simulation import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils import resolve_task_config @@ -28,7 +32,9 @@ def validate_runtime_compatibility(env_cfg, launcher_args=None): """Run the single-scan runtime validation for *env_cfg* (test adapter).""" - _validate_runtime(scan(env_cfg), launcher_args) + config_scan = scan(env_cfg, launcher_args) + _validate_runtime(config_scan, launcher_args) + return config_scan def _resolve_with_presets(presets: str): @@ -132,6 +138,58 @@ def test_default_preset_is_valid(): validate_runtime_compatibility(env_cfg) +def test_rtx_with_default_physx_is_valid_and_resolves_to_isaac_rtx(): + """The RTX preset chooses the Kit-compatible renderer for PhysX.""" + env_cfg = _resolve_with_presets("rtx") + config_scan = validate_runtime_compatibility(env_cfg) + + assert isinstance(env_cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) + assert config_scan.needs_kit is True + + +def test_rtx_with_newton_is_valid_and_resolves_to_ovrtx(): + """The RTX preset chooses OVRTX when no Isaac Sim runtime is needed.""" + env_cfg = _resolve_with_presets("newton_mjwarp,rtx") + config_scan = validate_runtime_compatibility(env_cfg) + + assert isinstance(env_cfg.tiled_camera.renderer_cfg, OVRTXRendererCfg) + assert config_scan.needs_kit is False + + +def test_rtx_with_ovphysx_is_valid_and_resolves_to_ovrtx(): + """The RTX preset chooses OVRTX for an OvPhysX kitless run.""" + env_cfg = _resolve_with_presets("ovphysx,rtx") + config_scan = validate_runtime_compatibility(env_cfg) + + assert config_scan.has_ovphysx_physics is True + assert isinstance(env_cfg.tiled_camera.renderer_cfg, OVRTXRendererCfg) + assert config_scan.needs_kit is False + + +def test_rtx_with_kit_visualizer_is_valid_and_resolves_to_isaac_rtx(): + """The RTX preset chooses Isaac RTX when the Kit visualizer is requested.""" + env_cfg = _resolve_with_presets("newton_mjwarp,rtx") + config_scan = validate_runtime_compatibility(env_cfg, argparse.Namespace(visualizer="kit")) + + assert isinstance(env_cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) + assert config_scan.needs_kit is True + + +def test_livestream_rtx_injects_kit_before_auto_rtx_resolution(monkeypatch: pytest.MonkeyPatch): + """Livestreaming should make ``presets=newton_mjwarp,rtx`` choose Isaac RTX.""" + env_cfg = _resolve_with_presets("newton_mjwarp,rtx") + launcher_args = argparse.Namespace(livestream=2, visualizer=None, visualizer_explicit=False) + monkeypatch.setattr(sim_launcher_module, "_ensure_isaac_sim_available", lambda: None) + monkeypatch.setattr(isaaclab_utils, "has_kit", lambda: True) + + with launch_simulation(env_cfg, launcher_args) as physics_cfg: + assert type(physics_cfg).__name__ == "NewtonCfg" + + assert launcher_args.visualizer == ["kit"] + assert launcher_args.enable_cameras is True + assert isinstance(env_cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) + + def test_newton_plus_isaacsim_rtx_is_valid(): """Newton + Isaac RTX renderer is supported (RTX runs in Kit, Newton syncs to USD).""" env_cfg = _resolve_with_presets("newton,isaacsim_rtx_renderer") diff --git a/source/isaaclab_tasks/test/core/test_shadow_hand_camera_presets.py b/source/isaaclab_tasks/test/core/test_shadow_hand_camera_presets.py index 040f5b2956a..efcf08d5be6 100644 --- a/source/isaaclab_tasks/test/core/test_shadow_hand_camera_presets.py +++ b/source/isaaclab_tasks/test/core/test_shadow_hand_camera_presets.py @@ -34,6 +34,8 @@ from isaaclab_ov.renderers import OVRTXRendererCfg # noqa: E402 from isaaclab_physx.renderers import IsaacRtxRendererCfg # noqa: E402 +from isaaclab.renderers import RendererCfg # noqa: E402 + from isaaclab_tasks.core.reorient.config.shadow_hand.shadow_hand_camera_env_cfg import ( # noqa: E402 ShadowHandCameraEnvCfg, ) @@ -255,10 +257,17 @@ def test_warp_renderer_has_correct_renderer_type(shadow_hand_camera_presets): assert warp_cfg.renderer_type == "newton_warp" +def test_rtx_preset_has_auto_renderer_type(shadow_hand_camera_presets): + """The ``rtx`` preset is an automatic renderer placeholder resolved at launch.""" + rtx_cfg = shadow_hand_camera_presets["tiled_camera.renderer_cfg"]["rtx"] + assert isinstance(rtx_cfg, RendererCfg) + assert rtx_cfg.renderer_type == "auto_rtx" + + def test_all_renderer_presets_present(shadow_hand_camera_presets): """Every preset in MultiBackendRendererCfg is discoverable.""" renderer_presets = shadow_hand_camera_presets["tiled_camera.renderer_cfg"] - expected_names = {"default", "isaacsim_rtx_renderer", "newton_renderer", "ovrtx_renderer"} + expected_names = {"default", "rtx", "isaacsim_rtx_renderer", "newton_renderer", "ovrtx_renderer"} missing = expected_names - set(renderer_presets.keys()) assert not missing, f"Renderer presets missing from collected presets: {missing}" diff --git a/source/isaaclab_tasks/test/core/test_sim_launcher_visualizer_intent.py b/source/isaaclab_tasks/test/core/test_sim_launcher_visualizer_intent.py index ad049f11ea9..5f6e6ba2933 100644 --- a/source/isaaclab_tasks/test/core/test_sim_launcher_visualizer_intent.py +++ b/source/isaaclab_tasks/test/core/test_sim_launcher_visualizer_intent.py @@ -51,6 +51,7 @@ def __init__(self, launcher_args): captured["closed"] = False self.app = types.SimpleNamespace(close=lambda: captured.update({"closed": True})) + monkeypatch.setitem(sys.modules, "isaaclab.utils", types.SimpleNamespace(has_kit=lambda: False)) monkeypatch.setitem(sys.modules, "isaaclab.app", types.SimpleNamespace(AppLauncher=_FakeAppLauncher)) monkeypatch.setattr("importlib.util.find_spec", lambda name: object() if name == "omni.kit" else None) From 56cb616c4a693da87ee7dc3f603b58ecc8004b40 Mon Sep 17 00:00:00 2001 From: vidurv-nvidia Date: Thu, 25 Jun 2026 11:10:42 -0500 Subject: [PATCH 012/139] Adding Mass USD data classes and writers (#6263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Adds the **mass** schema-fragment API. Unlike the other families, mass has **no backend split** — it is a single core fragment. - `MassFragment` marker + `MassCfg` (`physics:mass` / `physics:density`; `UsdPhysics.MassAPI` anchor) in `isaaclab`. - `apply_mass_properties` family writer (applies the `MassAPI` anchor, then dispatches each fragment via its `func`). - The spawner `mass_props` slot now also accepts a `MassCfg` / `list[MassCfg]`. This PR is purely **additive** and self-contained: it builds only on the single-namespace schema-fragment base (`SchemaFragment` + `apply_namespaced`) already in `develop`, existing call sites are untouched (a transition bridge routes legacy single cfgs to the existing `define_`/`modify_` writers), and it does **not** depend on any other open PR. ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots N/A — non-visual API 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 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 --- .../vidurv-schema-frag-mass.minor.rst | 27 +++ source/isaaclab/isaaclab/sim/__init__.pyi | 6 + .../isaaclab/sim/converters/mesh_converter.py | 11 +- .../isaaclab/sim/schemas/__init__.pyi | 6 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 33 ++++ .../isaaclab/sim/schemas/schemas_cfg.py | 42 ++++ .../sim/spawners/from_files/from_files.py | 9 +- .../isaaclab/sim/spawners/meshes/meshes.py | 9 +- .../isaaclab/sim/spawners/shapes/shapes.py | 8 +- .../isaaclab/sim/spawners/spawner_cfg.py | 10 +- .../isaaclab/test/sim/test_mass_fragments.py | 183 ++++++++++++++++++ 11 files changed, 334 insertions(+), 10 deletions(-) create mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-mass.minor.rst create mode 100644 source/isaaclab/test/sim/test_mass_fragments.py diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-mass.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-mass.minor.rst new file mode 100644 index 00000000000..fbbfa1fb70f --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-mass.minor.rst @@ -0,0 +1,27 @@ +Added +^^^^^ + +* Added the mass schema-fragment API: the :class:`~isaaclab.sim.schemas.MassFragment` marker and + :class:`~isaaclab.sim.schemas.MassCfg` (writes ``physics:mass`` / ``physics:density`` via + ``UsdPhysics.MassAPI``). The legacy :class:`~isaaclab.sim.schemas.MassPropertiesCfg` remains the + canonical name and continues to work unchanged. +* Added :func:`~isaaclab.sim.schemas.apply_mass_properties`, which applies a list of mass fragments + with ``UsdPhysics.MassAPI`` as the implicit anchor. + +Changed +^^^^^^^ + +* Changed the spawner ``mass_props`` slot + (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.mass_props`) to also accept a single + :class:`~isaaclab.sim.schemas.MassFragment` or a list of them. Legacy + :class:`~isaaclab.sim.schemas.MassPropertiesCfg` cfgs continue to work through a transition bridge + in the spawn writers. + +Fixed +^^^^^ + +* Fixed :func:`~isaaclab.sim.schemas.apply_mass_properties` to raise ``ValueError`` on an invalid + prim path and to aggregate per-fragment results instead of always returning ``True``, matching + :func:`~isaaclab.sim.schemas.apply_rigid_body_properties`. +* Fixed the spawn writers so an empty ``mass_props`` list is a harmless no-op rather than being + forwarded to :func:`~isaaclab.sim.schemas.define_mass_properties` as an unexpected list. diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index e0365d3e759..dc5d6207e85 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -45,6 +45,8 @@ __all__ = [ "DeformableBodyPropertiesCfg", "FixedTendonPropertiesCfg", "JointDriveBaseCfg", + "MassCfg", + "MassFragment", "MassPropertiesCfg", "MeshCollisionPropertiesCfg", "MujocoJointDrivePropertiesCfg", @@ -62,6 +64,7 @@ __all__ = [ "RigidBodyFragment", "SchemaFragment", "UsdPhysicsRigidBodyCfg", + "apply_mass_properties", "apply_namespaced", "apply_rigid_body_properties", "SDFMeshPropertiesCfg", @@ -221,6 +224,8 @@ from .schemas import ( DeformableBodyPropertiesCfg, FixedTendonPropertiesCfg, JointDriveBaseCfg, + MassCfg, + MassFragment, MassPropertiesCfg, MeshCollisionPropertiesCfg, PhysxJointDrivePropertiesCfg, @@ -234,6 +239,7 @@ from .schemas import ( TriangleMeshSimplificationPropertiesCfg, UsdPhysicsRigidBodyCfg, activate_contact_sensors, + apply_mass_properties, apply_namespaced, apply_rigid_body_properties, define_articulation_root_properties, diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py index f814e4fdb87..c9ee2cef324 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py @@ -12,9 +12,9 @@ from isaacsim.core.experimental.utils.app import enable_extension from pxr import Gf, Tf, Usd, UsdGeom, UsdPhysics, UsdUtils +from isaaclab.sim import schemas from isaaclab.sim.converters.asset_converter_base import AssetConverterBase from isaaclab.sim.converters.mesh_converter_cfg import MeshConverterCfg -from isaaclab.sim.schemas import schemas from isaaclab.sim.utils import delete_prim, export_prim_to_file # import logger @@ -182,9 +182,14 @@ def _convert_asset(self, cfg: MeshConverterCfg): # Apply mass and rigid body properties after everything else # Properties are applied to the top level prim to avoid the case where all instances of this # asset unintentionally share the same rigid body properties - # apply mass properties + # apply mass properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.mass_props is not None: - schemas.define_mass_properties(prim_path=xform_prim.GetPath(), cfg=cfg.mass_props, stage=stage) + # normalize a single fragment to a list so the convenience form routes like a list + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, schemas.SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, schemas.SchemaFragment) for f in mass_frags): + schemas.apply_mass_properties(str(xform_prim.GetPath()), mass_frags, stage=stage) + else: + schemas.define_mass_properties(prim_path=xform_prim.GetPath(), cfg=cfg.mass_props, stage=stage) # apply rigid body properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.rigid_props is not None: rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index af153a60fc6..a6ea01a293d 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", + "apply_mass_properties", "apply_namespaced", "apply_rigid_body_properties", "define_actuator_properties", @@ -33,6 +34,8 @@ __all__ = [ "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", "JointDriveBaseCfg", + "MassCfg", + "MassFragment", "MassPropertiesCfg", "MeshCollisionBaseCfg", "RigidBodyFragment", @@ -55,6 +58,7 @@ from .schemas import ( PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, activate_contact_sensors, + apply_mass_properties, apply_namespaced, apply_rigid_body_properties, define_articulation_root_properties, @@ -84,6 +88,8 @@ from .schemas_cfg import ( DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, JointDriveBaseCfg, + MassCfg, + MassFragment, MassPropertiesCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 6617543cad6..a4a6b9f74c8 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -651,6 +651,39 @@ def modify_collision_properties( """ +def apply_mass_properties( + prim_path: str, fragments: Iterable[schemas_cfg.MassFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of mass fragments to a prim. + + Applies ``UsdPhysics.MassAPI`` as the implicit anchor (the defining schema for mass properties), + then dispatches each fragment via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. + + Args: + prim_path: The prim path to apply the mass schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.MassFragment` instances. + 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. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the legacy define_mass_properties writer) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + if not UsdPhysics.MassAPI(prim): + UsdPhysics.MassAPI.Apply(prim) + # aggregate per-fragment results so a reported failure is not masked by the always-applied anchor + success = True + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + success = bool(func(cfg, prim_path, stage)) and success + return success + + def define_mass_properties(prim_path: str, cfg: schemas_cfg.MassPropertiesCfg, stage: Usd.Stage | None = None): """Apply the mass schema on the input prim and set its properties. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index ac5d8b22c02..529182915a3 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -405,6 +405,48 @@ class MassPropertiesCfg: """ +@configclass +class MassFragment(SchemaFragment): + """Marker base for mass fragments; types the ``mass_props`` slot.""" + + pass + + +@configclass +class MassCfg(MassFragment): + """``physics:*`` mass attributes from `UsdPhysics.MassAPI`_. + + The ``UsdPhysics.MassAPI`` schema is applied as the implicit anchor by the mass family writer + (:func:`~isaaclab.sim.schemas.apply_mass_properties`), so this fragment owns no applied schema + of its own. Mirrors the legacy :class:`MassPropertiesCfg`. + + .. note:: + A fragment present in a spawner slot means its schema is applied. ``None`` fields are left + unchanged on the prim (partial update). + + .. _UsdPhysics.MassAPI: https://openusd.org/dev/api/class_usd_physics_mass_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None # MassAPI applied by the family anchor + + mass: float | None = None + """The mass of the rigid body [kg]. + + Writes ``physics:mass`` via :class:`UsdPhysics.MassAPI`. + + Note: + If ``density`` is non-zero, it takes precedence and is used to compute the mass instead. + """ + + density: float | None = None + """The density of the rigid body [kg/m^3]. + + Writes ``physics:density`` via :class:`UsdPhysics.MassAPI`. The density indirectly defines the + mass of the rigid body. It is generally computed using the collision approximation of the body. + """ + + @configclass class JointDriveBaseCfg: """Solver-common properties to define the drive mechanism of a joint. 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 4545eb23e11..767a3e14ab4 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -353,9 +353,14 @@ def _spawn_from_usd_file( # modify collision properties if cfg.collision_props is not None: schemas.modify_collision_properties(prim_path, cfg.collision_props) - # modify mass properties + # modify mass properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> modify_*) if cfg.mass_props is not None: - schemas.modify_mass_properties(prim_path, cfg.mass_props) + # normalize a single fragment to a list so the convenience form routes like a list + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, schemas.SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, schemas.SchemaFragment) for f in mass_frags): + schemas.apply_mass_properties(prim_path, mass_frags) + else: + schemas.modify_mass_properties(prim_path, cfg.mass_props) # modify articulation root properties if cfg.articulation_props is not None: diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index 27f815343c0..6e930e2530b 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -441,9 +441,14 @@ def _spawn_mesh_geom_from_mesh( # note: we apply the rigid properties to the parent prim in case of rigid objects. if cfg.rigid_props is not None: - # apply mass properties + # apply mass properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.mass_props is not None: - schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) + # normalize a single fragment to a list so the convenience form routes like a list + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, schemas.SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, schemas.SchemaFragment) for f in mass_frags): + schemas.apply_mass_properties(prim_path, mass_frags, stage=stage) + else: + schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) # apply rigid properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index 0601cbed541..5d841e1c236 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -319,7 +319,13 @@ def _spawn_geom_from_prim_type( # note: we apply rigid properties in the end to later make the instanceable prim # apply mass properties if cfg.mass_props is not None: - schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) + # transition shim, remove later: fragment(s) -> apply_*; legacy cfg -> define_* + # normalize a single fragment to a list so the convenience form routes like a list + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, schemas.SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, schemas.SchemaFragment) for f in mass_frags): + schemas.apply_mass_properties(prim_path, mass_frags, stage=stage) + else: + schemas.define_mass_properties(prim_path, cfg.mass_props, stage=stage) # apply rigid body properties if cfg.rigid_props is not None: # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> define_* diff --git a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py index 1d52d451ba1..d492e0cae1d 100644 --- a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py @@ -82,8 +82,14 @@ class RigidObjectSpawnerCfg(SpawnerCfg): to the prim outside of the properties available by default when spawning the prim. """ - mass_props: schemas.MassPropertiesCfg | None = None - """Mass properties.""" + mass_props: schemas.MassPropertiesCfg | schemas.MassFragment | list[schemas.MassFragment] | None = None + """Mass properties. + + Accepts either a single legacy :class:`~isaaclab.sim.schemas.MassPropertiesCfg` or a list of + :class:`~isaaclab.sim.schemas.MassFragment` fragments (e.g. ``[MassCfg(...)]``). When a fragment + list is given, ``UsdPhysics.MassAPI`` is applied as the implicit anchor and each fragment writes + its own namespace. + """ rigid_props: schemas.RigidBodyBaseCfg | schemas.RigidBodyFragment | list[schemas.RigidBodyFragment] | None = None """Rigid body properties. diff --git a/source/isaaclab/test/sim/test_mass_fragments.py b/source/isaaclab/test/sim/test_mass_fragments.py new file mode 100644 index 00000000000..1e4a28a6121 --- /dev/null +++ b/source/isaaclab/test/sim/test_mass_fragments.py @@ -0,0 +1,183 @@ +# 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 pytest + +from pxr import UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + + +def _make_xform(stage, path="/World/Body"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# Fragment metadata -- MassFragment marker, MassCfg +# ------------------------------------------------------------------------------------- + + +def test_fragment_metadata_defaults(): + from isaaclab.sim.schemas import MassCfg, MassFragment, SchemaFragment + + cfg = MassCfg(mass=2.0) + assert isinstance(cfg, MassFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physics" + assert type(cfg)._usd_applied_schema is None # anchor applies MassAPI, not the fragment + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.mass == 2.0 and cfg.density is None + + +# ------------------------------------------------------------------------------------- +# apply_namespaced writes only the set fields under the physics namespace +# ------------------------------------------------------------------------------------- + + +def test_apply_namespaced_writes_only_set_fields(): + from isaaclab.sim.schemas import MassCfg, apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage) + UsdPhysics.MassAPI.Apply(prim) + apply_namespaced(MassCfg(mass=3.0), "/World/Body", stage) + assert abs(prim.GetAttribute("physics:mass").Get() - 3.0) < 1e-6 + # None field must not be authored (density exists as a MassAPI fallback attr) + assert not prim.GetAttribute("physics:density").HasAuthoredValue() + + +# ------------------------------------------------------------------------------------- +# apply_mass_properties dispatch (implicit MassAPI anchor) +# ------------------------------------------------------------------------------------- + + +def test_apply_mass_properties_applies_anchor_and_writes_fields(): + from isaaclab.sim.schemas import MassCfg, apply_mass_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/B2") + apply_mass_properties("/World/B2", [MassCfg(mass=5.0, density=100.0)], stage) + prim = stage.GetPrimAtPath("/World/B2") + assert bool(UsdPhysics.MassAPI(prim)) # implicit anchor applied + assert abs(prim.GetAttribute("physics:mass").Get() - 5.0) < 1e-6 + assert abs(prim.GetAttribute("physics:density").Get() - 100.0) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# spawner slot accepts a fragment list + transition routing +# ------------------------------------------------------------------------------------- + + +def test_spawn_shape_with_mass_fragment_list(): + from isaaclab.sim.schemas import MassCfg, UsdPhysicsRigidBodyCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + rigid_props=[UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)], + mass_props=[MassCfg(mass=4.0)], + ) + cfg.func("/World/Cube", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube") + assert bool(UsdPhysics.MassAPI(prim)) + assert abs(prim.GetAttribute("physics:mass").Get() - 4.0) < 1e-6 + + +def test_spawn_shape_with_single_mass_fragment(): + # the ``mass_props`` slot advertises a single fragment (convenience form), not only a list; + # the spawn shim must route a bare fragment through ``apply_mass_properties`` (not the legacy writer) + from isaaclab.sim.schemas import MassCfg, UsdPhysicsRigidBodyCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + rigid_props=[UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)], + mass_props=MassCfg(mass=4.0), + ) + cfg.func("/World/CubeSingle", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/CubeSingle") + assert bool(UsdPhysics.MassAPI(prim)) + assert abs(prim.GetAttribute("physics:mass").Get() - 4.0) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# Review follow-ups -- prim-validity guard, aggregated return, empty-list no-op +# ------------------------------------------------------------------------------------- + + +def test_apply_mass_properties_raises_on_invalid_prim(): + from isaaclab.sim.schemas import MassCfg, apply_mass_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # no prim authored at this path -> GetPrimAtPath returns an invalid prim + with pytest.raises(ValueError): + apply_mass_properties("/World/DoesNotExist", [MassCfg(mass=1.0)], stage) + + +def test_apply_mass_properties_aggregates_fragment_results(): + from isaaclab.sim.schemas import MassCfg, apply_mass_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Agg") + + # a fragment whose applier reports failure must make the aggregate return False + failing = MassCfg(mass=1.0) + failing.func = lambda cfg, prim_path, stage=None: False + assert apply_mass_properties("/World/Agg", [failing], stage) is False + + # all-succeeding fragments return True + ok = MassCfg(mass=1.0) + assert apply_mass_properties("/World/Agg", [ok], stage) is True + + +def test_spawn_shape_with_empty_mass_list_is_noop(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + rigid_props=[UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)], + mass_props=[], + ) + # an empty fragment list routes through the fragment path and applies nothing (no exception) + cfg.func("/World/Cube", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube") + # mass anchor is not required when there are zero fragments to apply + assert not prim.GetAttribute("physics:mass").HasAuthoredValue() + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab.sim.schemas import ( # noqa: F401 + MassCfg, + MassFragment, + SchemaFragment, + apply_mass_properties, + ) From dc8e89de5c6afee84c170926c54702deda9b8aef Mon Sep 17 00:00:00 2001 From: vidurv-nvidia Date: Fri, 26 Jun 2026 00:10:24 -0500 Subject: [PATCH 013/139] Adding Collision USD data classes and writers (#6254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Adds the **collision** schema-fragment API: a prim can carry collision properties from multiple USD namespaces (`UsdPhysics` + PhysX + Newton) at once, and core names no backend. - `CollisionFragment` marker + `UsdPhysicsCollisionCfg` (`physics:collisionEnabled`; `UsdPhysics.CollisionAPI` anchor) in `isaaclab`. - `PhysxCollisionCfg` (`physxCollision:*` — `contact_offset`, `rest_offset`, `torsional_patch_radius`, `min_torsional_patch_radius`) in `isaaclab_physx`. - `NewtonCollisionCfg` (`newton:*`) in `isaaclab_newton`. - `apply_collision_properties` family writer (applies the anchor, then dispatches each fragment via its `func`; core imports no backend). - The spawner `collision_props` slot now also accepts a `CollisionFragment` / `list[CollisionFragment]`. This PR is purely **additive** and self-contained: it builds only on the single-namespace schema-fragment base (`SchemaFragment` + `apply_namespaced`) already in `develop`, existing call sites are untouched (a transition bridge routes legacy single cfgs to the existing `define_`/`modify_` writers), and it does **not** depend on any other open PR. ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots N/A — non-visual API 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 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 --- .../vidurv-schema-frag-collision.minor.rst | 20 +++ source/isaaclab/isaaclab/sim/__init__.pyi | 6 + .../isaaclab/sim/converters/mesh_converter.py | 11 +- .../sim/converters/mesh_converter_cfg.py | 9 +- .../isaaclab/sim/schemas/__init__.pyi | 6 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 30 ++++ .../isaaclab/sim/schemas/schemas_cfg.py | 28 +++ .../sim/spawners/from_files/from_files.py | 7 +- .../isaaclab/sim/spawners/meshes/meshes.py | 7 +- .../isaaclab/sim/spawners/shapes/shapes.py | 7 +- .../isaaclab/sim/spawners/spawner_cfg.py | 13 +- .../test/sim/test_collision_fragments.py | 170 ++++++++++++++++++ .../vidurv-schema-frag-collision.minor.rst | 8 + .../isaaclab_newton/sim/schemas/__init__.pyi | 2 + .../sim/schemas/schemas_cfg.py | 36 ++++ .../vidurv-schema-frag-collision.minor.rst | 7 + .../isaaclab_physx/sim/schemas/__init__.pyi | 2 + .../isaaclab_physx/sim/schemas/schemas_cfg.py | 53 ++++++ 18 files changed, 414 insertions(+), 8 deletions(-) create mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-collision.minor.rst create mode 100644 source/isaaclab/test/sim/test_collision_fragments.py create mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-collision.minor.rst create mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-collision.minor.rst diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-collision.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-collision.minor.rst new file mode 100644 index 00000000000..d31eb5bcee9 --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-collision.minor.rst @@ -0,0 +1,20 @@ +Added +^^^^^ + +* Added the collision schema-fragment API: the + :class:`~isaaclab.sim.schemas.CollisionFragment` marker and + :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` (the ``physics:collisionEnabled`` + single-namespace fragment). Each fragment carries ``_usd_namespace`` / ``_usd_applied_schema`` + metadata and a ``func`` applier so a prim can carry collision properties from multiple USD + namespaces at once. +* Added :func:`~isaaclab.sim.schemas.apply_collision_properties`, which applies a list of + collision fragments with ``UsdPhysics.CollisionAPI`` as the implicit anchor. + +Changed +^^^^^^^ + +* Changed the spawner ``collision_props`` slot + (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.collision_props`) and the mesh-converter + ``collision_props`` slot to also accept a list of + :class:`~isaaclab.sim.schemas.CollisionFragment` fragments. Legacy single cfgs continue to work + through a transition bridge in the spawn writers. diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index dc5d6207e85..d1e36946aa2 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -39,6 +39,7 @@ __all__ = [ "BoundingCubePropertiesCfg", "BoundingSpherePropertiesCfg", "CollisionBaseCfg", + "CollisionFragment", "ConvexDecompositionPropertiesCfg", "ConvexHullPropertiesCfg", "DeformableBodyPropertiesBaseCfg", @@ -63,7 +64,9 @@ __all__ = [ "RigidBodyBaseCfg", "RigidBodyFragment", "SchemaFragment", + "UsdPhysicsCollisionCfg", "UsdPhysicsRigidBodyCfg", + "apply_collision_properties", "apply_mass_properties", "apply_namespaced", "apply_rigid_body_properties", @@ -218,6 +221,7 @@ from .schemas import ( BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, CollisionBaseCfg, + CollisionFragment, ConvexDecompositionPropertiesCfg, ConvexHullPropertiesCfg, DeformableBodyPropertiesBaseCfg, @@ -237,8 +241,10 @@ from .schemas import ( SpatialTendonPropertiesCfg, TriangleMeshPropertiesCfg, TriangleMeshSimplificationPropertiesCfg, + UsdPhysicsCollisionCfg, UsdPhysicsRigidBodyCfg, activate_contact_sensors, + apply_collision_properties, apply_mass_properties, apply_namespaced, apply_rigid_body_properties, diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py index c9ee2cef324..f4022507eec 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py @@ -126,9 +126,16 @@ def _convert_asset(self, cfg: MeshConverterCfg): # Apply collider properties to mesh if cfg.collision_props is not None: # -- Collider properties such as offset, scale, etc. - schemas.define_collision_properties( - prim_path=child_mesh_prim.GetPath(), cfg=cfg.collision_props, stage=stage + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> define_* + coll_frags = ( + cfg.collision_props if isinstance(cfg.collision_props, (list, tuple)) else [cfg.collision_props] ) + if coll_frags and all(isinstance(f, schemas.SchemaFragment) for f in coll_frags): + schemas.apply_collision_properties(str(child_mesh_prim.GetPath()), coll_frags, stage=stage) + else: + schemas.define_collision_properties( + prim_path=child_mesh_prim.GetPath(), cfg=cfg.collision_props, stage=stage + ) # Add collision mesh if cfg.mesh_collision_props is not None: schemas.define_mesh_collision_properties( diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py index 73ec37e777b..09c887a4899 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py @@ -26,9 +26,16 @@ class MeshConverterCfg(AssetConverterBaseCfg): If None, then no rigid body properties will be added. """ - collision_props: schemas_cfg.CollisionPropertiesCfg = None + collision_props: ( + schemas_cfg.CollisionPropertiesCfg | schemas_cfg.CollisionFragment | list[schemas_cfg.CollisionFragment] + ) = None """Collision properties to apply to the USD. Defaults to None. + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.CollisionBaseCfg`) or a + list of :class:`~isaaclab.sim.schemas.CollisionFragment` fragments. When a fragment list is + given, ``UsdPhysics.CollisionAPI`` is applied as the implicit anchor and each fragment writes + its own namespace. + Note: If None, then no collision properties will be added. """ diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index a6ea01a293d..baea5a920f9 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", + "apply_collision_properties", "apply_mass_properties", "apply_namespaced", "apply_rigid_body_properties", @@ -31,6 +32,7 @@ __all__ = [ "BoundingCubePropertiesCfg", "BoundingSpherePropertiesCfg", "CollisionBaseCfg", + "CollisionFragment", "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", "JointDriveBaseCfg", @@ -40,6 +42,7 @@ __all__ = [ "MeshCollisionBaseCfg", "RigidBodyFragment", "SchemaFragment", + "UsdPhysicsCollisionCfg", "UsdPhysicsRigidBodyCfg", "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyPropertiesCfg", @@ -58,6 +61,7 @@ from .schemas import ( PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, activate_contact_sensors, + apply_collision_properties, apply_mass_properties, apply_namespaced, apply_rigid_body_properties, @@ -85,6 +89,7 @@ from .schemas_cfg import ( BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, CollisionBaseCfg, + CollisionFragment, DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, JointDriveBaseCfg, @@ -95,6 +100,7 @@ from .schemas_cfg import ( RigidBodyBaseCfg, RigidBodyFragment, SchemaFragment, + UsdPhysicsCollisionCfg, UsdPhysicsRigidBodyCfg, ) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index a4a6b9f74c8..abbe115541b 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -557,6 +557,36 @@ def modify_rigid_body_properties( """ +def apply_collision_properties( + prim_path: str, fragments: Iterable[schemas_cfg.CollisionFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of collision fragments to a prim. + + Applies ``UsdPhysics.CollisionAPI`` as the implicit anchor (the defining schema for a + collider), then dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Backend fragments carry backend-specific + funcs, so core never imports a backend. + + Args: + prim_path: The prim path to apply the collision schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.CollisionFragment` instances. + 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. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + if not UsdPhysics.CollisionAPI(prim): + UsdPhysics.CollisionAPI.Apply(prim) + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, prim_path, stage) + return True + + def define_collision_properties( prim_path: str, cfg: schemas_cfg.CollisionPropertiesCfg, stage: Usd.Stage | None = None ): diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index 529182915a3..29478e843cf 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -176,6 +176,34 @@ class UsdPhysicsRigidBodyCfg(RigidBodyFragment): """ +@configclass +class CollisionFragment(SchemaFragment): + """Marker base for collision fragments; types the ``collision_props`` slot.""" + + pass + + +@configclass +class UsdPhysicsCollisionCfg(CollisionFragment): + """``physics:*`` collision attributes from `UsdPhysics.CollisionAPI`_. + + The ``UsdPhysics.CollisionAPI`` schema is applied as the implicit anchor by the collision + family writer (:func:`~isaaclab.sim.schemas.apply_collision_properties`), so this fragment + owns no applied schema of its own. + + .. _UsdPhysics.CollisionAPI: https://openusd.org/dev/api/class_usd_physics_collision_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None # CollisionAPI applied by the family anchor + + collision_enabled: bool | None = None + """Whether to enable or disable collisions. + + Writes ``physics:collisionEnabled`` via :class:`UsdPhysics.CollisionAPI`. + """ + + @configclass class ArticulationRootBaseCfg: """Solver-common properties to apply to the root of an articulation. 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 767a3e14ab4..ff51fa97375 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -352,7 +352,12 @@ def _spawn_from_usd_file( schemas.modify_rigid_body_properties(prim_path, cfg.rigid_props) # modify collision properties if cfg.collision_props is not None: - schemas.modify_collision_properties(prim_path, cfg.collision_props) + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> modify_* + coll_frags = cfg.collision_props if isinstance(cfg.collision_props, (list, tuple)) else [cfg.collision_props] + if coll_frags and all(isinstance(f, schemas.SchemaFragment) for f in coll_frags): + schemas.apply_collision_properties(prim_path, coll_frags) + else: + schemas.modify_collision_properties(prim_path, cfg.collision_props) # modify mass properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> modify_*) if cfg.mass_props is not None: # normalize a single fragment to a list so the convenience form routes like a list diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index 6e930e2530b..bf0df38db12 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -415,7 +415,12 @@ def _spawn_mesh_geom_from_mesh( mesh_collision_api = UsdPhysics.MeshCollisionAPI.Apply(mesh_prim) mesh_collision_api.GetApproximationAttr().Set(collision_approximation) # apply collision properties - schemas.define_collision_properties(mesh_prim_path, cfg.collision_props, stage=stage) + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> define_* + coll_frags = cfg.collision_props if isinstance(cfg.collision_props, (list, tuple)) else [cfg.collision_props] + if coll_frags and all(isinstance(f, schemas.SchemaFragment) for f in coll_frags): + schemas.apply_collision_properties(mesh_prim_path, coll_frags, stage=stage) + else: + schemas.define_collision_properties(mesh_prim_path, cfg.collision_props, stage=stage) # apply visual material if cfg.visual_material is not None: diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index 5d841e1c236..2d1b721e956 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -294,7 +294,12 @@ def _spawn_geom_from_prim_type( create_prim(mesh_prim_path, prim_type, scale=scale, attributes=attributes, stage=stage) # apply collision properties if cfg.collision_props is not None: - schemas.define_collision_properties(mesh_prim_path, cfg.collision_props, stage=stage) + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> define_* + coll_frags = cfg.collision_props if isinstance(cfg.collision_props, (list, tuple)) else [cfg.collision_props] + if coll_frags and all(isinstance(f, schemas.SchemaFragment) for f in coll_frags): + schemas.apply_collision_properties(mesh_prim_path, coll_frags, stage=stage) + else: + schemas.define_collision_properties(mesh_prim_path, cfg.collision_props, stage=stage) # apply visual material if cfg.visual_material is not None: if not cfg.visual_material_path.startswith("/"): diff --git a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py index d492e0cae1d..5a655ce38db 100644 --- a/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/spawner_cfg.py @@ -105,8 +105,17 @@ class RigidObjectSpawnerCfg(SpawnerCfg): make the object static and will not be affected by gravity or other forces. """ - collision_props: schemas.CollisionPropertiesCfg | None = None - """Properties to apply to all collision meshes.""" + collision_props: ( + schemas.CollisionPropertiesCfg | schemas.CollisionFragment | list[schemas.CollisionFragment] | None + ) = None + """Properties to apply to all collision meshes. + + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.CollisionBaseCfg`) or a + list of :class:`~isaaclab.sim.schemas.CollisionFragment` fragments + (e.g. ``[UsdPhysicsCollisionCfg(...), PhysxCollisionCfg(...)]``). When a fragment list is given, + ``UsdPhysics.CollisionAPI`` is applied as the implicit anchor and each fragment writes its own + namespace. + """ activate_contact_sensors: bool = False """Activate contact reporting on all rigid bodies. Defaults to False. diff --git a/source/isaaclab/test/sim/test_collision_fragments.py b/source/isaaclab/test/sim/test_collision_fragments.py new file mode 100644 index 00000000000..69800659592 --- /dev/null +++ b/source/isaaclab/test/sim/test_collision_fragments.py @@ -0,0 +1,170 @@ +# 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.""" + +from pxr import UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + + +def _make_xform(stage, path="/World/Body"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# CollisionFragment marker + UsdPhysicsCollisionCfg +# ------------------------------------------------------------------------------------- + + +def test_collision_fragment_metadata_defaults(): + from isaaclab.sim.schemas import CollisionFragment, SchemaFragment, UsdPhysicsCollisionCfg + + cfg = UsdPhysicsCollisionCfg(collision_enabled=True) + assert isinstance(cfg, CollisionFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physics" + assert type(cfg)._usd_applied_schema is None # anchor applies CollisionAPI, not the fragment + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.collision_enabled is True + + +# ------------------------------------------------------------------------------------- +# UsdPhysicsCollisionCfg writes its physics namespace via apply_namespaced +# ------------------------------------------------------------------------------------- + + +def test_usd_physics_collision_fragment_writes_physics_namespace(): + from isaaclab.sim.schemas import UsdPhysicsCollisionCfg, apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage) + UsdPhysics.CollisionAPI.Apply(prim) + apply_namespaced(UsdPhysicsCollisionCfg(collision_enabled=True), "/World/Body", stage) + assert prim.GetAttribute("physics:collisionEnabled").Get() is True + + +# ------------------------------------------------------------------------------------- +# PhysxCollisionCfg (isaaclab_physx) +# ------------------------------------------------------------------------------------- + + +def test_physx_collision_fragment_writes_physx_namespace(): + from isaaclab_physx.sim.schemas import PhysxCollisionCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/C2") + UsdPhysics.CollisionAPI.Apply(prim) + apply_namespaced( + PhysxCollisionCfg(contact_offset=0.02, rest_offset=0.0, torsional_patch_radius=0.1), "/World/C2", stage + ) + assert abs(prim.GetAttribute("physxCollision:contactOffset").Get() - 0.02) < 1e-6 + assert abs(prim.GetAttribute("physxCollision:restOffset").Get() - 0.0) < 1e-6 + assert abs(prim.GetAttribute("physxCollision:torsionalPatchRadius").Get() - 0.1) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# NewtonCollisionCfg (isaaclab_newton) +# ------------------------------------------------------------------------------------- + + +def test_newton_collision_fragment_writes_newton_namespace(): + from isaaclab_newton.sim.schemas import NewtonCollisionCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/C3") + UsdPhysics.CollisionAPI.Apply(prim) + apply_namespaced(NewtonCollisionCfg(contact_margin=0.01, contact_gap=0.005), "/World/C3", stage) + assert abs(prim.GetAttribute("newton:contactMargin").Get() - 0.01) < 1e-6 + assert abs(prim.GetAttribute("newton:contactGap").Get() - 0.005) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# apply_collision_properties dispatch (implicit anchor + multi-namespace) +# ------------------------------------------------------------------------------------- + + +def test_apply_collision_properties_composes_namespaces(): + from isaaclab_newton.sim.schemas import NewtonCollisionCfg + from isaaclab_physx.sim.schemas import PhysxCollisionCfg + + from isaaclab.sim.schemas import UsdPhysicsCollisionCfg, apply_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/C4") + apply_collision_properties( + "/World/C4", + [ + UsdPhysicsCollisionCfg(collision_enabled=True), + PhysxCollisionCfg(contact_offset=0.02), + NewtonCollisionCfg(contact_margin=0.01), + ], + stage, + ) + prim = stage.GetPrimAtPath("/World/C4") + assert bool(UsdPhysics.CollisionAPI(prim)) # implicit anchor applied + assert prim.GetAttribute("physics:collisionEnabled").Get() is True + assert abs(prim.GetAttribute("physxCollision:contactOffset").Get() - 0.02) < 1e-6 + assert abs(prim.GetAttribute("newton:contactMargin").Get() - 0.01) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# spawner slot accepts a fragment list + transition routing +# ------------------------------------------------------------------------------------- + + +def test_spawn_shape_with_collision_fragment_list(): + from isaaclab_physx.sim.schemas import PhysxCollisionCfg + + from isaaclab.sim.schemas import UsdPhysicsCollisionCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = sim_utils.CuboidCfg( + size=(1, 1, 1), + collision_props=[UsdPhysicsCollisionCfg(collision_enabled=True), PhysxCollisionCfg(contact_offset=0.03)], + ) + cfg.func("/World/Cube", cfg) + prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube/geometry/mesh") + assert bool(UsdPhysics.CollisionAPI(prim)) + assert abs(prim.GetAttribute("physxCollision:contactOffset").Get() - 0.03) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_newton.sim.schemas import NewtonCollisionCfg # noqa: F401 + from isaaclab_physx.sim.schemas import PhysxCollisionCfg # noqa: F401 + + from isaaclab.sim.schemas import ( # noqa: F401 + CollisionFragment, + SchemaFragment, + UsdPhysicsCollisionCfg, + apply_collision_properties, + apply_namespaced, + ) diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-collision.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-collision.minor.rst new file mode 100644 index 00000000000..dee587ff7e7 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-collision.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.NewtonCollisionCfg`, the ``newton:*`` + single-namespace collision fragment (``newton:contactMargin``, ``newton:contactGap`` via + ``NewtonCollisionAPI``). It composes with + :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxCollisionCfg` in a ``collision_props`` fragment list. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi index 89ca2d068af..f7413adbb88 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -8,6 +8,7 @@ __all__ = [ "MujocoRigidBodyCfg", "MujocoRigidBodyPropertiesCfg", "NewtonArticulationRootPropertiesCfg", + "NewtonCollisionCfg", "NewtonCollisionPropertiesCfg", "NewtonDeformableBodyPropertiesCfg", "NewtonJointDrivePropertiesCfg", @@ -22,6 +23,7 @@ from .schemas_cfg import ( MujocoRigidBodyCfg, MujocoRigidBodyPropertiesCfg, NewtonArticulationRootPropertiesCfg, + NewtonCollisionCfg, NewtonCollisionPropertiesCfg, NewtonDeformableBodyPropertiesCfg, NewtonJointDrivePropertiesCfg, 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 4c99379e1ad..edf007790b5 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, + CollisionFragment, DeformableBodyPropertiesBaseCfg, JointDriveBaseCfg, MeshCollisionBaseCfg, @@ -157,6 +158,41 @@ class MujocoJointDrivePropertiesCfg(NewtonJointDrivePropertiesCfg): """ +@configclass +class NewtonCollisionCfg(CollisionFragment): + """``newton:*`` collision attributes for Newton's contact pipeline. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) carrying + Newton-native contact-geometry attributes (``NewtonCollisionAPI``). Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` via + :func:`~isaaclab.sim.schemas.apply_collision_properties`. + + .. note:: + The contact / rest offsets live on :class:`~isaaclab_physx.sim.schemas.PhysxCollisionCfg` + as ``physxCollision:*`` fields; Newton reads them via its PhysX-bridge resolver, so they + are not duplicated here. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonCollisionAPI" + + 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 are 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 NewtonCollisionPropertiesCfg(CollisionBaseCfg): """Newton-specific collision properties. diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-collision.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-collision.minor.rst new file mode 100644 index 00000000000..f15e2c27b24 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-collision.minor.rst @@ -0,0 +1,7 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.schemas.PhysxCollisionCfg`, the ``physxCollision:*`` + single-namespace collision fragment (PhysX ``PhysxCollisionAPI``). It carries + ``contact_offset`` / ``rest_offset`` plus the torsional patch-friction fields, and composes with + :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` in a ``collision_props`` fragment list. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 10d2502ddf2..767ba36f06d 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -16,6 +16,7 @@ __all__ = [ "MeshCollisionPropertiesCfg", "OmniPhysicsDeformableBodyPropertiesCfg", "PhysxArticulationRootPropertiesCfg", + "PhysxCollisionCfg", "PhysxCollisionPropertiesCfg", "PhysxConvexDecompositionPropertiesCfg", "PhysxConvexHullPropertiesCfg", @@ -51,6 +52,7 @@ from .schemas_cfg import ( MeshCollisionPropertiesCfg, OmniPhysicsDeformableBodyPropertiesCfg, PhysxArticulationRootPropertiesCfg, + PhysxCollisionCfg, PhysxCollisionPropertiesCfg, PhysxConvexDecompositionPropertiesCfg, PhysxConvexHullPropertiesCfg, 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 18a5de023f9..c81ea84f710 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -11,6 +11,7 @@ from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, CollisionBaseCfg, + CollisionFragment, DeformableBodyPropertiesBaseCfg, JointDriveBaseCfg, MeshCollisionBaseCfg, @@ -391,6 +392,58 @@ def __post_init__(self): super().__post_init__() +@configclass +class PhysxCollisionCfg(CollisionFragment): + """``physxCollision:*`` collision attributes from `PhysxCollisionAPI`_. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) for the + PhysX collision add-on schema. Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` via + :func:`~isaaclab.sim.schemas.apply_collision_properties`. + + The :attr:`contact_offset` / :attr:`rest_offset` knobs live here as plain + ``physxCollision:*`` fields. Newton's USD importer consumes the same attributes via its + PhysX-bridge resolver, so they are not duplicated on the Newton collision fragment. + + .. _PhysxCollisionAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_collision_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physxCollision" + _usd_applied_schema: ClassVar[str | None] = "PhysxCollisionAPI" + + contact_offset: float | None = None + """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 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 [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. + + Writes ``physxCollision:restOffset``. Newton's USD importer consumes the same attribute via its + PhysX-bridge resolver. + """ + + 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 PhysxCollisionPropertiesCfg(CollisionBaseCfg): """PhysX-specific rigid-body collision properties. From 3b17fb95b55575d52a8ac8093f97db3354d6bce0 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:27:13 +0000 Subject: [PATCH 014/139] [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: 7.2.0 → 7.3.0 - isaaclab_newton: 1.2.0 → 1.3.0 - isaaclab_ov: 0.5.1 → 0.5.2 - isaaclab_ovphysx: 3.2.0 → 4.0.0 - isaaclab_physx: 2.2.0 → 2.3.0 - isaaclab_tasks: 8.0.6 → 8.0.7 --- .../changelog.d/mataylor-rtx-preset.rst | 5 -- .../vidurv-schema-frag-collision.minor.rst | 20 -------- .../vidurv-schema-frag-mass.minor.rst | 27 ----------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 47 +++++++++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../vidurv-schema-frag-collision.minor.rst | 8 ---- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 13 +++++ source/isaaclab_newton/pyproject.toml | 2 +- .../normals-rendering-validation.rst | 7 --- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 12 +++++ source/isaaclab_ov/pyproject.toml | 2 +- .../changelog.d/ovphysx-view.major.rst | 15 ------ source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 20 ++++++++ source/isaaclab_ovphysx/pyproject.toml | 2 +- .../vidurv-schema-frag-collision.minor.rst | 7 --- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 12 +++++ source/isaaclab_physx/pyproject.toml | 2 +- .../changelog.d/mataylor-rtx-preset.rst | 5 -- .../normals-rendering-validation.rst | 7 --- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 18 +++++++ source/isaaclab_tasks/pyproject.toml | 2 +- 27 files changed, 134 insertions(+), 113 deletions(-) delete mode 100644 source/isaaclab/changelog.d/mataylor-rtx-preset.rst delete mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-collision.minor.rst delete mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-mass.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-collision.minor.rst delete mode 100644 source/isaaclab_ov/changelog.d/normals-rendering-validation.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/ovphysx-view.major.rst delete mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-collision.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/mataylor-rtx-preset.rst delete mode 100644 source/isaaclab_tasks/changelog.d/normals-rendering-validation.rst diff --git a/source/isaaclab/changelog.d/mataylor-rtx-preset.rst b/source/isaaclab/changelog.d/mataylor-rtx-preset.rst deleted file mode 100644 index 56d0e808801..00000000000 --- a/source/isaaclab/changelog.d/mataylor-rtx-preset.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Changed automatic ``rtx`` launch-time renderer resolution to support the - ``renderer=rtx`` selector. diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-collision.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-collision.minor.rst deleted file mode 100644 index d31eb5bcee9..00000000000 --- a/source/isaaclab/changelog.d/vidurv-schema-frag-collision.minor.rst +++ /dev/null @@ -1,20 +0,0 @@ -Added -^^^^^ - -* Added the collision schema-fragment API: the - :class:`~isaaclab.sim.schemas.CollisionFragment` marker and - :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` (the ``physics:collisionEnabled`` - single-namespace fragment). Each fragment carries ``_usd_namespace`` / ``_usd_applied_schema`` - metadata and a ``func`` applier so a prim can carry collision properties from multiple USD - namespaces at once. -* Added :func:`~isaaclab.sim.schemas.apply_collision_properties`, which applies a list of - collision fragments with ``UsdPhysics.CollisionAPI`` as the implicit anchor. - -Changed -^^^^^^^ - -* Changed the spawner ``collision_props`` slot - (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.collision_props`) and the mesh-converter - ``collision_props`` slot to also accept a list of - :class:`~isaaclab.sim.schemas.CollisionFragment` fragments. Legacy single cfgs continue to work - through a transition bridge in the spawn writers. diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-mass.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-mass.minor.rst deleted file mode 100644 index fbbfa1fb70f..00000000000 --- a/source/isaaclab/changelog.d/vidurv-schema-frag-mass.minor.rst +++ /dev/null @@ -1,27 +0,0 @@ -Added -^^^^^ - -* Added the mass schema-fragment API: the :class:`~isaaclab.sim.schemas.MassFragment` marker and - :class:`~isaaclab.sim.schemas.MassCfg` (writes ``physics:mass`` / ``physics:density`` via - ``UsdPhysics.MassAPI``). The legacy :class:`~isaaclab.sim.schemas.MassPropertiesCfg` remains the - canonical name and continues to work unchanged. -* Added :func:`~isaaclab.sim.schemas.apply_mass_properties`, which applies a list of mass fragments - with ``UsdPhysics.MassAPI`` as the implicit anchor. - -Changed -^^^^^^^ - -* Changed the spawner ``mass_props`` slot - (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.mass_props`) to also accept a single - :class:`~isaaclab.sim.schemas.MassFragment` or a list of them. Legacy - :class:`~isaaclab.sim.schemas.MassPropertiesCfg` cfgs continue to work through a transition bridge - in the spawn writers. - -Fixed -^^^^^ - -* Fixed :func:`~isaaclab.sim.schemas.apply_mass_properties` to raise ``ValueError`` on an invalid - prim path and to aggregate per-fragment results instead of always returning ``True``, matching - :func:`~isaaclab.sim.schemas.apply_rigid_body_properties`. -* Fixed the spawn writers so an empty ``mass_props`` list is a harmless no-op rather than being - forwarded to :func:`~isaaclab.sim.schemas.define_mass_properties` as an unexpected list. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 526b40a09a2..7570819f8ad 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 = "7.2.0" +version = "7.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 715fd93b27e..62f27539be6 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,53 @@ Changelog --------- +7.3.0 (2026-06-26) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the mass schema-fragment API: the :class:`~isaaclab.sim.schemas.MassFragment` marker and + :class:`~isaaclab.sim.schemas.MassCfg` (writes ``physics:mass`` / ``physics:density`` via + ``UsdPhysics.MassAPI``). The legacy :class:`~isaaclab.sim.schemas.MassPropertiesCfg` remains the + canonical name and continues to work unchanged. +* Added :func:`~isaaclab.sim.schemas.apply_mass_properties`, which applies a list of mass fragments + with ``UsdPhysics.MassAPI`` as the implicit anchor. +* Added the collision schema-fragment API: the + :class:`~isaaclab.sim.schemas.CollisionFragment` marker and + :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` (the ``physics:collisionEnabled`` + single-namespace fragment). Each fragment carries ``_usd_namespace`` / ``_usd_applied_schema`` + metadata and a ``func`` applier so a prim can carry collision properties from multiple USD + namespaces at once. +* Added :func:`~isaaclab.sim.schemas.apply_collision_properties`, which applies a list of + collision fragments with ``UsdPhysics.CollisionAPI`` as the implicit anchor. + +Changed +^^^^^^^ + +* Changed automatic ``rtx`` launch-time renderer resolution to support the + ``renderer=rtx`` selector. +* Changed the spawner ``mass_props`` slot + (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.mass_props`) to also accept a single + :class:`~isaaclab.sim.schemas.MassFragment` or a list of them. Legacy + :class:`~isaaclab.sim.schemas.MassPropertiesCfg` cfgs continue to work through a transition bridge + in the spawn writers. +* Changed the spawner ``collision_props`` slot + (:attr:`~isaaclab.sim.spawners.RigidObjectSpawnerCfg.collision_props`) and the mesh-converter + ``collision_props`` slot to also accept a list of + :class:`~isaaclab.sim.schemas.CollisionFragment` fragments. Legacy single cfgs continue to work + through a transition bridge in the spawn writers. + +Fixed +^^^^^ + +* Fixed :func:`~isaaclab.sim.schemas.apply_mass_properties` to raise ``ValueError`` on an invalid + prim path and to aggregate per-fragment results instead of always returning ``True``, matching + :func:`~isaaclab.sim.schemas.apply_rigid_body_properties`. +* Fixed the spawn writers so an empty ``mass_props`` list is a harmless no-op rather than being + forwarded to :func:`~isaaclab.sim.schemas.define_mass_properties` as an unexpected list. + + 7.2.0 (2026-06-25) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 6951b216a78..95af22c8f52 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "7.2.0" +version = "7.3.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-collision.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-collision.minor.rst deleted file mode 100644 index dee587ff7e7..00000000000 --- a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-collision.minor.rst +++ /dev/null @@ -1,8 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_newton.sim.schemas.NewtonCollisionCfg`, the ``newton:*`` - single-namespace collision fragment (``newton:contactMargin``, ``newton:contactGap`` via - ``NewtonCollisionAPI``). It composes with - :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` and - :class:`~isaaclab_physx.sim.schemas.PhysxCollisionCfg` in a ``collision_props`` fragment list. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 17ce08be4fa..76c89b9afd9 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 = "1.2.0" +version = "1.3.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 741050bea56..1f6e2a807fd 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,19 @@ Changelog --------- +1.3.0 (2026-06-26) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.NewtonCollisionCfg`, the ``newton:*`` + single-namespace collision fragment (``newton:contactMargin``, ``newton:contactGap`` via + ``NewtonCollisionAPI``). It composes with + :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxCollisionCfg` in a ``collision_props`` fragment list. + + 1.2.0 (2026-06-25) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index d5eaf633989..38cbf741d7d 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_newton" -version = "1.2.0" +version = "1.3.0" description = "Extension providing IsaacLab with Newton specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ov/changelog.d/normals-rendering-validation.rst b/source/isaaclab_ov/changelog.d/normals-rendering-validation.rst deleted file mode 100644 index c96fc90bb03..00000000000 --- a/source/isaaclab_ov/changelog.d/normals-rendering-validation.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Added ``"normals"`` support to :class:`~isaaclab_ov.renderers.OVRTXRenderer`. The renderer now - declares :attr:`~isaaclab.renderers.RenderBufferKind.NORMALS` in - :meth:`~isaaclab_ov.renderers.OVRTXRenderer.supported_output_types` (3-channel ``float32``) and - extracts the ``NormalSD`` AOV from each rendered frame into the output buffer. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index e40d1954bcd..e5170159518 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.5.1" +version = "0.5.2" 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 0a3c77eafcc..aa17465a7ae 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +0.5.2 (2026-06-26) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``"normals"`` support to :class:`~isaaclab_ov.renderers.OVRTXRenderer`. The renderer now + declares :attr:`~isaaclab.renderers.RenderBufferKind.NORMALS` in + :meth:`~isaaclab_ov.renderers.OVRTXRenderer.supported_output_types` (3-channel ``float32``) and + extracts the ``NormalSD`` AOV from each rendered frame into the output buffer. + + 0.5.1 (2026-06-25) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/pyproject.toml b/source/isaaclab_ov/pyproject.toml index 9c135dda288..3831b4bdf91 100644 --- a/source/isaaclab_ov/pyproject.toml +++ b/source/isaaclab_ov/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ov" -version = "0.5.1" +version = "0.5.2" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ovphysx/changelog.d/ovphysx-view.major.rst b/source/isaaclab_ovphysx/changelog.d/ovphysx-view.major.rst deleted file mode 100644 index a6c0e1be126..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/ovphysx-view.major.rst +++ /dev/null @@ -1,15 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`, a string-keyed binding manager - over the OVPhysX tensor bindings. Attributes are addressed by the lowercased - ``TensorType`` name (e.g. ``view.get_attribute("articulation_dof_stiffness")``, - ``view.read_into("articulation_root_pose", buf)``, - ``view.set_attribute("rigid_body_pose", values, mask=...)``), bringing the OVPhysX - binding surface closer to the Newton selection API. The view reads/writes each binding - on its native device and raises on a device mismatch rather than staging between CPU - and GPU. :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.get_attribute` returns a typed - array for attributes with a structured layout (e.g. ``wp.transformf`` for poses, - ``wp.spatial_vectorf`` for velocities) and flat ``float32`` otherwise, and - :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.read_into` reuses the ``float32`` - reinterpret of a destination buffer across calls so the wheel's read cache stays warm. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index 5b9de244333..5d64576ae09 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 = "3.2.0" +version = "4.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 50dfbf19040..a4829a64709 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,26 @@ Changelog --------- +4.0.0 (2026-06-26) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`, a string-keyed binding manager + over the OVPhysX tensor bindings. Attributes are addressed by the lowercased + ``TensorType`` name (e.g. ``view.get_attribute("articulation_dof_stiffness")``, + ``view.read_into("articulation_root_pose", buf)``, + ``view.set_attribute("rigid_body_pose", values, mask=...)``), bringing the OVPhysX + binding surface closer to the Newton selection API. The view reads/writes each binding + on its native device and raises on a device mismatch rather than staging between CPU + and GPU. :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.get_attribute` returns a typed + array for attributes with a structured layout (e.g. ``wp.transformf`` for poses, + ``wp.spatial_vectorf`` for velocities) and flat ``float32`` otherwise, and + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.read_into` reuses the ``float32`` + reinterpret of a destination buffer across calls so the wheel's read cache stays warm. + + 3.2.0 (2026-06-23) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index 9e58b1ccfb7..4619ddb6c17 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ovphysx" -version = "3.2.0" +version = "4.0.0" description = "Extension providing IsaacLab with ovphysx/TensorBindingsAPI specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-collision.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-collision.minor.rst deleted file mode 100644 index f15e2c27b24..00000000000 --- a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-collision.minor.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_physx.sim.schemas.PhysxCollisionCfg`, the ``physxCollision:*`` - single-namespace collision fragment (PhysX ``PhysxCollisionAPI``). It carries - ``contact_offset`` / ``rest_offset`` plus the torsional patch-friction fields, and composes with - :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` in a ``collision_props`` fragment list. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index b5c4680e9f3..44a7f2fdf03 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 = "2.2.0" +version = "2.3.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 9ace25ecd86..dbe245bc3bc 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +2.3.0 (2026-06-26) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.schemas.PhysxCollisionCfg`, the ``physxCollision:*`` + single-namespace collision fragment (PhysX ``PhysxCollisionAPI``). It carries + ``contact_offset`` / ``rest_offset`` plus the torsional patch-friction fields, and composes with + :class:`~isaaclab.sim.schemas.UsdPhysicsCollisionCfg` in a ``collision_props`` fragment list. + + 2.2.0 (2026-06-25) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index 28ad44e0d2b..d949e646bcd 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "2.2.0" +version = "2.3.0" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/mataylor-rtx-preset.rst b/source/isaaclab_tasks/changelog.d/mataylor-rtx-preset.rst deleted file mode 100644 index 64f639d3f88..00000000000 --- a/source/isaaclab_tasks/changelog.d/mataylor-rtx-preset.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Changed :class:`~isaaclab_tasks.utils.presets.MultiBackendRendererCfg` to - expose automatic ``rtx`` selection through ``renderer=rtx``. diff --git a/source/isaaclab_tasks/changelog.d/normals-rendering-validation.rst b/source/isaaclab_tasks/changelog.d/normals-rendering-validation.rst deleted file mode 100644 index 39a8171a4d5..00000000000 --- a/source/isaaclab_tasks/changelog.d/normals-rendering-validation.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Added ``"normals"`` to the rendering correctness validation suite. The ``normals`` data type - (float32 surface normal vectors in ``[-1, 1]``) is now included in - :data:`~rendering_test_utils._DEFAULT_SENSOR_DATA_TYPES` for all RTX-based renderer - combinations and as an explicit parameter for the Newton Warp renderer. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 007095dd6cf..554c6cdbddf 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 = "8.0.6" +version = "8.0.7" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 0c9659104d3..18d415207bf 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,24 @@ Changelog --------- +8.0.7 (2026-06-26) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``"normals"`` to the rendering correctness validation suite. The ``normals`` data type + (float32 surface normal vectors in ``[-1, 1]``) is now included in + :data:`~rendering_test_utils._DEFAULT_SENSOR_DATA_TYPES` for all RTX-based renderer + combinations and as an explicit parameter for the Newton Warp renderer. + +Changed +^^^^^^^ + +* Changed :class:`~isaaclab_tasks.utils.presets.MultiBackendRendererCfg` to + expose automatic ``rtx`` selection through ``renderer=rtx``. + + 8.0.6 (2026-06-25) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index f55da039202..32d6e948075 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.0.6" +version = "8.0.7" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From c37615a261492b50eb107a3445f07f23b3edc66d Mon Sep 17 00:00:00 2001 From: Jiwen Cai Date: Fri, 26 Jun 2026 14:46:38 +0800 Subject: [PATCH 015/139] Add SO-101 support in Isaac Lab (#5989) # Description Adds so101 stack env for teleop + imitation learning 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: Rafael Wiltz --- CONTRIBUTORS.md | 1 + .../changelog.d/jiwenc-so-101.minor.rst | 25 ++ .../isaaclab/controllers/differential_ik.py | 130 ++++++- .../controllers/differential_ik_cfg.py | 60 ++- .../envs/mdp/actions/task_space_actions.py | 9 + .../test_differential_ik_features.py | 195 ++++++++++ .../changelog.d/jiwenc-so-101.minor.rst | 6 + .../isaaclab_assets/robots/__init__.pyi | 3 + .../isaaclab_assets/robots/so101.py | 86 +++++ .../changelog.d/jiwenc-so-101.minor.rst | 53 +++ .../config/franka/stack_joint_pos_env_cfg.py | 145 ++----- .../contrib/stack/config/so101/__init__.py | 35 ++ .../stack/config/so101/pose_ik_action.py | 44 +++ .../stack/config/so101/pose_ik_action_term.py | 69 ++++ .../stack/config/so101/pose_ik_controller.py | 102 +++++ .../config/so101/stack_ik_abs_env_cfg.py | 298 ++++++++++++++ .../config/so101/stack_joint_pos_env_cfg.py | 129 +++++++ .../contrib/stack/mdp/franka_stack_events.py | 362 ++--------------- .../contrib/stack/mdp/observations.py | 66 ++-- .../contrib/stack/mdp/stack_events.py | 363 ++++++++++++++++++ .../contrib/stack/mdp/terminations.py | 33 +- .../contrib/stack/stack_env_cfg.py | 125 +++++- .../test/contrib/stack/test_so101_pose_ik.py | 190 +++++++++ .../contrib/test_so101_teleop_pipeline.py | 88 +++++ 24 files changed, 2094 insertions(+), 523 deletions(-) create mode 100644 source/isaaclab/changelog.d/jiwenc-so-101.minor.rst create mode 100644 source/isaaclab/test/controllers/test_differential_ik_features.py create mode 100644 source/isaaclab_assets/changelog.d/jiwenc-so-101.minor.rst create mode 100644 source/isaaclab_assets/isaaclab_assets/robots/so101.py create mode 100644 source/isaaclab_tasks/changelog.d/jiwenc-so-101.minor.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/pose_ik_action.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/pose_ik_action_term.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/pose_ik_controller.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_ik_abs_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_joint_pos_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/stack_events.py create mode 100644 source/isaaclab_tasks/test/contrib/stack/test_so101_pose_ik.py create mode 100644 source/isaaclab_tasks/test/contrib/test_so101_teleop_pipeline.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index ee6967f09fc..51c86b8af98 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -110,6 +110,7 @@ Guidelines for modifications: * Jingzhou Liu * Jinqi Wei * Jinyeob Kim +* Jiwen Cai * Johnson Sun * Juana Du * Kaixi Bao diff --git a/source/isaaclab/changelog.d/jiwenc-so-101.minor.rst b/source/isaaclab/changelog.d/jiwenc-so-101.minor.rst new file mode 100644 index 00000000000..8578898f432 --- /dev/null +++ b/source/isaaclab/changelog.d/jiwenc-so-101.minor.rst @@ -0,0 +1,25 @@ +Added +^^^^^ + +* Added an ``"adaptive_dls"`` ``ik_method`` to :class:`~isaaclab.controllers.DifferentialIKController`: + a manipulability-aware damped least squares whose damping ramps from ``lambda_min`` toward + ``lambda_max`` as the smallest task-Jacobian singular value drops below ``sigma_thresh`` + (Maciejewski-Klein style), keeping low-DOF / near-singular arms well-conditioned. +* Added an optional per-axis :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.orientation_weight` + (scalar or ``(wx, wy, wz)``) that soft-weights the orientation rows of a ``"pose"`` task, so an + arm that cannot serve a full 6-DOF pose degrades gracefully instead of leaking orientation error + into position. +* Added null-space joint-limit avoidance to :class:`~isaaclab.controllers.DifferentialIKController` + via :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.joint_limit_avoidance_gain` / + :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.joint_limit_avoidance_margin` and + :meth:`~isaaclab.controllers.DifferentialIKController.set_joint_pos_limits`. When enabled, a + center-seeking bias is projected into the null space of the position rows so it never perturbs + the commanded end-effector position; :class:`~isaaclab.envs.mdp.actions.task_space_actions.DifferentialInverseKinematicsAction` + injects the joint limits automatically. + +Changed +^^^^^^^ + +* Changed :meth:`~isaaclab.controllers.DifferentialIKController.set_command` to renormalize the + commanded quaternion for absolute ``"pose"`` commands, hardening the controller against slightly + non-unit quaternion inputs. Existing unit-quaternion callers are unaffected. diff --git a/source/isaaclab/isaaclab/controllers/differential_ik.py b/source/isaaclab/isaaclab/controllers/differential_ik.py index 9c8f74bade8..51f399ba4be 100644 --- a/source/isaaclab/isaaclab/controllers/differential_ik.py +++ b/source/isaaclab/isaaclab/controllers/differential_ik.py @@ -69,6 +69,20 @@ def __init__(self, cfg: DifferentialIKControllerCfg, num_envs: int, device: str) self.ee_quat_des = torch.zeros(self.num_envs, 4, device=self._device) # -- input command self._command = torch.zeros(self.num_envs, self.action_dim, device=self._device) + # -- optional per-axis orientation task weights (used for "pose" command types only) + if self.cfg.orientation_weight is None: + self._orientation_weight = None + else: + ori_weight = self.cfg.orientation_weight + weight_tuple = ( + (float(ori_weight),) * 3 + if isinstance(ori_weight, (int, float)) + else tuple(float(value) for value in ori_weight) + ) + self._orientation_weight = torch.tensor(weight_tuple, device=self._device) + # -- optional joint position limits for null-space joint-limit avoidance (set externally) + self._joint_pos_lower = None + self._joint_pos_upper = None """ Properties. @@ -144,7 +158,24 @@ def set_command( self.ee_pos_des, self.ee_quat_des = apply_delta_pose(ee_pos, ee_quat, self._command) else: self.ee_pos_des = self._command[:, 0:3] - self.ee_quat_des = self._command[:, 3:7] + # renormalize the commanded quaternion (callers may pass a slightly non-unit quat) + quat = self._command[:, 3:7] + self.ee_quat_des = quat / torch.linalg.norm(quat, dim=-1, keepdim=True) + + def set_joint_pos_limits(self, lower: torch.Tensor, upper: torch.Tensor) -> None: + """Provide the controlled joints' position limits for null-space joint-limit avoidance. + + Only used when + :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.joint_limit_avoidance_gain` + is positive. The IK action term injects these automatically on its first step; call this + manually only when using the controller standalone. + + Args: + lower: Lower joint-position limits in shape (num_joints,). + upper: Upper joint-position limits in shape (num_joints,). + """ + self._joint_pos_lower = lower.to(self._device) + self._joint_pos_upper = upper.to(self._device) def compute( self, ee_pos: torch.Tensor, ee_quat: torch.Tensor, jacobian: torch.Tensor, joint_pos: torch.Tensor @@ -160,17 +191,16 @@ def compute( Returns: The target joint positions commands in shape (N, num_joints). """ - # compute the delta in joint-space + # assemble the task Jacobian and task-space error if "position" in self.cfg.command_type: - position_error = self.ee_pos_des - ee_pos - jacobian_pos = jacobian[:, 0:3] - delta_joint_pos = self._compute_delta_joint_pos(delta_pose=position_error, jacobian=jacobian_pos) + task_jacobian = jacobian[:, 0:3] + task_error = self.ee_pos_des - ee_pos else: - position_error, axis_angle_error = compute_pose_error( - ee_pos, ee_quat, self.ee_pos_des, self.ee_quat_des, rot_error_type="axis_angle" - ) - pose_error = torch.cat((position_error, axis_angle_error), dim=1) - delta_joint_pos = self._compute_delta_joint_pos(delta_pose=pose_error, jacobian=jacobian) + task_jacobian, task_error = self._compute_pose_task(ee_pos, ee_quat, jacobian) + # compute the delta in joint-space + delta_joint_pos = self._compute_delta_joint_pos(delta_pose=task_error, jacobian=task_jacobian) + # add an optional null-space joint-limit-avoidance bias (a no-op when joint_limit_avoidance_gain == 0) + delta_joint_pos = delta_joint_pos + self._joint_limit_avoidance(joint_pos, task_jacobian) # return the desired joint positions return joint_pos + delta_joint_pos @@ -235,7 +265,87 @@ def _compute_delta_joint_pos(self, delta_pose: torch.Tensor, jacobian: torch.Ten jacobian_T @ torch.inverse(jacobian @ jacobian_T + lambda_matrix) @ delta_pose.unsqueeze(-1) ) delta_joint_pos = delta_joint_pos.squeeze(-1) + elif self.cfg.ik_method == "adaptive_dls": # manipulability-aware damped least squares + # parameters + lambda_min = self.cfg.ik_params["lambda_min"] + lambda_max = self.cfg.ik_params["lambda_max"] + sigma_thresh = self.cfg.ik_params["sigma_thresh"] + # per-environment squared damping: lambda_min^2 away from singularities, ramping + # quadratically up to lambda_max^2 as the smallest task-Jacobian singular value -> 0 + # (Maciejewski-Klein). Keying off the full task Jacobian damps both position and + # orientation rank-loss configurations. + sigma_min = torch.linalg.svdvals(jacobian)[:, -1] # (N,) + ratio = (sigma_min / sigma_thresh).clamp(max=1.0) + lambda_sq = lambda_min**2 + (1.0 - ratio**2) * (lambda_max**2 - lambda_min**2) # (N,) + jacobian_T = torch.transpose(jacobian, dim0=1, dim1=2) + lambda_matrix = lambda_sq.view(-1, 1, 1) * torch.eye(n=jacobian.shape[1], device=self._device) + delta_joint_pos = torch.bmm( + jacobian_T, + torch.linalg.solve(torch.bmm(jacobian, jacobian_T) + lambda_matrix, delta_pose.unsqueeze(-1)), + ).squeeze(-1) else: raise ValueError(f"Unsupported inverse-kinematics method: {self.cfg.ik_method}") return delta_joint_pos + + def _compute_pose_task( + self, ee_pos: torch.Tensor, ee_quat: torch.Tensor, jacobian: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor]: + """Assemble the (optionally orientation-weighted) pose task Jacobian and error. + + The orientation error is the axis-angle of ``q_des * q_cur^-1`` from + :func:`~isaaclab.utils.math.compute_pose_error`. When + :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.orientation_weight` + is set, the 3 orientation rows of both the Jacobian and the error are scaled per + base-frame axis (a weight of 0 drops that axis from the solve). Subclasses may override + this to further shape the task (e.g. masking which joints serve orientation). + + Args: + ee_pos: Current end-effector position in shape (N, 3). + ee_quat: Current end-effector orientation (x, y, z, w) in shape (N, 4). + jacobian: The geometric Jacobian in shape (N, 6, num_joints). + + Returns: + A tuple ``(task_jacobian, task_error)`` with the (N, 6, num_joints) task Jacobian and + the (N, 6) task-space error. + """ + position_error, axis_angle_error = compute_pose_error( + ee_pos, ee_quat, self.ee_pos_des, self.ee_quat_des, rot_error_type="axis_angle" + ) + task_jacobian = jacobian + if self._orientation_weight is not None: + weight = self._orientation_weight + task_jacobian = torch.cat([jacobian[:, 0:3, :], jacobian[:, 3:6, :] * weight.view(1, 3, 1)], dim=1) + axis_angle_error = axis_angle_error * weight.view(1, 3) + task_error = torch.cat((position_error, axis_angle_error), dim=1) + return task_jacobian, task_error + + def _joint_limit_avoidance(self, joint_pos: torch.Tensor, task_jacobian: torch.Tensor) -> torch.Tensor: + """Null-space joint-centering bias that keeps joints off their position limits. + + Projects a center-seeking joint velocity (active only within + :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.joint_limit_avoidance_margin` + of a limit) into the null space of the position (linear) task rows, so it never perturbs + the commanded end-effector position. Returns zeros when disabled (``joint_limit_avoidance_gain == 0``) or + before joint limits are provided via :meth:`set_joint_pos_limits`. + + Args: + joint_pos: Current joint positions in shape (N, num_joints). + task_jacobian: The task Jacobian in shape (N, T, num_joints); rows 0-2 are the + position (linear) rows. + + Returns: + The joint-space correction in shape (N, num_joints). + """ + if self.cfg.joint_limit_avoidance_gain <= 0.0 or self._joint_pos_lower is None: + return torch.zeros_like(joint_pos) + lower, upper = self._joint_pos_lower, self._joint_pos_upper + q_mid = 0.5 * (lower + upper) + dist = torch.minimum(joint_pos - lower, upper - joint_pos) # margin to nearest limit + activation = 1.0 - (dist / self.cfg.joint_limit_avoidance_margin).clamp(0.0, 1.0) # 1 at the limit, 0 mid-range + dq_center = -self.cfg.joint_limit_avoidance_gain * activation * (joint_pos - q_mid) # toward the joint center + j_pos = task_jacobian[:, :3, :] + j_pos_pinv = torch.linalg.pinv(j_pos) + num_joints = task_jacobian.shape[2] + null_proj = torch.eye(num_joints, device=self._device) - torch.bmm(j_pos_pinv, j_pos) + return torch.bmm(null_proj, dq_center.unsqueeze(-1)).squeeze(-1) diff --git a/source/isaaclab/isaaclab/controllers/differential_ik_cfg.py b/source/isaaclab/isaaclab/controllers/differential_ik_cfg.py index 409ecc9429f..58c2fbcce2c 100644 --- a/source/isaaclab/isaaclab/controllers/differential_ik_cfg.py +++ b/source/isaaclab/isaaclab/controllers/differential_ik_cfg.py @@ -35,7 +35,7 @@ class DifferentialIKControllerCfg: Otherwise, the controller treats the input command as the absolute position/pose. """ - ik_method: Literal["pinv", "svd", "trans", "dls"] = MISSING + ik_method: Literal["pinv", "svd", "trans", "dls", "adaptive_dls"] = MISSING """Method for computing inverse of Jacobian.""" ik_params: dict[str, float] | None = None @@ -51,13 +51,48 @@ class DifferentialIKControllerCfg: - "k_val": Scaling of computed delta-joint positions (default: 1.0). - Damped Moore-Penrose pseudo-inverse ("dls"): - "lambda_val": Damping coefficient (default: 0.01). + - Manipulability-aware damped least squares ("adaptive_dls"): + - "lambda_min": Baseline damping coefficient used away from singularities (default: 0.05). + - "lambda_max": Maximum damping coefficient, reached as the smallest task-Jacobian + singular value approaches zero (default: 0.2). + - "sigma_thresh": Smallest-singular-value threshold below which the damping ramps + quadratically from ``lambda_min`` toward ``lambda_max`` (Maciejewski-Klein style) + (default: 0.02). """ + orientation_weight: float | tuple[float, float, float] | None = None + """Soft weight on the orientation task rows for ``"pose"`` command types. Defaults to ``None`` + (the orientation rows keep weight 1, i.e. unchanged behavior). + + A scalar weights all three orientation rows equally; a per-axis ``(wx, wy, wz)`` weights the + base-frame orientation axes independently. Scaling an orientation row (and its error) by a + weight de-emphasises -- or, at weight 0, drops -- that rotation DOF in the solve without + changing the task dimensionality. This is useful for arms that cannot serve a full 6-DOF pose + (e.g. a 5-DOF arm) so the unreachable orientation DOF degrades gracefully instead of leaking + error into the position rows. Ignored for ``"position"`` command types. + """ + + joint_limit_avoidance_gain: float = 0.0 + """Gain for the null-space joint-limit-avoidance bias. ``0`` disables it (default). + + When positive, a center-seeking joint velocity (active only within + :attr:`joint_limit_avoidance_margin` of a limit) is projected into the null space of the + position task rows, so it keeps joints off their limits without perturbing the commanded + end-effector position. Active only once joint limits are provided via + :meth:`~isaaclab.controllers.differential_ik.DifferentialIKController.set_joint_pos_limits` + (the IK action term injects them automatically when ``joint_limit_avoidance_gain > 0``). + """ + + joint_limit_avoidance_margin: float = 0.3 + """Joint-range margin within which the joint-limit-avoidance bias activates (1 at the limit, + ramping to 0 at ``joint_limit_avoidance_margin`` away from it). Units match the joints + (e.g. [rad] for revolute joints).""" + def __post_init__(self): # check valid input if self.command_type not in ["position", "pose"]: raise ValueError(f"Unsupported inverse-kinematics command: {self.command_type}.") - if self.ik_method not in ["pinv", "svd", "trans", "dls"]: + if self.ik_method not in ["pinv", "svd", "trans", "dls", "adaptive_dls"]: raise ValueError(f"Unsupported inverse-kinematics method: {self.ik_method}.") # default parameters for different inverse kinematics approaches. default_ik_params = { @@ -65,9 +100,30 @@ def __post_init__(self): "svd": {"k_val": 1.0, "min_singular_value": 1e-5}, "trans": {"k_val": 1.0}, "dls": {"lambda_val": 0.01}, + "adaptive_dls": {"lambda_min": 0.05, "lambda_max": 0.2, "sigma_thresh": 0.02}, } # update parameters for IK-method if not provided ik_params = default_ik_params[self.ik_method].copy() if self.ik_params is not None: ik_params.update(self.ik_params) self.ik_params = ik_params + # validate adaptive_dls parameters + if self.ik_method == "adaptive_dls": + if self.ik_params["sigma_thresh"] <= 0.0: + raise ValueError(f"adaptive_dls sigma_thresh must be > 0, got {self.ik_params['sigma_thresh']}.") + if self.ik_params["lambda_min"] > self.ik_params["lambda_max"]: + raise ValueError( + f"adaptive_dls lambda_min ({self.ik_params['lambda_min']}) must be <= " + f"lambda_max ({self.ik_params['lambda_max']})." + ) + # validate optional orientation weighting / joint-limit-avoidance settings + if self.orientation_weight is not None and not isinstance(self.orientation_weight, (int, float)): + if len(self.orientation_weight) != 3: + raise ValueError( + "orientation_weight must be a scalar or a length-3 (wx, wy, wz) tuple, got " + f"{self.orientation_weight}." + ) + if self.joint_limit_avoidance_gain < 0.0: + raise ValueError(f"joint_limit_avoidance_gain must be >= 0, got {self.joint_limit_avoidance_gain}.") + if self.joint_limit_avoidance_margin <= 0.0: + raise ValueError(f"joint_limit_avoidance_margin must be > 0, got {self.joint_limit_avoidance_margin}.") 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 8d5496b7678..ac1a713d234 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py @@ -90,6 +90,9 @@ def __init__(self, cfg: actions_cfg.DifferentialInverseKinematicsActionCfg, env: self._ik_controller = DifferentialIKController( cfg=self.cfg.controller, num_envs=self.num_envs, device=self.device ) + # joint limits are injected lazily on the first apply (asset data is populated by then) so + # the controller can do null-space joint-limit avoidance; only needed when joint_limit_avoidance_gain > 0. + self._limits_injected = False # create tensors for raw and processed actions self._raw_actions = torch.zeros(self.num_envs, self.action_dim, device=self.device) @@ -201,6 +204,12 @@ def apply_actions(self): # obtain quantities from simulation ee_pos_curr, ee_quat_curr = self._compute_frame_pose() joint_pos = self._asset.data.joint_pos.torch[:, self._joint_ids] + # lazily provide joint limits to the controller for null-space joint-limit avoidance + # (limits are uniform across envs for these articulations; env 0 is representative) + if not self._limits_injected and getattr(self.cfg.controller, "joint_limit_avoidance_gain", 0.0) > 0.0: + limits = self._asset.data.soft_joint_pos_limits.torch[0, self._joint_ids, :] + self._ik_controller.set_joint_pos_limits(limits[:, 0].clone(), limits[:, 1].clone()) + self._limits_injected = True # compute the delta in joint-space if ee_quat_curr.norm() != 0: jacobian = self._compute_frame_jacobian() diff --git a/source/isaaclab/test/controllers/test_differential_ik_features.py b/source/isaaclab/test/controllers/test_differential_ik_features.py new file mode 100644 index 00000000000..40ce0dd3d5c --- /dev/null +++ b/source/isaaclab/test/controllers/test_differential_ik_features.py @@ -0,0 +1,195 @@ +# 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 + +"""Sim-free unit tests for the optional :class:`DifferentialIKController` features. + +Covers the ``adaptive_dls`` ik-method, per-axis orientation weighting, null-space joint-limit +avoidance, and quaternion renormalization -- all exercised with hand-built tensors (no gym.make, +USD, or GPU). The simulated convergence tests live in ``test_differential_ik.py``. +""" + +import math + +import pytest +import torch + +from isaaclab.controllers.differential_ik import DifferentialIKController +from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg + +_NUM_JOINTS = 5 +_ID_QUAT = [0.0, 0.0, 0.0, 1.0] # xyzw identity + + +def _quat_xyzw(axis: list[float], angle: float) -> list[float]: + """Build a unit xyzw quaternion from an axis (need not be unit) and angle [rad].""" + norm = math.sqrt(sum(a * a for a in axis)) or 1.0 + s = math.sin(angle / 2.0) + return [axis[0] / norm * s, axis[1] / norm * s, axis[2] / norm * s, math.cos(angle / 2.0)] + + +def _make_controller( + command_type: str = "pose", + ik_method: str = "adaptive_dls", + ik_params: dict | None = None, + orientation_weight=None, + joint_limit_avoidance_gain: float = 0.0, + joint_limit_avoidance_margin: float = 0.3, + num_envs: int = 1, +) -> DifferentialIKController: + cfg = DifferentialIKControllerCfg( + command_type=command_type, + use_relative_mode=False, + ik_method=ik_method, + ik_params=ik_params, + orientation_weight=orientation_weight, + joint_limit_avoidance_gain=joint_limit_avoidance_gain, + joint_limit_avoidance_margin=joint_limit_avoidance_margin, + ) + return DifferentialIKController(cfg, num_envs=num_envs, device="cpu") + + +def test_adaptive_dls_default_params(): + """The cfg fills the adaptive_dls defaults when ``ik_params`` is not provided.""" + cfg = DifferentialIKControllerCfg(command_type="pose", use_relative_mode=False, ik_method="adaptive_dls") + assert set(cfg.ik_params) == {"lambda_min", "lambda_max", "sigma_thresh"} + + +def test_cfg_rejects_bad_orientation_weight(): + with pytest.raises(ValueError): + DifferentialIKControllerCfg( + command_type="pose", use_relative_mode=False, ik_method="dls", orientation_weight=(0.3, 0.3) + ) + + +def test_cfg_rejects_bad_adaptive_params(): + with pytest.raises(ValueError): + DifferentialIKControllerCfg( + command_type="pose", + use_relative_mode=False, + ik_method="adaptive_dls", + ik_params={"lambda_min": 0.5, "lambda_max": 0.1, "sigma_thresh": 0.02}, + ) + + +def test_set_command_renormalizes_quat(): + """A non-unit commanded quaternion is stored renormalized in pose (absolute) mode.""" + c = _make_controller() + raw = torch.tensor([0.2588, 0.0, 0.0, 0.9659]) # xyzw + cmd = torch.cat([torch.tensor([0.3, -0.1, 0.2]), raw * 3.0]).unsqueeze(0) # non-unit + c.set_command(cmd) + stored = c.ee_quat_des[0] + assert torch.linalg.norm(stored).item() == pytest.approx(1.0, abs=1e-6) + torch.testing.assert_close(stored, raw / torch.linalg.norm(raw), atol=1e-6, rtol=0.0) + + +def test_orientation_weight_none_is_unweighted(): + """With no orientation weight, the pose task Jacobian equals the raw Jacobian.""" + c = _make_controller(orientation_weight=None) + ee_pos = torch.tensor([[0.3, 0.0, 0.2]]) + ee_quat = torch.tensor([_ID_QUAT]) + c.set_command(torch.tensor([[0.31, 0.0, 0.2] + _quat_xyzw([1.0, 0.0, 0.0], 0.5)])) + jac = torch.arange(6 * _NUM_JOINTS, dtype=torch.float32).reshape(1, 6, _NUM_JOINTS) + task_jac, _ = c._compute_pose_task(ee_pos, ee_quat, jac) + torch.testing.assert_close(task_jac, jac) + + +def test_orientation_weight_per_axis_scales_rows_and_error(): + """A per-axis (wx, wy, wz) weight scales each base-frame orientation row and error; wz=0 drops + the yaw row. Position rows/error are untouched.""" + ee_pos = torch.tensor([[0.3, 0.0, 0.2]]) + ee_quat = torch.tensor([_ID_QUAT]) + jac = torch.arange(6 * _NUM_JOINTS, dtype=torch.float32).reshape(1, 6, _NUM_JOINTS) + cmd = torch.tensor([[0.31, 0.0, 0.2] + _quat_xyzw([0.3, 0.5, 0.8], 0.7)]) + + base = _make_controller(orientation_weight=None) + base.set_command(cmd) + jb, eb = base._compute_pose_task(ee_pos, ee_quat, jac) + + c = _make_controller(orientation_weight=(0.4, 0.2, 0.0)) + c.set_command(cmd) + jp, ep = c._compute_pose_task(ee_pos, ee_quat, jac) + + torch.testing.assert_close(jp[:, :3, :], jb[:, :3, :]) # position rows unchanged + torch.testing.assert_close(jp[:, 3, :], 0.4 * jb[:, 3, :]) + torch.testing.assert_close(jp[:, 4, :], 0.2 * jb[:, 4, :]) + torch.testing.assert_close(jp[:, 5, :], torch.zeros_like(jb[:, 5, :])) # yaw dropped + torch.testing.assert_close(ep[:, :3], eb[:, :3]) + torch.testing.assert_close(ep[:, 3], 0.4 * eb[:, 3]) + torch.testing.assert_close(ep[:, 5], torch.zeros_like(eb[:, 5])) + + +def test_compute_pose_task_quat_convention_xyzw(): + """Discriminating regression for the xyzw quaternion convention: commanding the EE's current + orientation yields zero orientation error. A wxyz mis-read would corrupt this.""" + rot = pytest.importorskip("scipy.spatial.transform").Rotation.from_euler("x", 30.0, degrees=True) + q_xyzw = rot.as_quat() # [x, y, z, w] + ee_quat = torch.tensor(q_xyzw, dtype=torch.float32).unsqueeze(0) + ee_pos = torch.tensor([[0.3, 0.0, 0.2]]) + c = _make_controller(orientation_weight=1.0) + cmd = torch.cat([torch.tensor([0.3, 0.0, 0.2]), torch.tensor(q_xyzw, dtype=torch.float32)]).unsqueeze(0) + c.set_command(cmd) + _, err = c._compute_pose_task(ee_pos, ee_quat, torch.zeros(1, 6, _NUM_JOINTS)) + assert torch.linalg.norm(err[0, 3:6]).item() == pytest.approx(0.0, abs=1e-6) + + +def test_adaptive_dls_damps_singularity(): + """Near a task-Jacobian singularity, the adaptive ramp produces a smaller (more damped) and + finite step than a fixed ``lambda_min`` solve would.""" + c = _make_controller(ik_params={"lambda_min": 0.01, "lambda_max": 0.5, "sigma_thresh": 0.1}) + j_task = torch.zeros(1, 6, _NUM_JOINTS) + j_task[0, 0, 0] = j_task[0, 1, 1] = j_task[0, 2, 2] = 1.0 # well-conditioned position block + eps = 1e-3 + j_task[0, 3, 3] = j_task[0, 4, 4] = eps # near-singular orientation block + err = torch.zeros(1, 6) + err[0, 3] = err[0, 4] = 1.0 + + dq = c._compute_delta_joint_pos(delta_pose=err, jacobian=j_task) + # reference: fixed lambda_min damped least squares + jt = j_task.transpose(1, 2) + a_min = torch.bmm(j_task, jt) + (0.01**2) * torch.eye(6) + dq_min = torch.bmm(jt, torch.linalg.solve(a_min, err.unsqueeze(-1))).squeeze(-1) + assert torch.isfinite(dq).all() + assert dq.norm().item() < dq_min.norm().item() + + +def test_joint_limit_avoidance_zero_when_disabled(): + """JLA returns zeros when joint_limit_avoidance_gain == 0 (default) or before limits are provided.""" + c = _make_controller(joint_limit_avoidance_gain=0.0) + out = c._joint_limit_avoidance(torch.zeros(1, _NUM_JOINTS), torch.ones(1, 6, _NUM_JOINTS)) + torch.testing.assert_close(out, torch.zeros(1, _NUM_JOINTS)) + # enabled but limits not set yet -> still zeros + c2 = _make_controller(joint_limit_avoidance_gain=1.0) + out2 = c2._joint_limit_avoidance(torch.zeros(1, _NUM_JOINTS), torch.ones(1, 6, _NUM_JOINTS)) + torch.testing.assert_close(out2, torch.zeros(1, _NUM_JOINTS)) + + +def test_joint_limit_avoidance_stays_in_position_nullspace(): + """When enabled, the JLA correction lies in the null space of the position rows, so it does not + perturb the commanded end-effector position (``J_pos @ correction ~= 0``).""" + c = _make_controller(joint_limit_avoidance_gain=2.0, joint_limit_avoidance_margin=0.3) + c.set_joint_pos_limits(torch.full((_NUM_JOINTS,), -1.0), torch.full((_NUM_JOINTS,), 1.0)) + # a generic well-conditioned task Jacobian + torch.manual_seed(0) + j_task = torch.randn(1, 6, _NUM_JOINTS) + # joints near their limits -> non-zero center-seeking bias + joint_pos = torch.tensor([[0.95, -0.9, 0.0, 0.8, -0.85]]) + correction = c._joint_limit_avoidance(joint_pos, j_task) + assert correction.norm().item() > 0.0 # bias is active + residual = torch.bmm(j_task[:, :3, :], correction.unsqueeze(-1)).squeeze(-1) + torch.testing.assert_close(residual, torch.zeros_like(residual), atol=1e-5, rtol=0.0) + + +def test_compute_returns_joint_targets_shape(): + """compute returns one target per joint (joint_pos + delta).""" + c = _make_controller(orientation_weight=(0.5, 0.5, 0.0), joint_limit_avoidance_gain=0.5) + c.set_joint_pos_limits(torch.full((_NUM_JOINTS,), -1.0), torch.full((_NUM_JOINTS,), 1.0)) + ee_pos = torch.tensor([[0.3, 0.0, 0.2]]) + ee_quat = torch.tensor([_ID_QUAT]) + c.set_command(torch.tensor([[0.31, 0.0, 0.2] + _ID_QUAT])) + jac = torch.zeros(1, 6, _NUM_JOINTS) + for i in range(5): + jac[0, i, i] = 1.0 + out = c.compute(ee_pos, ee_quat, jac, torch.zeros(1, _NUM_JOINTS)) + assert out.shape == (1, _NUM_JOINTS) diff --git a/source/isaaclab_assets/changelog.d/jiwenc-so-101.minor.rst b/source/isaaclab_assets/changelog.d/jiwenc-so-101.minor.rst new file mode 100644 index 00000000000..692dcf5f187 --- /dev/null +++ b/source/isaaclab_assets/changelog.d/jiwenc-so-101.minor.rst @@ -0,0 +1,6 @@ +Added +^^^^^ + +* Added :obj:`~isaaclab_assets.robots.so101.SO101_CFG` and + :obj:`~isaaclab_assets.robots.so101.SO101_HIGH_PD_CFG` configurations for the + TheRobotStudio SO-101 5-DOF follower arm. diff --git a/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi b/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi index 1a91afa213a..2440b5d8a79 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi +++ b/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi @@ -36,6 +36,8 @@ __all__ = [ "RIDGEBACK_FRANKA_PANDA_CFG", "SAWYER_CFG", "SHADOW_HAND_CFG", + "SO101_CFG", + "SO101_HIGH_PD_CFG", "joint_parameter_lookup", "SPOT_CFG", "GO1_ACTUATOR_CFG", @@ -85,6 +87,7 @@ from .quadcopter import CRAZYFLIE_CFG from .ridgeback_franka import RIDGEBACK_FRANKA_PANDA_CFG from .sawyer import SAWYER_CFG from .shadow_hand import SHADOW_HAND_CFG +from .so101 import SO101_CFG, SO101_HIGH_PD_CFG from .spot import joint_parameter_lookup, SPOT_CFG from .unitree import ( GO1_ACTUATOR_CFG, diff --git a/source/isaaclab_assets/isaaclab_assets/robots/so101.py b/source/isaaclab_assets/isaaclab_assets/robots/so101.py new file mode 100644 index 00000000000..aba5a180a33 --- /dev/null +++ b/source/isaaclab_assets/isaaclab_assets/robots/so101.py @@ -0,0 +1,86 @@ +# 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 TheRobotStudio SO-101 follower arm. + +The following configurations are available: + +* :obj:`SO101_CFG`: SO-101 5-DOF arm with a single-jaw gripper. +* :obj:`SO101_HIGH_PD_CFG`: SO-101 with a stiffer PD controller for task-space (IK) tracking. + +The SO-101 is a low-cost 5-DOF arm (``shoulder_pan``, ``shoulder_lift``, ``elbow_flex``, +``wrist_flex``, ``wrist_roll``) plus a single revolute ``gripper`` jaw. Because the arm has +only 5 actuated DOF, it cannot achieve an arbitrary 6-DOF end-effector pose; task-space +controllers should command the full pose but soft-weight the orientation rows so position is +tracked exactly and orientation is best-effort (see the cube-stack IK-Abs task). + +Reference: https://github.com/TheRobotStudio/SO-ARM100 +Actuator gains follow the values tuned for simulation in LeIsaac. +""" + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets.articulation import ArticulationCfg +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR + +## +# Configuration +## + +SO101_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/RobotStudio/so101_new_calib/so101_new_calib.usd", + activate_contact_sensors=False, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + max_depenetration_velocity=5.0, + ), + articulation_props=sim_utils.ArticulationRootPropertiesCfg( + enabled_self_collisions=True, + solver_position_iteration_count=8, + solver_velocity_iteration_count=0, + fix_root_link=True, + ), + ), + init_state=ArticulationCfg.InitialStateCfg( + joint_pos={ + "shoulder_pan": 0.0, + "shoulder_lift": 0.0, + "elbow_flex": 0.0, + "wrist_flex": 0.0, + "wrist_roll": 0.0, + "gripper": 0.0, + }, + ), + actuators={ + "arm": ImplicitActuatorCfg( + joint_names_expr=["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll"], + effort_limit_sim=10.0, + velocity_limit_sim=10.0, + stiffness=17.8, + damping=0.60, + ), + "gripper": ImplicitActuatorCfg( + joint_names_expr=["gripper"], + effort_limit_sim=10.0, + velocity_limit_sim=10.0, + stiffness=17.8, + damping=0.60, + ), + }, + soft_joint_pos_limit_factor=1.0, +) +"""Configuration of the SO-101 follower arm with implicit actuators.""" + + +SO101_HIGH_PD_CFG = SO101_CFG.copy() +SO101_HIGH_PD_CFG.spawn.rigid_props.disable_gravity = True +SO101_HIGH_PD_CFG.actuators["arm"].stiffness = 400.0 +SO101_HIGH_PD_CFG.actuators["arm"].damping = 80.0 +"""Configuration of the SO-101 follower arm with stiffer PD control. + +This configuration is useful for task-space control using differential IK, where the +default low-stiffness gains track end-effector targets poorly. +""" diff --git a/source/isaaclab_tasks/changelog.d/jiwenc-so-101.minor.rst b/source/isaaclab_tasks/changelog.d/jiwenc-so-101.minor.rst new file mode 100644 index 00000000000..a4b858d955a --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/jiwenc-so-101.minor.rst @@ -0,0 +1,53 @@ +Added +^^^^^ + +* Added ``Isaac-Stack-Cube-SO101-v0`` and ``Isaac-Stack-Cube-SO101-IK-Abs-v0`` cube-stacking + environments for the SO-101 5-DOF arm. The IK-Abs variant uses absolute task-space + differential inverse kinematics and is seated on the table. +* Generalized the cube-stack MDP gripper observations and terminations to support single-jaw + grippers in addition to two-finger parallel grippers. +* Added XR teleoperation for ``Isaac-Stack-Cube-SO101-IK-Abs-v0`` via an IsaacTeleop retargeting + pipeline built from the SO-101 retargeters in ``isaacteleop.retargeters``. Controller poses are + rebased into the robot base frame upstream via + :attr:`~isaaclab_teleop.IsaacTeleopCfg.target_frame_prim_path` (set to the seated, +90 deg-yawed + base prim), so the retargeters work directly in the IK command frame. End-effector motion is + clutch-rebased around the pose captured on engage: the clutch latches on the first running frame + (the headset "Play") and seeds its home from a static reset-origin (the gripper's pose in the + base frame at the seated init pose) so engaging with a steady controller does not move the arm, + then keeps a running home so a mid-task re-clutch resumes from the last commanded pose. The + gripper jaw tracks the controller trigger continuously (analog). + +Changed +^^^^^^^ + +* **Breaking:** Reworked the ``Isaac-Stack-Cube-SO101-IK-Abs-v0`` teleop control to command the + full end-effector SE3 pose. The arm action is now an 8-D + ``[pos_x, pos_y, pos_z, quat_x, quat_y, quat_z, quat_w, gripper]`` (orientation xyzw), + replacing the previous 6-D ``[pos_x, pos_y, pos_z, pitch, roll, gripper]``. A single + full-pose differential IK (``SO101PoseIKController``, ``command_type="pose"``) now solves all + **5** arm joints (``shoulder_pan``, ``shoulder_lift``, ``elbow_flex``, ``wrist_flex``, + ``wrist_roll``) over a 6-row task: 3 linear rows track position exactly (weight 1) and 3 + orientation rows are soft-weighted by the core controller's ``orientation_weight`` (default + ``0.5``). This replaces the reduced 4-row ``[x, y, z, pitch]`` IK over 4 joints plus a separate + ``wrist_roll`` action. The manipulability-aware damped least squares + (``ik_method="adaptive_dls"``) and null-space joint-limit avoidance are now provided by the core + :class:`~isaaclab.controllers.DifferentialIKController`; ``SO101PoseIKController`` only adds the + wrist-only orientation joint mask (restricting orientation to ``wrist_flex`` / ``wrist_roll`` so + ``shoulder_pan`` stays position-only) on top. + + Migration: the controller / action classes were renamed + ``SO101PositionPitchIK{Controller,ControllerCfg,Action,ActionCfg}`` -> + ``SO101PoseIK{...}`` (modules ``position_pitch_ik_{controller,action}.py`` -> + ``pose_ik_{controller,action}.py``), the cfg field ``pitch_task_weight`` -> + ``orientation_weight``, and the ``wrist_roll_action`` term was removed (``wrist_roll`` is + now solved by the IK). The SO-101 ``SO101WristRetargeter`` (and its ``SO101RollRetargeter`` + alias) was removed from ``isaacteleop.retargeters``; the clutch retargeter now drives the full + pose with a fixed orientation calibration offset. +* Refactored the cube-stack scaffolding so the SO-101 config no longer depends on Franka. The + cube setup, table/ground/robot semantics, end-effector frame builder, and reset events were + promoted to the robot-neutral :class:`~isaaclab_tasks.contrib.stack.stack_env_cfg.StackEnvCfg` + base, and the Franka and SO-101 joint-position configs are now override-only. The generic stack + event functions moved to ``isaaclab_tasks.contrib.stack.mdp.stack_events`` (``franka_stack_events`` + re-exports them for backward compatibility), and ``randomize_joint_by_gaussian_offset`` now holds + the gripper joints fixed by resolving them from the env's ``gripper_joint_names`` rather than + assuming the last two joints are the gripper. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_joint_pos_env_cfg.py index 0bb1a635b92..7cc2ce66d2a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_joint_pos_env_cfg.py @@ -3,24 +3,20 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.assets import ArticulationCfg, RigidObjectCfg -from isaaclab.managers import EventTermCfg as EventTerm -from isaaclab.managers import SceneEntityCfg -from isaaclab.sensors import FrameTransformerCfg -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.assets import ISAAC_NUCLEUS_DIR +from isaaclab.assets import ArticulationCfg from isaaclab.utils.configclass import configclass from isaaclab_tasks.contrib.stack import mdp -from isaaclab_tasks.contrib.stack.mdp import franka_stack_events -from isaaclab_tasks.contrib.stack.stack_env_cfg import StackEnvCfg +from isaaclab_tasks.contrib.stack.stack_env_cfg import ( + StackEnvCfg, + StackEventCfg, + apply_default_semantics, + make_ee_frame_cfg, +) ## # Pre-defined configs ## -from isaaclab.markers.config import FRAME_MARKER_CFG # isort: skip from isaaclab_assets.robots.franka import FRANKA_PANDA_CFG # isort: skip # Default arm + gripper joint pose @@ -36,41 +32,22 @@ } -@configclass -class EventCfg: - """Configuration for events.""" - - randomize_franka_joint_state = EventTerm( - func=franka_stack_events.randomize_joint_by_gaussian_offset, - mode="reset", - params={ - "mean": 0.0, - "std": 0.02, - "asset_cfg": SceneEntityCfg("robot"), - }, - ) - - randomize_cube_positions = EventTerm( - func=franka_stack_events.randomize_object_pose, - mode="reset", - params={ - "pose_range": {"x": (0.4, 0.6), "y": (-0.10, 0.10), "z": (0.0203, 0.0203), "yaw": (-1.0, 1, 0)}, - "min_separation": 0.1, - "asset_cfgs": [SceneEntityCfg("cube_1"), SceneEntityCfg("cube_2"), SceneEntityCfg("cube_3")], - }, - ) - - @configclass class FrankaCubeStackEnvCfg(StackEnvCfg): - """Configuration for the Franka Cube Stack Environment.""" + """Configuration for the Franka Cube Stack Environment. + + Uses the robot-neutral stack scaffolding (cubes, semantics, ee-frame builder, reset events) + from :mod:`~isaaclab_tasks.contrib.stack.stack_env_cfg`; the default cube transforms and + workspace there match the Franka layout, so only the robot, actions/gripper, and end-effector + frame prim paths are set here. + """ def __post_init__(self): # post init of parent super().__post_init__() - # Set events - self.events = EventCfg() + # Set robot-neutral reset events (default cube workspace matches the Franka layout). + self.events = StackEventCfg() # Set Franka as robot self.scene.robot = FRANKA_PANDA_CFG.replace( @@ -78,14 +55,8 @@ def __post_init__(self): init_state=ArticulationCfg.InitialStateCfg(joint_pos=_FRANKA_STACK_IK_REL_INIT_JOINT_POS), ) - # Add semantics to table - self.scene.table.spawn.semantic_tags = [("class", "table")] - - # Add semantics to ground - self.scene.plane.semantic_tags = [("class", "ground")] - - # Add semantics to robot - self.scene.robot.spawn.semantic_tags = [("class", "robot")] + # Tag the table / ground / robot semantic classes. + apply_default_semantics(self.scene) # Set actions for the specific robot type (franka) self.actions.arm_action = mdp.JointPositionActionCfg( @@ -102,77 +73,13 @@ def __post_init__(self): self.gripper_open_val = 0.04 self.gripper_threshold = 0.005 - # Rigid body properties of each cube - cube_properties = RigidBodyPropertiesCfg( - solver_position_iteration_count=16, - solver_velocity_iteration_count=1, - max_angular_velocity=1000.0, - max_linear_velocity=1000.0, - max_depenetration_velocity=5.0, - disable_gravity=False, - ) - - # Set each stacking cube deterministically - self.scene.cube_1 = RigidObjectCfg( - prim_path="{ENV_REGEX_NS}/Cube_1", - init_state=RigidObjectCfg.InitialStateCfg(pos=[0.4, 0.0, 0.0203], rot=[0, 0, 0, 1]), - spawn=UsdFileCfg( - usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/blue_block.usd", - scale=(1.0, 1.0, 1.0), - rigid_props=cube_properties, - semantic_tags=[("class", "cube_1")], - ), - ) - self.scene.cube_2 = RigidObjectCfg( - prim_path="{ENV_REGEX_NS}/Cube_2", - init_state=RigidObjectCfg.InitialStateCfg(pos=[0.55, 0.05, 0.0203], rot=[0, 0, 0, 1]), - spawn=UsdFileCfg( - usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/red_block.usd", - scale=(1.0, 1.0, 1.0), - rigid_props=cube_properties, - semantic_tags=[("class", "cube_2")], - ), - ) - self.scene.cube_3 = RigidObjectCfg( - prim_path="{ENV_REGEX_NS}/Cube_3", - init_state=RigidObjectCfg.InitialStateCfg(pos=[0.60, -0.1, 0.0203], rot=[0, 0, 0, 1]), - spawn=UsdFileCfg( - usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/green_block.usd", - scale=(1.0, 1.0, 1.0), - rigid_props=cube_properties, - semantic_tags=[("class", "cube_3")], - ), - ) - - # Listens to the required transforms - marker_cfg = FRAME_MARKER_CFG.copy() - marker_cfg.markers["frame"].scale = (0.1, 0.1, 0.1) - marker_cfg.prim_path = "/Visuals/FrameTransformer" - self.scene.ee_frame = FrameTransformerCfg( - prim_path="{ENV_REGEX_NS}/Robot/panda_link0", - debug_vis=False, - visualizer_cfg=marker_cfg, - target_frames=[ - FrameTransformerCfg.FrameCfg( - prim_path="{ENV_REGEX_NS}/Robot/panda_hand", - name="end_effector", - offset=OffsetCfg( - pos=[0.0, 0.0, 0.1034], - ), - ), - FrameTransformerCfg.FrameCfg( - prim_path="{ENV_REGEX_NS}/Robot/panda_rightfinger", - name="tool_rightfinger", - offset=OffsetCfg( - pos=(0.0, 0.0, 0.046), - ), - ), - FrameTransformerCfg.FrameCfg( - prim_path="{ENV_REGEX_NS}/Robot/panda_leftfinger", - name="tool_leftfinger", - offset=OffsetCfg( - pos=(0.0, 0.0, 0.046), - ), - ), + # End-effector frame (the shared cubes/spawns come from the base scene). + self.scene.ee_frame = make_ee_frame_cfg( + base_prim_path="{ENV_REGEX_NS}/Robot/panda_link0", + target_specs=[ + ("{ENV_REGEX_NS}/Robot/panda_hand", "end_effector", (0.0, 0.0, 0.1034)), + ("{ENV_REGEX_NS}/Robot/panda_rightfinger", "tool_rightfinger", (0.0, 0.0, 0.046)), + ("{ENV_REGEX_NS}/Robot/panda_leftfinger", "tool_leftfinger", (0.0, 0.0, 0.046)), ], + marker_scale=(0.1, 0.1, 0.1), ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/__init__.py new file mode 100644 index 00000000000..98851798edd --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/__init__.py @@ -0,0 +1,35 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +import gymnasium as gym + +## +# Register Gym environments. +## + +## +# Joint Position Control +## + +gym.register( + id="IsaacContrib-Stack-Cube-SO101-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": f"{__name__}.stack_joint_pos_env_cfg:SO101CubeStackEnvCfg", + }, + disable_env_checker=True, +) + +## +# Inverse Kinematics - Absolute Pose Control +## + +gym.register( + id="IsaacContrib-Stack-Cube-SO101-IK-Abs-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": f"{__name__}.stack_ik_abs_env_cfg:SO101CubeStackEnvCfg", + }, + disable_env_checker=True, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/pose_ik_action.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/pose_ik_action.py new file mode 100644 index 00000000000..fdac999d348 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/pose_ik_action.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 + +"""Configuration for the SO-101 full-pose SE3 IK action term. + +This module is pure data: it must stay importable without Kit so ``load_cfg_from_registry`` can +construct the env cfg before deciding whether to launch the simulator (see +``test/core/test_env_cfg_no_forbidden_imports.py``). The live term lives in +:mod:`.pose_ik_action_term` because subclassing +:class:`~isaaclab.envs.mdp.actions.task_space_actions.DifferentialInverseKinematicsAction` forces an +eager ``from pxr import UsdPhysics`` import. We therefore reference the term lazily via a +``"{DIR}.pose_ik_action_term:SO101PoseIKAction"`` string ``class_type`` (resolved by +:meth:`cfg.validate` after Kit has launched), mirroring the base +:class:`~isaaclab.envs.mdp.actions.actions_cfg.DifferentialInverseKinematicsActionCfg`. + +.. note:: + :attr:`~isaaclab.envs.mdp.actions.actions_cfg.DifferentialInverseKinematicsActionCfg.clip` + is not supported for this subclass. The base clips by action-dim axis using joint-name + keys, but this term's action layout is the task-space pose ``[pos_xyz, quat_xyzw]``, which + does not map to joint names. Setting ``clip`` raises :exc:`NotImplementedError`. +""" + +from __future__ import annotations + +from dataclasses import MISSING +from typing import TYPE_CHECKING + +from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg +from isaaclab.utils.configclass import configclass + +from .pose_ik_controller import SO101PoseIKControllerCfg + +if TYPE_CHECKING: + from .pose_ik_action_term import SO101PoseIKAction + + +@configclass +class SO101PoseIKActionCfg(DifferentialInverseKinematicsActionCfg): + """Configuration for :class:`~.pose_ik_action_term.SO101PoseIKAction`.""" + + class_type: type[SO101PoseIKAction] | str = "{DIR}.pose_ik_action_term:SO101PoseIKAction" + controller: SO101PoseIKControllerCfg = MISSING diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/pose_ik_action_term.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/pose_ik_action_term.py new file mode 100644 index 00000000000..a7e7cecfb1d --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/pose_ik_action_term.py @@ -0,0 +1,69 @@ +# 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 + +"""Runtime action term for the SO-101 full-pose SE3 IK controller. + +This module hosts the live :class:`SO101PoseIKAction` term. It is intentionally **separate** +from :mod:`.pose_ik_action` (which holds the pure-data :class:`~.pose_ik_action.SO101PoseIKActionCfg`) +because subclassing :class:`~isaaclab.envs.mdp.actions.task_space_actions.DifferentialInverseKinematicsAction` +forces an eager ``from pxr import UsdPhysics`` at import time. The env cfg must be constructable +without Kit (see ``test/core/test_env_cfg_no_forbidden_imports.py``), so the cfg only references +this term lazily via a ``"{DIR}.pose_ik_action_term:SO101PoseIKAction"`` string ``class_type`` that +:meth:`cfg.validate` resolves after Kit has launched. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from isaaclab.envs.mdp.actions.task_space_actions import DifferentialInverseKinematicsAction + +from .pose_ik_controller import SO101PoseIKController + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedEnv + + from .pose_ik_action import SO101PoseIKActionCfg + + +class SO101PoseIKAction(DifferentialInverseKinematicsAction): + """IK action term that uses the SO-101 full-pose SE3 controller.""" + + cfg: SO101PoseIKActionCfg + + def __init__(self, cfg: SO101PoseIKActionCfg, env: ManagerBasedEnv): + super().__init__(cfg, env) + # Swap the base differential-IK controller for the SO-101 one. The two share the same 7D + # ``[pos, quat_xyzw]`` action layout, so the base-allocated buffers/scale stay valid; the + # SO-101 controller only adds the orientation joint mask on top of the core adaptive-DLS + + # orientation-weighting + JLA controller. Null-space joint-limit injection now comes from + # the base term (gated on ``joint_limit_avoidance_gain > 0``), so it is not repeated here. + self._ik_controller = SO101PoseIKController(cfg=self.cfg.controller, num_envs=self.num_envs, device=self.device) + # Restrict the orientation task to a subset of joints if configured (e.g. the SO-101 wrist), + # so the other joints (shoulder_pan, ...) serve position only and the base does not swing to + # track orientation. Resolve the names against ``self._joint_names`` (asset-ordered, matching + # the Jacobian columns the controller receives) so the pushed mask is order-proof. + ori_names = self.cfg.controller.orientation_joint_names + if ori_names is not None: + missing = set(ori_names) - set(self._joint_names) + if missing: + raise ValueError( + f"orientation_joint_names {sorted(missing)} are not among this action's joints {self._joint_names}." + ) + ori_mask = torch.tensor( + [1.0 if name in ori_names else 0.0 for name in self._joint_names], device=self.device + ) + self._ik_controller.set_orientation_joint_mask(ori_mask) + # Action clipping is not supported for this term: the base clips by action-dim axis using + # joint-name keys, but this term's action layout is the task-space pose + # [pos_xyz, quat_xyzw], which does not map to joint names. Refuse rather than silently + # clip the wrong axis. + if self.cfg.clip is not None: + raise NotImplementedError( + "clip is not supported for SO101PoseIKAction (task-space [pos, quat] action " + "does not map to joint-name clip keys)." + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/pose_ik_controller.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/pose_ik_controller.py new file mode 100644 index 00000000000..5491a5e129b --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/pose_ik_controller.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 + +"""Wrist-only-orientation differential IK for the SO-101. + +The SO-101 is a 5-DOF arm, so a full 6-DOF pose target is over-determined by one DOF. This is +handled with features that now live in the core :class:`~isaaclab.controllers.DifferentialIKController`: + +* ``ik_method="adaptive_dls"`` -- manipulability-aware damped least squares. +* ``orientation_weight`` -- per-axis soft weighting of the orientation task rows. +* ``joint_limit_avoidance_gain`` / ``joint_limit_avoidance_margin`` -- null-space joint-limit avoidance. + +The only SO-101-specific behavior left here is the **orientation joint mask**: restricting which +joints serve the orientation rows so the base (``shoulder_pan``) stays position-only and the wrist +serves orientation. See :attr:`SO101PoseIKControllerCfg.orientation_joint_names`. + +.. note:: + The quaternion convention is **xyzw** (scalar-last), matching the IsaacLab asset convention and + :func:`isaaclab.utils.math.compute_pose_error`. The core controller renormalizes the commanded + quaternion in :meth:`~isaaclab.controllers.DifferentialIKController.set_command`. +""" + +from __future__ import annotations + +import torch + +from isaaclab.controllers.differential_ik import DifferentialIKController +from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg +from isaaclab.utils.configclass import configclass + + +@configclass +class SO101PoseIKControllerCfg(DifferentialIKControllerCfg): + """Core differential-IK config plus the SO-101 wrist-only orientation joint mask. + + Adaptive-DLS damping (``ik_method="adaptive_dls"`` with ``ik_params`` ``lambda_min`` / + ``lambda_max`` / ``sigma_thresh``), per-axis :attr:`~DifferentialIKControllerCfg.orientation_weight`, + and null-space joint-limit avoidance (:attr:`~DifferentialIKControllerCfg.joint_limit_avoidance_gain` / + :attr:`~DifferentialIKControllerCfg.joint_limit_avoidance_margin`) are inherited from the core config. + """ + + orientation_joint_names: tuple[str, ...] | None = None + """Names of the joints permitted to serve the orientation task rows. When set, every other + joint's orientation-Jacobian columns are zeroed, so those joints serve **position only** while + orientation is solved purely by the listed joints (position still uses all joints). ``None`` + (default) lets all joints serve orientation. + + For the SO-101's down-pointing gripper this is set to the wrist joints + ``("wrist_flex", "wrist_roll")``: ``wrist_roll`` takes the gripper spin about the (vertical) + approach axis -- the DOF otherwise redundant with ``shoulder_pan`` -- and ``wrist_flex`` takes + the tilt, leaving ``shoulder_pan`` free to serve position (heading) so the base never swings to + satisfy a commanded orientation. The action term resolves these names to Jacobian columns + (asset-ordered, so the mask is order-proof) and pushes the column mask via + :meth:`SO101PoseIKController.set_orientation_joint_mask`.""" + + +class SO101PoseIKController(DifferentialIKController): + """Core differential-IK controller plus an optional wrist-only orientation joint mask. + + All of the heavy lifting -- adaptive-DLS damping, per-axis orientation weighting, and + null-space joint-limit avoidance -- is inherited from + :class:`~isaaclab.controllers.DifferentialIKController`. This subclass only adds the orientation + joint mask: zeroing the orientation-Jacobian columns of the masked-out joints so they serve + position only (see :attr:`SO101PoseIKControllerCfg.orientation_joint_names`). + """ + + cfg: SO101PoseIKControllerCfg + + def __init__(self, cfg: SO101PoseIKControllerCfg, num_envs: int, device: str): + super().__init__(cfg, num_envs, device) + # Column mask (1 = joint may serve the orientation rows) over the IK joints, pushed by the + # action term once it has resolved ``orientation_joint_names`` to Jacobian columns. ``None`` + # leaves all joints free to serve orientation (the default). + self._ori_joint_mask: torch.Tensor | None = None + + def set_orientation_joint_mask(self, mask: torch.Tensor) -> None: + """Restrict which joints serve the orientation task rows. + + Args: + mask: Per-IK-joint multiplier [dimensionless], shape (num_joints,), in the controller's + joint (Jacobian-column) order: 1 for joints allowed to serve orientation, 0 for + joints that should serve position only. See + :attr:`SO101PoseIKControllerCfg.orientation_joint_names`. + """ + self._ori_joint_mask = mask.to(self._device) + + def _compute_pose_task( + self, ee_pos: torch.Tensor, ee_quat: torch.Tensor, jacobian: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor]: + """Build the core (orientation-weighted) pose task, then mask the orientation columns. + + Zeroing the orientation rows of the masked-out joints means only the allowed joints can + reduce the orientation error, so the base (``shoulder_pan``) serves position only and the + redundant spin-about-vertical DOF is routed to ``wrist_roll``. Position rows are untouched. + """ + task_jacobian, task_error = super()._compute_pose_task(ee_pos, ee_quat, jacobian) + if self._ori_joint_mask is not None: + task_jacobian = task_jacobian.clone() + task_jacobian[:, 3:6, :] = task_jacobian[:, 3:6, :] * self._ori_joint_mask.view(1, 1, -1) + return task_jacobian, task_error diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_ik_abs_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_ik_abs_env_cfg.py new file mode 100644 index 00000000000..39f9f01f370 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_ik_abs_env_cfg.py @@ -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 + +from dataclasses import MISSING + +import numpy as np +from isaaclab_teleop import IsaacTeleopCfg + +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.utils.configclass import configclass + +from isaaclab_tasks.contrib.stack import mdp + +from . import stack_joint_pos_env_cfg +from .pose_ik_action import SO101PoseIKActionCfg +from .pose_ik_controller import SO101PoseIKControllerCfg +from .stack_joint_pos_env_cfg import SO101_GRIPPER_CLOSE, SO101_GRIPPER_OPEN + +## +# Pre-defined configs +## +from isaaclab_assets.robots.so101 import SO101_HIGH_PD_CFG # isort: skip + +# ``isaaclab_teleop`` imports cleanly without the optional ``isaacteleop`` package (the heavy +# import is deferred to session start), so no import guard is needed here. This lightweight +# marker lets the env test suite categorize this as a teleop env (see +# ``isaaclab_tasks/test/env_test_utils.py::_is_teleop_env``). +_TELEOP_AVAILABLE = True + +# Analog-gripper action affine: ``joint = offset + scale * closedness`` maps the retargeter's +# closedness ``c in [0, 1]`` to a ``gripper`` joint target [rad]. ``offset == OPEN`` (so ``c=0`` +# is fully open) and ``offset + scale == CLOSE`` (so ``c=1`` is fully closed). Tied to the +# single source of truth in ``stack_joint_pos_env_cfg`` so the endpoints cannot drift. +_SO101_GRIPPER_OFFSET = SO101_GRIPPER_OPEN # [rad] closedness c=0 -> open +_SO101_GRIPPER_SCALE = SO101_GRIPPER_CLOSE - SO101_GRIPPER_OPEN # [rad] c=1 -> close + +# Clutch EE reset-origin home: the ``base_T_ee`` transform [m] for the gripper's pose in the +# robot base frame at the seated reset pose. The clutch seeds its home from this on reset / first +# engage (no live end-effector is read; the base rebase is handled upstream by +# ``target_frame_prim_path`` and the EE state forward is the clutch's own running home). The robot +# ``init_state`` MUST forward-kinematics to this value, or engaging snaps the arm -- this is the +# forward kinematics of the seated ``_SO101_STACK_INIT_JOINT_POS`` measured in sim, so re-measure +# (read ``ee_frame.data.target_pos_source`` at reset) if those init joints change. Only the +# translation drives the IK position command; the orientation command is the live controller grip +# orientation composed with the clutch's fixed calibration offset, not this transform's rotation. +_BASE_T_GRIPPER_HOME = np.eye(4, dtype=np.float32) +_BASE_T_GRIPPER_HOME[:3, 3] = (0.01918, -0.18852, 0.18887) + +# Fixed controller-grip -> gripper_link orientation calibration offset [x, y, z, w] (xyzw), +# passed to the clutch as a body-frame right multiply. ``None`` uses the clutch default (Rz(pi)). +# Derivation: with ``q_grip`` the controller grip orientation and ``q_G0`` the gripper_link +# orientation in the base frame (both xyzw) at the reset pose, the offset is +# ``quat_inv(q_grip) (x) q_G0`` (xyzw); tuning it here needs no Teleop rebuild. +# Set to RPY (roll, pitch, yaw) = (-90, 0, 60) degrees (intrinsic XYZ), i.e. +# ``Rotation.from_euler("XYZ", [-90, 0, 60], degrees=True).as_quat()``. +_SO101_ORIENTATION_OFFSET_XYZW: tuple[float, float, float, float] | None = ( + -0.61237244, + 0.35355339, + 0.35355339, + 0.61237244, +) + + +def _build_so101_stack_pipeline(): + """Build an IsaacTeleop retargeting pipeline for SO-101 cube stacking. + + Creates a SO101ClutchRetargeter for right-hand clutch-rebased full-pose tracking and a + SO101GripperRetargeter for right-hand analog gripper control, flattened into a single action + tensor via TensorReorderer. + + The SO-101 is 5-DOF and driven by a full-SE3-pose IK over all 5 arm joints (shoulder_pan, + shoulder_lift, elbow_flex, wrist_flex, wrist_roll): the clutch emits the controller pose + (position + grip orientation composed with a fixed calibration offset) and the IK tracks + position exactly while best-effort tracking orientation (soft-weighted). The gripper jaw + tracks the trigger proportionally (analog), and the EE position is clutch-rebased around a + captured origin so engaging teleop does not teleport the arm. + + Returns: + OutputCombiner with a single "action" output containing the flattened + 8D action tensor: [pos_x, pos_y, pos_z, quat_x, quat_y, quat_z, quat_w, gripper]. + """ + from isaacteleop.retargeters import ( + SO101ClutchRetargeter, + SO101GripperRetargeter, + TensorReorderer, + ) + from isaacteleop.retargeters.SO101.gripper_retargeter import GRIPPER_COMMAND_KEY, GRIPPER_ELEMENT_LABEL + from isaacteleop.retargeting_engine.deviceio_source_nodes import ControllersSource + from isaacteleop.retargeting_engine.interface import OutputCombiner, ValueInput + from isaacteleop.retargeting_engine.tensor_types import TransformMatrix + + # Create input sources (trackers are auto-discovered from pipeline) + controllers = ControllersSource(name="controllers") + + # External input: the anchor transform 4x4 provided by IsaacTeleopDevice. With + # ``target_frame_prim_path`` set to the robot base (see the cfg below) the device feeds + # ``base_T_world @ world_T_anchor``, so the transformed controller stream below arrives + # already in the robot base frame -- the IK command's root frame. + transform_input = ValueInput("world_T_anchor", TransformMatrix()) + + # Rebase controller poses into the robot base frame so the downstream clutch retargeter + # receives base-frame data matching the absolute-pose IK command frame. + transformed_controllers = controllers.transformed(transform_input.output(ValueInput.VALUE)) + + # Clutch (relative-origin) EE-pose retargeter (right hand). Emits the same absolute 7D + # "ee_pose" contract as Se3AbsRetargeter (node name + output key "ee_pose"), but rebases + # controller motion around an origin captured on engage. It seeds the home from the static + # ``home_base_T_ee`` reset-origin on reset / first engage and keeps its own running home + # thereafter (so a mid-task re-clutch resumes from the last commanded pose). The robot + # ``init_state`` must reset the arm to ``_BASE_T_GRIPPER_HOME`` so the first engage does not + # jump. The full pose (position + orientation) drives the 5-joint SE3 IK below. + clutch = SO101ClutchRetargeter( + name="ee_pose", + input_device=ControllersSource.RIGHT, + home_base_T_ee=_BASE_T_GRIPPER_HOME, + orientation_offset=( + np.array(_SO101_ORIENTATION_OFFSET_XYZW, dtype=np.float64) + if _SO101_ORIENTATION_OFFSET_XYZW is not None + else None + ), + ) + connected_clutch = clutch.connect( + { + ControllersSource.RIGHT: transformed_controllers.output(ControllersSource.RIGHT), + } + ) + + # Analog Gripper Retargeter (right hand). Emits a proportional jaw closedness in [0, 1] + # from the controller trigger; the node name "gripper" matches its gripper_command/ + # gripper_value channel and the gripper_action term. + gripper = SO101GripperRetargeter(name="gripper", input_device=ControllersSource.RIGHT) + connected_gripper = gripper.connect( + { + ControllersSource.RIGHT: transformed_controllers.output(ControllersSource.RIGHT), + } + ) + + # TensorReorderer to flatten into a single action vector + # SO101ClutchRetargeter outputs a 7D NDArray (pos xyz + quat xyzw) + # SO101GripperRetargeter outputs a single float (gripper closedness) + ee_pose_elements = ["pos_x", "pos_y", "pos_z", "quat_x", "quat_y", "quat_z", "quat_w"] + gripper_elements = [GRIPPER_ELEMENT_LABEL] + + reorderer = TensorReorderer( + input_config={ + "ee_pose": ee_pose_elements, + GRIPPER_COMMAND_KEY: gripper_elements, + }, + # Full-pose SE3 IK: pass the whole 7D pose (xyz + quat xyzw) through, then the gripper. + # Output order: [pos_xyz, quat_xyzw, gripper] — the positional contract with the + # SO101PoseIKAction (7D arm: pos + quat) + gripper. + output_order=ee_pose_elements + gripper_elements, + name="action_reorderer", + input_types={ + "ee_pose": "array", + GRIPPER_COMMAND_KEY: "scalar", + }, + ) + connected_reorderer = reorderer.connect( + { + "ee_pose": connected_clutch.output("ee_pose"), + GRIPPER_COMMAND_KEY: connected_gripper.output(GRIPPER_COMMAND_KEY), + } + ) + + return OutputCombiner({"action": connected_reorderer.output("output")}) + + +@configclass +class SO101IkActionsCfg: + """Action terms for SO-101 IK-Abs teleop, ordered to match the pipeline ``output_order``. + + This is a fresh (non-inheriting) action container declared in field order + ``[arm_action, gripper_action]`` so the flattened action concatenates as + ``[pos_x, pos_y, pos_z, quat_x, quat_y, quat_z, quat_w, gripper]`` — the positional contract + with the teleop pipeline's ``output_order`` in :func:`_build_so101_stack_pipeline`. The arm + action covers 7 dimensions (pos xyz + quat xyzw); gripper covers 1. Inheriting from the base + ``ActionsCfg`` would append new fields after the base's and mis-order the concat. + """ + + arm_action: SO101PoseIKActionCfg = MISSING + gripper_action: mdp.JointPositionActionCfg = MISSING + + +@configclass +class SO101CubeStackEnvCfg(stack_joint_pos_env_cfg.SO101CubeStackEnvCfg): + """SO-101 cube-stack environment with absolute task-space (IK) control. + + The SO-101 has only 5 actuated arm DOF, so a full 6-DOF pose target is over-determined by + one DOF. We command an absolute end-effector **SE3 pose** via a full-pose differential IK + over all 5 arm joints (``shoulder_pan``, ``shoulder_lift``, ``elbow_flex``, ``wrist_flex``, + ``wrist_roll``): the 3 linear task rows track position exactly (weight 1) and the + orientation rows are soft-weighted (``orientation_weight``) so orientation is + best-effort and never leaks error into position. Orientation is further restricted to the wrist + (``orientation_joint_names=("wrist_flex", "wrist_roll")``): ``wrist_roll`` takes the gripper + spin about the (vertical) approach axis and ``wrist_flex`` the tilt, while ``shoulder_pan`` + serves position only -- so the base never swings to satisfy a commanded orientation. The + resulting action is + ``[pos_x, pos_y, pos_z, quat_x, quat_y, quat_z, quat_w, gripper]`` (orientation xyzw). The IK + is kept well-conditioned near singularities via the soft orientation weight, + manipulability-aware damped least squares (adaptive lambda keyed off the full weighted task + Jacobian, so it is coupled to the orientation weight), and null-space joint-limit avoidance. + """ + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Switch to a stiffer PD controller so the arm tracks IK targets well. SO101_HIGH_PD_CFG + # carries only the so101.py asset default init_state (zeros, no root pos), so ``replace`` + # would DISCARD the parent's seated + stack-posed init_state. Preserve it explicitly so + # the IK-Abs task is also seated on the table and starts in the stack joint pose. + # + # Also soften the gripper to mimic the Franka panda_hand's grasp feel. The SO-101 jaw asset + # default (effort 10 N·m, velocity 10 rad/s) snaps shut fast and hard, pushing through the + # cube and ejecting it. The Franka panda_hand (effort 200 N, stiffness 2e3) is a *prismatic* + # finger pair, so its gains do not transfer to this *revolute* jaw [N·m, rad]; instead we + # behavior-match by capping the closing speed and grip torque so it closes gently and holds + # without penetrating. ``effort_limit_sim`` is the "strength" knob and ``velocity_limit_sim`` + # the "speed" knob; stiffness/damping keep the asset defaults. Tune in-sim (lower effort if + # it still pushes through; raise it if the cube drops). + self.scene.robot = SO101_HIGH_PD_CFG.replace( + prim_path="{ENV_REGEX_NS}/Robot", + init_state=self.scene.robot.init_state, + actuators={ + "arm": SO101_HIGH_PD_CFG.actuators["arm"], + "gripper": ImplicitActuatorCfg( + joint_names_expr=["gripper"], + effort_limit_sim=1.0, # was 10.0 -- cap grip torque so it can't push through + velocity_limit_sim=2.0, # was 10.0 -- close gently instead of snapping shut + stiffness=17.8, + damping=0.60, + ), + }, + ) + + # Replace the actions container. The 2-field order here is the positional contract + # with the pipeline ``output_order`` ([arm pos+quat, gripper]); a true action_dim check + # needs the loaded articulation, so the sim-free test is the real guard. + self.actions = SO101IkActionsCfg( + # Full-pose SE3 IK over all 5 arm joints (including wrist_roll). Solves a 6-row + # pose task [pos_xyz, ori_xyz]; the 3 orientation rows are soft-weighted. + arm_action=SO101PoseIKActionCfg( + asset_name="robot", + joint_names=["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll"], + body_name="gripper", + scale=1.0, + controller=SO101PoseIKControllerCfg( + command_type="pose", + use_relative_mode=False, + # Manipulability-aware damped least squares keeps the 5-DOF arm well-conditioned + # near singularities (adaptive lambda keyed off the smallest task-Jacobian + # singular value). + ik_method="adaptive_dls", + ik_params={"lambda_min": 0.05, "lambda_max": 0.2, "sigma_thresh": 0.02}, + # Track all 3 orientation axes (including spin about the vertical approach + # axis), but restrict orientation to the wrist via ``orientation_joint_names`` + # below so the base never serves it: ``wrist_roll`` takes the spin (controller + # twist about vertical / stage Z) and ``wrist_flex`` the tilt, while + # ``shoulder_pan`` stays position-only (heading to the target). + orientation_weight=0.5, + # Orientation is a wrist-only task (see above). Position still uses all 5 joints. + orientation_joint_names=("wrist_flex", "wrist_roll"), + # Null-space joint-limit avoidance (0 disables it). + joint_limit_avoidance_gain=0.5, + joint_limit_avoidance_margin=0.3, + ), + ), + # Analog gripper: continuous JointPositionActionCfg mapping the retargeter's + # closedness c in [0, 1] to a ``gripper`` joint target [rad] via the affine + # ``joint = offset + scale * c`` (offset == OPEN, offset + scale == CLOSE). This + # replaces the joint-pos task's BinaryJointPositionActionCfg with proportional + # control. ``use_default_offset=False`` so the offset is the absolute open angle. + gripper_action=mdp.JointPositionActionCfg( + asset_name="robot", + joint_names=["gripper"], + scale=_SO101_GRIPPER_SCALE, + offset=_SO101_GRIPPER_OFFSET, + use_default_offset=False, + ), + ) + + # IsaacTeleop-based teleoperation pipeline + self.isaac_teleop = IsaacTeleopCfg( + pipeline_builder=_build_so101_stack_pipeline, + sim_device=self.sim.device, + xr_cfg=self.xr, + # Rebase all teleop output poses into the robot base frame: the device reads this + # prim's world transform each frame and left-multiplies its inverse onto the XR + # anchor (``base_T_world @ world_T_anchor``), so the clutch retargeter works + # in the base frame -- the IK command's root frame (the FrameTransformer source + # also uses ``Robot/base``). The clutch's reset-origin home (_BASE_T_GRIPPER_HOME) + # provides the engage seed, so no live end-effector feed is needed. Teleop runs a + # single env (env_0). + target_frame_prim_path="/World/envs/env_0/Robot/base", + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_joint_pos_env_cfg.py new file mode 100644 index 00000000000..0190ef1c0ae --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_joint_pos_env_cfg.py @@ -0,0 +1,129 @@ +# 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.assets import ArticulationCfg, RigidObjectCfg +from isaaclab.utils.configclass import configclass + +from isaaclab_tasks.contrib.stack import mdp +from isaaclab_tasks.contrib.stack.stack_env_cfg import ( + StackEnvCfg, + StackEventCfg, + apply_default_semantics, + make_ee_frame_cfg, +) + +## +# Pre-defined configs +## +from isaaclab_assets.robots.so101 import SO101_CFG # isort: skip + +# Default arm + gripper joint pose [rad]. +# NOTE: kept mid-range (elbow/wrist bent) to avoid the boundary singularity of a fully +# extended 5-DOF arm. Tune in-sim so the gripper starts above the cube workspace pointing +# down for top-down grasps. +_SO101_STACK_INIT_JOINT_POS: dict[str, float] = { + "shoulder_pan": 0.0, + "shoulder_lift": -0.6, + "elbow_flex": 0.8, + "wrist_flex": 0.6, + "wrist_roll": 0.0, + "gripper": 0.0, +} + +# Gripper jaw extremes [rad]. The SO-101 jaw is a single revolute joint; USD limits are +# roughly [-10 deg, 100 deg]. ``open`` ~= jaw fully open, ``close`` ~= jaw shut. +# Confirm sign/magnitude against the spawned articulation. +SO101_GRIPPER_OPEN = 1.745 +SO101_GRIPPER_CLOSE = 0.0 + +# Base seat pose authoring the SO-101 root transform explicitly (it replaces the asset's baked-in +# default xform, not stacks on it). Load-bearing constraints: +# - ``_SO101_MOUNT_Z = -0.03008`` [m]: downward base correction that plants the column foot on the +# tabletop (table-top surface is at world z~=0). Adjust only the root z; do NOT x-shift the base +# (the cubes live at world x in [0.18, 0.30] and an x-shift would break reachability). +# - ``_SO101_BASE_SEAT_ROT`` [quat (x, y, z, w)]: 90 deg about +Z, matching the table's +# ``rot=[0, 0, 0.707, 0.707]`` so the arm faces the cube workspace. +_SO101_MOUNT_Z = -0.03008 +_SO101_BASE_SEAT_POS = (0.0, 0.0, _SO101_MOUNT_Z) +_SO101_BASE_SEAT_ROT = (0.0, 0.0, 0.70710678, 0.70710678) + + +@configclass +class SO101CubeStackEnvCfg(StackEnvCfg): + """Configuration for the SO-101 Cube Stack Environment (joint-position control). + + Reuses the robot-neutral stack scaffolding (cubes, semantics, ee-frame builder, reset events) + from :mod:`~isaaclab_tasks.contrib.stack.stack_env_cfg` and overrides only the SO-101-specific + bits: the seated robot, its actions/gripper, the cube transforms (shrunk to the arm's reach), + and the end-effector frame prim paths. + """ + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Robot-neutral reset events, with the cube workspace shrunk to the SO-101's ~0.3 m reach + # (Franka uses x in [0.4, 0.6]). The joint-randomization term holds the ``gripper`` joint + # via ``gripper_joint_names`` below (so ``wrist_roll`` is still randomized). + self.events = StackEventCfg() + self.events.randomize_cube_positions.params["pose_range"] = { + "x": (0.15, 0.30), + "y": (-0.10, 0.10), + "z": (0.0203, 0.0203), + "yaw": (-1.0, 1.0), + } + self.events.randomize_cube_positions.params["min_separation"] = 0.06 + + # Set SO-101 as robot. Seat the base on the table-top and face it toward the cube + # workspace (see ``_SO101_MOUNT_Z`` / ``_SO101_BASE_SEAT_ROT``). + self.scene.robot = SO101_CFG.replace( + prim_path="{ENV_REGEX_NS}/Robot", + init_state=ArticulationCfg.InitialStateCfg( + pos=_SO101_BASE_SEAT_POS, + rot=_SO101_BASE_SEAT_ROT, + joint_pos=_SO101_STACK_INIT_JOINT_POS, + ), + ) + + # Tag the table / ground / robot semantic classes. + apply_default_semantics(self.scene) + + # Set actions for the specific robot type (so101) + self.actions.arm_action = mdp.JointPositionActionCfg( + asset_name="robot", + joint_names=["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll"], + scale=0.5, + use_default_offset=True, + ) + self.actions.gripper_action = mdp.BinaryJointPositionActionCfg( + asset_name="robot", + joint_names=["gripper"], + open_command_expr={"gripper": SO101_GRIPPER_OPEN}, + close_command_expr={"gripper": SO101_GRIPPER_CLOSE}, + ) + # utilities for gripper status check + self.gripper_joint_names = ["gripper"] + self.gripper_open_val = SO101_GRIPPER_OPEN + self.gripper_threshold = 0.2 + + # Place the shared cubes within the SO-101 workspace (override transforms only; the cube + # spawn/properties/semantics come from the base scene). + self.scene.cube_1.init_state = RigidObjectCfg.InitialStateCfg(pos=[0.18, 0.0, 0.0203], rot=[0, 0, 0, 1]) + self.scene.cube_2.init_state = RigidObjectCfg.InitialStateCfg(pos=[0.24, 0.06, 0.0203], rot=[0, 0, 0, 1]) + self.scene.cube_3.init_state = RigidObjectCfg.InitialStateCfg(pos=[0.27, -0.07, 0.0203], rot=[0, 0, 0, 1]) + + # End-effector frame. SO-101 link chain (verified against the so101_new_calib USD): + # base -> shoulder -> upper_arm -> lower_arm -> wrist -> gripper -> moving_jaw_so101_v1. + # ``gripper`` is the static wrist/gripper body (the IK target); ``moving_jaw_so101_v1`` is + # the actuated jaw. The ``end_effector`` offset should be set to the actual grasp point + # (between the jaws); it is left at the gripper-link origin for now. + self.scene.ee_frame = make_ee_frame_cfg( + base_prim_path="{ENV_REGEX_NS}/Robot/base", + target_specs=[ + ("{ENV_REGEX_NS}/Robot/gripper", "end_effector", (0.0, 0.0, 0.0)), + ("{ENV_REGEX_NS}/Robot/moving_jaw_so101_v1", "tool_jaw", (0.0, 0.0, 0.0)), + ], + marker_scale=(0.05, 0.05, 0.05), + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/franka_stack_events.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/franka_stack_events.py index 4d148d6ba21..48b0c86128b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/franka_stack_events.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/franka_stack_events.py @@ -3,336 +3,32 @@ # # SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -import math -import random -from typing import TYPE_CHECKING - -import torch -import warp as wp - -import isaaclab.utils.math as math_utils -from isaaclab.managers import SceneEntityCfg - -if TYPE_CHECKING: - from isaaclab.assets import Articulation, AssetBase - from isaaclab.envs import ManagerBasedEnv - - -def set_default_joint_pose( - env: ManagerBasedEnv, - env_ids: torch.Tensor, - default_pose: torch.Tensor, - asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), -): - # Set the default pose for robots in all envs - asset = env.scene[asset_cfg.name] - # Convert default_pose to 1D array and create joint indices - default_pose_1d = torch.tensor(default_pose, device=env.device).repeat(env.num_envs, 1).flatten() - num_joints = len(default_pose) - joint_ids = torch.arange(num_joints, device=env.device, dtype=torch.int32) - # Use update_default_joint_values kernel to update all joints for all environments - from isaaclab_physx.assets.articulation.kernels import update_default_joint_values - - wp.launch( - update_default_joint_values, - dim=(env.num_envs, num_joints), - inputs=[ - default_pose_1d, - joint_ids, - ], - outputs=[asset.data.default_joint_pos], - device=env.device, - ) - - -def randomize_joint_by_gaussian_offset( - env: ManagerBasedEnv, - env_ids: torch.Tensor, - mean: float, - std: float, - asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), -): - asset: Articulation = env.scene[asset_cfg.name] - - # Add gaussian noise to joint states - joint_pos = asset.data.default_joint_pos.torch[env_ids].clone() - joint_vel = asset.data.default_joint_vel.torch[env_ids].clone() - joint_pos += math_utils.sample_gaussian(mean, std, joint_pos.shape, joint_pos.device) - - # Clamp joint pos to limits - joint_pos_limits = asset.data.soft_joint_pos_limits.torch[env_ids] - joint_pos = joint_pos.clamp_(joint_pos_limits[..., 0], joint_pos_limits[..., 1]) - - # Don't noise the gripper poses - joint_pos[:, -2:] = asset.data.default_joint_pos.torch[env_ids, -2:] - - # Set into the physics simulation - asset.set_joint_position_target_index(target=joint_pos, env_ids=env_ids) - asset.set_joint_velocity_target_index(target=joint_vel, env_ids=env_ids) - asset.write_joint_position_to_sim_index(position=joint_pos, env_ids=env_ids) - asset.write_joint_velocity_to_sim_index(velocity=joint_vel, env_ids=env_ids) - - -def sample_random_color(base=(0.75, 0.75, 0.75), variation=0.1): - """ - Generates a randomized color that stays close to the base color while preserving overall brightness. - The relative balance between the R, G, and B components is maintained by ensuring that - the sum of random offsets is zero. - - Parameters: - base (tuple): The base RGB color with each component between 0 and 1. - variation (float): Maximum deviation to sample for each channel before balancing. - - Returns: - tuple: A new RGB color with balanced random variation. - """ - # Generate random offsets for each channel in the range [-variation, variation] - offsets = [random.uniform(-variation, variation) for _ in range(3)] - # Compute the average offset - avg_offset = sum(offsets) / 3 - # Adjust offsets so their sum is zero (maintaining brightness) - balanced_offsets = [offset - avg_offset for offset in offsets] - - # Apply the balanced offsets to the base color and clamp each channel between 0 and 1 - new_color = tuple(max(0, min(1, base_component + offset)) for base_component, offset in zip(base, balanced_offsets)) - - return new_color - - -def randomize_scene_lighting_domelight( - env: ManagerBasedEnv, - env_ids: torch.Tensor, - intensity_range: tuple[float, float], - color_variation: float, - textures: list[str], - default_intensity: float = 3000.0, - default_color: tuple[float, float, float] = (0.75, 0.75, 0.75), - default_texture: str = "", - asset_cfg: SceneEntityCfg = SceneEntityCfg("light"), -): - asset: AssetBase = env.scene[asset_cfg.name] - light_prim = asset.prims[0] - - intensity_attr = light_prim.GetAttribute("inputs:intensity") - intensity_attr.Set(default_intensity) - - color_attr = light_prim.GetAttribute("inputs:color") - color_attr.Set(default_color) - - texture_file_attr = light_prim.GetAttribute("inputs:texture:file") - texture_file_attr.Set(default_texture) - - if not hasattr(env.cfg, "eval_mode") or not env.cfg.eval_mode: - return - - if env.cfg.eval_type in ["light_intensity", "all"]: - # Sample new light intensity - new_intensity = random.uniform(intensity_range[0], intensity_range[1]) - # Set light intensity to light prim - intensity_attr.Set(new_intensity) - - if env.cfg.eval_type in ["light_color", "all"]: - # Sample new light color - new_color = sample_random_color(base=default_color, variation=color_variation) - # Set light color to light prim - color_attr.Set(new_color) - - if env.cfg.eval_type in ["light_texture", "all"]: - # Sample new light texture (background) - new_texture = random.sample(textures, 1)[0] - # Set light texture to light prim - texture_file_attr.Set(new_texture) - - -def sample_object_poses( - num_objects: int, - min_separation: float = 0.0, - pose_range: dict[str, tuple[float, float]] = {}, - max_sample_tries: int = 5000, -): - range_list = [pose_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z", "roll", "pitch", "yaw"]] - pose_list = [] - - for i in range(num_objects): - for j in range(max_sample_tries): - sample = [random.uniform(range[0], range[1]) for range in range_list] - - # Accept pose if it is the first one, or if reached max num tries - if len(pose_list) == 0 or j == max_sample_tries - 1: - pose_list.append(sample) - break - - # Check if pose of object is sufficiently far away from all other objects - separation_check = [math.dist(sample[:3], pose[:3]) > min_separation for pose in pose_list] - if False not in separation_check: - pose_list.append(sample) - break - - return pose_list - - -def randomize_object_pose( - env: ManagerBasedEnv, - env_ids: torch.Tensor, - asset_cfgs: list[SceneEntityCfg], - min_separation: float = 0.0, - pose_range: dict[str, tuple[float, float]] = {}, - max_sample_tries: int = 5000, -): - if env_ids is None: - return - - # Randomize poses in each environment independently - for cur_env in env_ids.tolist(): - pose_list = sample_object_poses( - num_objects=len(asset_cfgs), - min_separation=min_separation, - pose_range=pose_range, - max_sample_tries=max_sample_tries, - ) - - # Randomize pose for each object - for i in range(len(asset_cfgs)): - asset_cfg = asset_cfgs[i] - asset = env.scene[asset_cfg.name] - - # Write pose to simulation - pose_tensor = torch.tensor([pose_list[i]], device=env.device) - positions = pose_tensor[:, 0:3] + env.scene.env_origins[cur_env, 0:3] - orientations = math_utils.quat_from_euler_xyz(pose_tensor[:, 3], pose_tensor[:, 4], pose_tensor[:, 5]) - asset.write_root_pose_to_sim_index( - root_pose=torch.cat([positions, orientations], dim=-1), - env_ids=torch.tensor([cur_env], device=env.device), - ) - asset.write_root_velocity_to_sim_index( - root_velocity=torch.zeros(1, 6, device=env.device), env_ids=torch.tensor([cur_env], device=env.device) - ) - - -def randomize_rigid_objects_in_focus( - env: ManagerBasedEnv, - env_ids: torch.Tensor, - asset_cfgs: list[SceneEntityCfg], - out_focus_state: torch.Tensor, - min_separation: float = 0.0, - pose_range: dict[str, tuple[float, float]] = {}, - max_sample_tries: int = 5000, -): - if env_ids is None: - return - - # List of rigid objects in focus for each env (dim = [num_envs, num_rigid_objects]) - env.rigid_objects_in_focus = [] - - for cur_env in env_ids.tolist(): - # Sample in focus object poses - pose_list = sample_object_poses( - num_objects=len(asset_cfgs), - min_separation=min_separation, - pose_range=pose_range, - max_sample_tries=max_sample_tries, - ) - - selected_ids = [] - for asset_idx in range(len(asset_cfgs)): - asset_cfg = asset_cfgs[asset_idx] - asset = env.scene[asset_cfg.name] - - # Randomly select an object to bring into focus - object_id = random.randint(0, asset.num_bodies - 1) - selected_ids.append(object_id) - - # Create object state tensor with shape (num_envs, num_objects, state_dim) - # Since we're updating one environment, we need shape (1, num_objects, state_dim) - object_states = torch.stack([out_focus_state] * asset.num_bodies).to(device=env.device).unsqueeze(0) - pose_tensor = torch.tensor([pose_list[asset_idx]], device=env.device) - positions = pose_tensor[:, 0:3] + env.scene.env_origins[cur_env, 0:3] - orientations = math_utils.quat_from_euler_xyz(pose_tensor[:, 3], pose_tensor[:, 4], pose_tensor[:, 5]) - object_states[0, object_id, 0:3] = positions.squeeze(0) - object_states[0, object_id, 3:7] = orientations.squeeze(0) - - asset.write_body_pose_to_sim_index( - body_poses=object_states[:, :, :7], env_ids=torch.tensor([cur_env], device=env.device) - ) - asset.write_body_link_velocity_to_sim_index( - body_velocities=object_states[:, :, 7:], env_ids=torch.tensor([cur_env], device=env.device) - ) - - env.rigid_objects_in_focus.append(selected_ids) - - -def randomize_visual_texture_material( - env: ManagerBasedEnv, - env_ids: torch.Tensor, - asset_cfg: SceneEntityCfg, - textures: list[str], - default_texture: str = "", - texture_rotation: tuple[float, float] = (0.0, 0.0), -): - """Randomize the visual texture of bodies on an asset using Replicator API. - - This function randomizes the visual texture of the bodies of the asset using the Replicator API. - The function samples random textures from the given texture paths and applies them to the bodies - of the asset. The textures are projected onto the bodies and rotated by the given angles. - - .. note:: - The function assumes that the asset follows the prim naming convention as: - "{asset_prim_path}/{body_name}/visuals" where the body name is the name of the body to - which the texture is applied. This is the default prim ordering when importing assets - from the asset converters in Isaac Lab. - - .. note:: - When randomizing the texture of individual assets, please make sure to set - :attr:`isaaclab.scene.InteractiveSceneCfg.replicate_physics` to False. This ensures that physics - parser will parse the individual asset properties separately. - """ - if hasattr(env.cfg, "eval_mode") and ( - not env.cfg.eval_mode or env.cfg.eval_type not in [f"{asset_cfg.name}_texture", "all"] - ): - return - # textures = [default_texture] - - # enable replicator extension if not already enabled - from isaacsim.core.experimental.utils.app import enable_extension - - enable_extension("omni.replicator.core") - # we import the module here since we may not always need the replicator - import omni.replicator.core as rep - - # check to make sure replicate_physics is set to False, else raise error - # note: We add an explicit check here since texture randomization can happen outside of 'prestartup' mode - # and the event manager doesn't check in that case. - if env.cfg.scene.replicate_physics: - raise RuntimeError( - "Unable to randomize visual texture material with scene replication enabled." - " For stable USD-level randomization, please disable scene replication" - " by setting 'replicate_physics' to False in 'InteractiveSceneCfg'." - ) - - # convert from radians to degrees - texture_rotation = tuple(math.degrees(angle) for angle in texture_rotation) - - # obtain the asset entity - asset = env.scene[asset_cfg.name] - - # join all bodies in the asset - body_names = asset_cfg.body_names - if isinstance(body_names, str): - body_names_regex = body_names - elif isinstance(body_names, list): - body_names_regex = "|".join(body_names) - else: - body_names_regex = ".*" - - if not hasattr(asset, "cfg"): - prims_group = rep.get.prims(path_pattern=f"{asset.prim_paths[0]}/visuals") - else: - prims_group = rep.get.prims(path_pattern=f"{asset.cfg.prim_path}/{body_names_regex}/visuals") - - with prims_group: - rep.randomizer.texture( - textures=textures, project_uvw=True, texture_rotate=rep.distribution.uniform(*texture_rotation) - ) +"""Backward-compatible re-exports of the (now robot-neutral) stack event functions. + +These functions used to live here but are robot-agnostic and shared by every stack robot config, +so they were moved to :mod:`.stack_events`. This module re-exports them so existing imports +(``from isaaclab_tasks.contrib.stack.mdp import franka_stack_events``) keep working. New configs +should import from :mod:`.stack_events` directly. +""" + +from .stack_events import ( + randomize_joint_by_gaussian_offset, + randomize_object_pose, + randomize_rigid_objects_in_focus, + randomize_scene_lighting_domelight, + randomize_visual_texture_material, + sample_object_poses, + sample_random_color, + set_default_joint_pose, +) + +__all__ = [ + "randomize_joint_by_gaussian_offset", + "randomize_object_pose", + "randomize_rigid_objects_in_focus", + "randomize_scene_lighting_domelight", + "randomize_visual_texture_material", + "sample_object_poses", + "sample_random_color", + "set_default_joint_pose", +] diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/observations.py index 1269ef260ff..94adf1b6453 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/observations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/observations.py @@ -292,7 +292,12 @@ def gripper_pos( else: if hasattr(env.cfg, "gripper_joint_names"): gripper_joint_ids, _ = robot.find_joints(env.cfg.gripper_joint_names) - assert len(gripper_joint_ids) == 2, "Observation gripper_pos only support parallel gripper for now" + if len(gripper_joint_ids) == 1: + # single-jaw gripper (e.g. SO-101) + return robot.data.joint_pos.torch[:, gripper_joint_ids[0]].clone().unsqueeze(1) + assert len(gripper_joint_ids) == 2, ( + "Observation gripper_pos only supports single- or parallel-jaw (2-joint) grippers for now" + ) finger_joint_1 = robot.data.joint_pos.torch[:, gripper_joint_ids[0]].clone().unsqueeze(1) finger_joint_2 = -1 * robot.data.joint_pos.torch[:, gripper_joint_ids[1]].clone().unsqueeze(1) return torch.cat((finger_joint_1, finger_joint_2), dim=1) @@ -326,24 +331,17 @@ def object_grasped( else: if hasattr(env.cfg, "gripper_joint_names"): gripper_joint_ids, _ = robot.find_joints(env.cfg.gripper_joint_names) - assert len(gripper_joint_ids) == 2, "Observations only support parallel gripper for now" - - grasped = torch.logical_and( - pose_diff < diff_threshold, - torch.abs( - robot.data.joint_pos.torch[:, gripper_joint_ids[0]] - - torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device) - ) - > env.cfg.gripper_threshold, - ) - grasped = torch.logical_and( - grasped, - torch.abs( - robot.data.joint_pos.torch[:, gripper_joint_ids[1]] - - torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device) + assert len(gripper_joint_ids) >= 1, "Observations require at least one gripper joint" + + # Grasped: the end-effector is close to the object and every gripper joint has moved + # away from the open position (i.e. the jaws have closed on the object). + open_val = torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device) + grasped = pose_diff < diff_threshold + for joint_id in gripper_joint_ids: + grasped = torch.logical_and( + grasped, + torch.abs(robot.data.joint_pos.torch[:, joint_id] - open_val) > env.cfg.gripper_threshold, ) - > env.cfg.gripper_threshold, - ) return grasped @@ -378,25 +376,19 @@ def object_stacked( else: if hasattr(env.cfg, "gripper_joint_names"): gripper_joint_ids, _ = robot.find_joints(env.cfg.gripper_joint_names) - assert len(gripper_joint_ids) == 2, "Observations only support parallel gripper for now" - stacked = torch.logical_and( - torch.isclose( - robot.data.joint_pos.torch[:, gripper_joint_ids[0]], - torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device), - atol=1e-4, - rtol=1e-4, - ), - stacked, - ) - stacked = torch.logical_and( - torch.isclose( - robot.data.joint_pos.torch[:, gripper_joint_ids[1]], - torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device), - atol=1e-4, - rtol=1e-4, - ), - stacked, - ) + assert len(gripper_joint_ids) >= 1, "Observations require at least one gripper joint" + # Stacked also requires the gripper to be released (every jaw back at the open value). + open_val = torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device) + for joint_id in gripper_joint_ids: + stacked = torch.logical_and( + torch.isclose( + robot.data.joint_pos.torch[:, joint_id], + open_val, + atol=1e-4, + rtol=1e-4, + ), + stacked, + ) else: raise ValueError("No gripper_joint_names found in environment config") diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/stack_events.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/stack_events.py new file mode 100644 index 00000000000..5bc7186a3a5 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/stack_events.py @@ -0,0 +1,363 @@ +# 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-neutral event functions shared by the cube-stacking tasks. + +These were previously defined in ``franka_stack_events.py``; they are robot-agnostic and are used +by every stack robot config, so they live here and ``franka_stack_events`` re-exports them for +backward compatibility. +""" + +from __future__ import annotations + +import math +import random +from typing import TYPE_CHECKING + +import torch +import warp as wp + +import isaaclab.utils.math as math_utils +from isaaclab.managers import SceneEntityCfg + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, AssetBase + from isaaclab.envs import ManagerBasedEnv + + +def set_default_joint_pose( + env: ManagerBasedEnv, + env_ids: torch.Tensor, + default_pose: torch.Tensor, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), +): + # Set the default pose for robots in all envs + asset = env.scene[asset_cfg.name] + # Convert default_pose to 1D array and create joint indices + default_pose_1d = torch.tensor(default_pose, device=env.device).repeat(env.num_envs, 1).flatten() + num_joints = len(default_pose) + joint_ids = torch.arange(num_joints, device=env.device, dtype=torch.int32) + # Use update_default_joint_values kernel to update all joints for all environments + from isaaclab_physx.assets.articulation.kernels import update_default_joint_values + + wp.launch( + update_default_joint_values, + dim=(env.num_envs, num_joints), + inputs=[ + default_pose_1d, + joint_ids, + ], + outputs=[asset.data.default_joint_pos], + device=env.device, + ) + + +def randomize_joint_by_gaussian_offset( + env: ManagerBasedEnv, + env_ids: torch.Tensor, + mean: float, + std: float, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), +): + """Add Gaussian noise to the (non-gripper) joints of an asset on reset. + + The gripper joints are restored to their default pose rather than noised. They are resolved + generically from ``env.cfg.gripper_joint_names`` (the same names the stack observations and + terminations already use) instead of assuming the last two joints are the gripper -- the latter + is Franka-specific and wrong for, e.g., the single-jaw SO-101 gripper or a UR10 surface gripper. + + Behavior of ``env.cfg.gripper_joint_names``: + + * a non-empty list -> those joints are held at their default pose (everything else is noised); + * an empty list -> no gripper joints to hold, so every joint is noised (e.g. surface grippers); + * unset / ``None`` -> backward-compatible fallback that holds the last two joints. + """ + asset: Articulation = env.scene[asset_cfg.name] + + # Add gaussian noise to joint states + joint_pos = asset.data.default_joint_pos.torch[env_ids].clone() + joint_vel = asset.data.default_joint_vel.torch[env_ids].clone() + joint_pos += math_utils.sample_gaussian(mean, std, joint_pos.shape, joint_pos.device) + + # Clamp joint pos to limits + joint_pos_limits = asset.data.soft_joint_pos_limits.torch[env_ids] + joint_pos = joint_pos.clamp_(joint_pos_limits[..., 0], joint_pos_limits[..., 1]) + + # Don't noise the gripper joints (resolved generically; see the docstring). + gripper_joint_names = getattr(env.cfg, "gripper_joint_names", None) + if gripper_joint_names is None: + # Backward-compatible fallback for callers that do not configure gripper joint names. + joint_pos[:, -2:] = asset.data.default_joint_pos.torch[env_ids, -2:] + elif gripper_joint_names: + gripper_ids, _ = asset.find_joints(gripper_joint_names) + joint_pos[:, gripper_ids] = asset.data.default_joint_pos.torch[env_ids][:, gripper_ids] + + # Set into the physics simulation + asset.set_joint_position_target_index(target=joint_pos, env_ids=env_ids) + asset.set_joint_velocity_target_index(target=joint_vel, env_ids=env_ids) + asset.write_joint_position_to_sim_index(position=joint_pos, env_ids=env_ids) + asset.write_joint_velocity_to_sim_index(velocity=joint_vel, env_ids=env_ids) + + +def sample_random_color(base=(0.75, 0.75, 0.75), variation=0.1): + """ + Generates a randomized color that stays close to the base color while preserving overall brightness. + The relative balance between the R, G, and B components is maintained by ensuring that + the sum of random offsets is zero. + + Parameters: + base (tuple): The base RGB color with each component between 0 and 1. + variation (float): Maximum deviation to sample for each channel before balancing. + + Returns: + tuple: A new RGB color with balanced random variation. + """ + # Generate random offsets for each channel in the range [-variation, variation] + offsets = [random.uniform(-variation, variation) for _ in range(3)] + # Compute the average offset + avg_offset = sum(offsets) / 3 + # Adjust offsets so their sum is zero (maintaining brightness) + balanced_offsets = [offset - avg_offset for offset in offsets] + + # Apply the balanced offsets to the base color and clamp each channel between 0 and 1 + new_color = tuple(max(0, min(1, base_component + offset)) for base_component, offset in zip(base, balanced_offsets)) + + return new_color + + +def randomize_scene_lighting_domelight( + env: ManagerBasedEnv, + env_ids: torch.Tensor, + intensity_range: tuple[float, float], + color_variation: float, + textures: list[str], + default_intensity: float = 3000.0, + default_color: tuple[float, float, float] = (0.75, 0.75, 0.75), + default_texture: str = "", + asset_cfg: SceneEntityCfg = SceneEntityCfg("light"), +): + asset: AssetBase = env.scene[asset_cfg.name] + light_prim = asset.prims[0] + + intensity_attr = light_prim.GetAttribute("inputs:intensity") + intensity_attr.Set(default_intensity) + + color_attr = light_prim.GetAttribute("inputs:color") + color_attr.Set(default_color) + + texture_file_attr = light_prim.GetAttribute("inputs:texture:file") + texture_file_attr.Set(default_texture) + + if not hasattr(env.cfg, "eval_mode") or not env.cfg.eval_mode: + return + + if env.cfg.eval_type in ["light_intensity", "all"]: + # Sample new light intensity + new_intensity = random.uniform(intensity_range[0], intensity_range[1]) + # Set light intensity to light prim + intensity_attr.Set(new_intensity) + + if env.cfg.eval_type in ["light_color", "all"]: + # Sample new light color + new_color = sample_random_color(base=default_color, variation=color_variation) + # Set light color to light prim + color_attr.Set(new_color) + + if env.cfg.eval_type in ["light_texture", "all"]: + # Sample new light texture (background) + new_texture = random.sample(textures, 1)[0] + # Set light texture to light prim + texture_file_attr.Set(new_texture) + + +def sample_object_poses( + num_objects: int, + min_separation: float = 0.0, + pose_range: dict[str, tuple[float, float]] = {}, + max_sample_tries: int = 5000, +): + range_list = [pose_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z", "roll", "pitch", "yaw"]] + pose_list = [] + + for i in range(num_objects): + for j in range(max_sample_tries): + sample = [random.uniform(range[0], range[1]) for range in range_list] + + # Accept pose if it is the first one, or if reached max num tries + if len(pose_list) == 0 or j == max_sample_tries - 1: + pose_list.append(sample) + break + + # Check if pose of object is sufficiently far away from all other objects + separation_check = [math.dist(sample[:3], pose[:3]) > min_separation for pose in pose_list] + if False not in separation_check: + pose_list.append(sample) + break + + return pose_list + + +def randomize_object_pose( + env: ManagerBasedEnv, + env_ids: torch.Tensor, + asset_cfgs: list[SceneEntityCfg], + min_separation: float = 0.0, + pose_range: dict[str, tuple[float, float]] = {}, + max_sample_tries: int = 5000, +): + if env_ids is None: + return + + # Randomize poses in each environment independently + for cur_env in env_ids.tolist(): + pose_list = sample_object_poses( + num_objects=len(asset_cfgs), + min_separation=min_separation, + pose_range=pose_range, + max_sample_tries=max_sample_tries, + ) + + # Randomize pose for each object + for i in range(len(asset_cfgs)): + asset_cfg = asset_cfgs[i] + asset = env.scene[asset_cfg.name] + + # Write pose to simulation + pose_tensor = torch.tensor([pose_list[i]], device=env.device) + positions = pose_tensor[:, 0:3] + env.scene.env_origins[cur_env, 0:3] + orientations = math_utils.quat_from_euler_xyz(pose_tensor[:, 3], pose_tensor[:, 4], pose_tensor[:, 5]) + asset.write_root_pose_to_sim_index( + root_pose=torch.cat([positions, orientations], dim=-1), + env_ids=torch.tensor([cur_env], device=env.device), + ) + asset.write_root_velocity_to_sim_index( + root_velocity=torch.zeros(1, 6, device=env.device), env_ids=torch.tensor([cur_env], device=env.device) + ) + + +def randomize_rigid_objects_in_focus( + env: ManagerBasedEnv, + env_ids: torch.Tensor, + asset_cfgs: list[SceneEntityCfg], + out_focus_state: torch.Tensor, + min_separation: float = 0.0, + pose_range: dict[str, tuple[float, float]] = {}, + max_sample_tries: int = 5000, +): + if env_ids is None: + return + + # List of rigid objects in focus for each env (dim = [num_envs, num_rigid_objects]) + env.rigid_objects_in_focus = [] + + for cur_env in env_ids.tolist(): + # Sample in focus object poses + pose_list = sample_object_poses( + num_objects=len(asset_cfgs), + min_separation=min_separation, + pose_range=pose_range, + max_sample_tries=max_sample_tries, + ) + + selected_ids = [] + for asset_idx in range(len(asset_cfgs)): + asset_cfg = asset_cfgs[asset_idx] + asset = env.scene[asset_cfg.name] + + # Randomly select an object to bring into focus + object_id = random.randint(0, asset.num_bodies - 1) + selected_ids.append(object_id) + + # Create object state tensor with shape (num_envs, num_objects, state_dim) + # Since we're updating one environment, we need shape (1, num_objects, state_dim) + object_states = torch.stack([out_focus_state] * asset.num_bodies).to(device=env.device).unsqueeze(0) + pose_tensor = torch.tensor([pose_list[asset_idx]], device=env.device) + positions = pose_tensor[:, 0:3] + env.scene.env_origins[cur_env, 0:3] + orientations = math_utils.quat_from_euler_xyz(pose_tensor[:, 3], pose_tensor[:, 4], pose_tensor[:, 5]) + object_states[0, object_id, 0:3] = positions.squeeze(0) + object_states[0, object_id, 3:7] = orientations.squeeze(0) + + asset.write_body_pose_to_sim_index( + body_poses=object_states[:, :, :7], env_ids=torch.tensor([cur_env], device=env.device) + ) + asset.write_body_link_velocity_to_sim_index( + body_velocities=object_states[:, :, 7:], env_ids=torch.tensor([cur_env], device=env.device) + ) + + env.rigid_objects_in_focus.append(selected_ids) + + +def randomize_visual_texture_material( + env: ManagerBasedEnv, + env_ids: torch.Tensor, + asset_cfg: SceneEntityCfg, + textures: list[str], + default_texture: str = "", + texture_rotation: tuple[float, float] = (0.0, 0.0), +): + """Randomize the visual texture of bodies on an asset using Replicator API. + + This function randomizes the visual texture of the bodies of the asset using the Replicator API. + The function samples random textures from the given texture paths and applies them to the bodies + of the asset. The textures are projected onto the bodies and rotated by the given angles. + + .. note:: + The function assumes that the asset follows the prim naming convention as: + "{asset_prim_path}/{body_name}/visuals" where the body name is the name of the body to + which the texture is applied. This is the default prim ordering when importing assets + from the asset converters in Isaac Lab. + + .. note:: + When randomizing the texture of individual assets, please make sure to set + :attr:`isaaclab.scene.InteractiveSceneCfg.replicate_physics` to False. This ensures that physics + parser will parse the individual asset properties separately. + """ + if hasattr(env.cfg, "eval_mode") and ( + not env.cfg.eval_mode or env.cfg.eval_type not in [f"{asset_cfg.name}_texture", "all"] + ): + return + # textures = [default_texture] + + # enable replicator extension if not already enabled + from isaacsim.core.experimental.utils.app import enable_extension + + enable_extension("omni.replicator.core") + # we import the module here since we may not always need the replicator + import omni.replicator.core as rep + + # check to make sure replicate_physics is set to False, else raise error + # note: We add an explicit check here since texture randomization can happen outside of 'prestartup' mode + # and the event manager doesn't check in that case. + if env.cfg.scene.replicate_physics: + raise RuntimeError( + "Unable to randomize visual texture material with scene replication enabled." + " For stable USD-level randomization, please disable scene replication" + " by setting 'replicate_physics' to False in 'InteractiveSceneCfg'." + ) + + # convert from radians to degrees + texture_rotation = tuple(math.degrees(angle) for angle in texture_rotation) + + # obtain the asset entity + asset = env.scene[asset_cfg.name] + + # join all bodies in the asset + body_names = asset_cfg.body_names + if isinstance(body_names, str): + body_names_regex = body_names + elif isinstance(body_names, list): + body_names_regex = "|".join(body_names) + else: + body_names_regex = ".*" + + if not hasattr(asset, "cfg"): + prims_group = rep.get.prims(path_pattern=f"{asset.prim_paths[0]}/visuals") + else: + prims_group = rep.get.prims(path_pattern=f"{asset.cfg.prim_path}/{body_names_regex}/visuals") + + with prims_group: + rep.randomizer.texture( + textures=textures, project_uvw=True, texture_rotate=rep.distribution.uniform(*texture_rotation) + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/terminations.py index e709821972c..bcfa202e258 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/terminations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/terminations.py @@ -77,26 +77,19 @@ def cubes_stacked( else: if hasattr(env.cfg, "gripper_joint_names"): gripper_joint_ids, _ = robot.find_joints(env.cfg.gripper_joint_names) - assert len(gripper_joint_ids) == 2, "Terminations only support parallel gripper for now" - - stacked = torch.logical_and( - torch.isclose( - robot.data.joint_pos.torch[:, gripper_joint_ids[0]], - torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device), - atol=atol, - rtol=rtol, - ), - stacked, - ) - stacked = torch.logical_and( - torch.isclose( - robot.data.joint_pos.torch[:, gripper_joint_ids[1]], - torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device), - atol=atol, - rtol=rtol, - ), - stacked, - ) + assert len(gripper_joint_ids) >= 1, "Terminations require at least one gripper joint" + # Success also requires the gripper to be released (every jaw back at the open value). + open_val = torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device) + for joint_id in gripper_joint_ids: + stacked = torch.logical_and( + torch.isclose( + robot.data.joint_pos.torch[:, joint_id], + open_val, + atol=atol, + rtol=rtol, + ), + stacked, + ) else: raise ValueError("No gripper_joint_names found in environment config") diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/stack_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/stack_env_cfg.py index 654d9e57aca..690cebab54b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/stack_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/stack_env_cfg.py @@ -9,15 +9,18 @@ from isaaclab_physx.physics import PhysxCfg import isaaclab.sim as sim_utils -from isaaclab.assets import ArticulationCfg, AssetBaseCfg +from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg from isaaclab.devices.openxr import XrCfg from isaaclab.envs import ManagerBasedRLEnvCfg +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 SceneEntityCfg from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.markers.config import FRAME_MARKER_CFG from isaaclab.scene import InteractiveSceneCfg -from isaaclab.sensors.frame_transformer.frame_transformer_cfg import FrameTransformerCfg +from isaaclab.sensors.frame_transformer.frame_transformer_cfg import FrameTransformerCfg, OffsetCfg +from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg 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 @@ -25,6 +28,60 @@ from isaaclab_tasks.utils import PresetCfg from . import mdp +from .mdp import stack_events + +# Shared rigid-body properties for the three stacking cubes (robot-neutral). +_CUBE_PROPERTIES = RigidBodyPropertiesCfg( + solver_position_iteration_count=16, + solver_velocity_iteration_count=1, + max_angular_velocity=1000.0, + max_linear_velocity=1000.0, + max_depenetration_velocity=5.0, + disable_gravity=False, +) + + +def make_ee_frame_cfg( + base_prim_path: str, + target_specs: list[tuple[str, str, tuple[float, float, float]]], + marker_scale: tuple[float, float, float] = (0.1, 0.1, 0.1), +) -> FrameTransformerCfg: + """Build the stacking end-effector :class:`FrameTransformerCfg` with the shared marker setup. + + The marker boilerplate is identical across robots; only the source/target prim paths and + offsets are robot-specific, so those are passed in. + + Args: + base_prim_path: Source-frame prim path (the robot base link). + target_specs: List of ``(prim_path, name, offset_pos)`` tuples, one per target frame. + marker_scale: Visualization marker scale. + + Returns: + The configured :class:`FrameTransformerCfg`. + """ + marker_cfg = FRAME_MARKER_CFG.copy() + marker_cfg.markers["frame"].scale = marker_scale + marker_cfg.prim_path = "/Visuals/FrameTransformer" + return FrameTransformerCfg( + prim_path=base_prim_path, + debug_vis=False, + visualizer_cfg=marker_cfg, + target_frames=[ + FrameTransformerCfg.FrameCfg(prim_path=prim_path, name=name, offset=OffsetCfg(pos=pos)) + for (prim_path, name, pos) in target_specs + ], + ) + + +def apply_default_semantics(scene: "ObjectTableSceneCfg") -> None: + """Tag the table, ground, and robot with their semantic classes. + + Call this from a robot env cfg's ``__post_init__`` after the robot has been set (the robot is + populated by the derived cfg, so it cannot be tagged in the base ``__post_init__``). + """ + scene.table.spawn.semantic_tags = [("class", "table")] + scene.plane.semantic_tags = [("class", "ground")] + scene.robot.spawn.semantic_tags = [("class", "robot")] ## @@ -62,6 +119,39 @@ class ObjectTableSceneCfg(InteractiveSceneCfg): spawn=sim_utils.DomeLightCfg(color=(0.75, 0.75, 0.75), intensity=3000.0), ) + # Three stacking cubes (blue / red / green). Derived robot cfgs override only the per-cube + # ``init_state`` (transforms) to place them within the robot's reach. + cube_1 = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube_1", + init_state=RigidObjectCfg.InitialStateCfg(pos=[0.4, 0.0, 0.0203], rot=[0, 0, 0, 1]), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/blue_block.usd", + scale=(1.0, 1.0, 1.0), + rigid_props=_CUBE_PROPERTIES, + semantic_tags=[("class", "cube_1")], + ), + ) + cube_2 = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube_2", + init_state=RigidObjectCfg.InitialStateCfg(pos=[0.55, 0.05, 0.0203], rot=[0, 0, 0, 1]), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/red_block.usd", + scale=(1.0, 1.0, 1.0), + rigid_props=_CUBE_PROPERTIES, + semantic_tags=[("class", "cube_2")], + ), + ) + cube_3 = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube_3", + init_state=RigidObjectCfg.InitialStateCfg(pos=[0.60, -0.1, 0.0203], rot=[0, 0, 0, 1]), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/green_block.usd", + scale=(1.0, 1.0, 1.0), + rigid_props=_CUBE_PROPERTIES, + semantic_tags=[("class", "cube_3")], + ), + ) + ## # MDP settings @@ -75,6 +165,37 @@ class ActionsCfg: gripper_action: mdp.BinaryJointPositionActionCfg = MISSING +@configclass +class StackEventCfg: + """Robot-neutral reset events shared by the stacking tasks. + + Derived robot cfgs typically override the cube ``pose_range`` / ``min_separation`` to match the + robot's reachable workspace. The joint-randomization term holds the gripper joints fixed, + resolving them from the env's ``gripper_joint_names`` (see + :func:`~isaaclab_tasks.contrib.stack.mdp.stack_events.randomize_joint_by_gaussian_offset`). + """ + + randomize_joint_state = EventTerm( + func=stack_events.randomize_joint_by_gaussian_offset, + mode="reset", + params={ + "mean": 0.0, + "std": 0.02, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + randomize_cube_positions = EventTerm( + func=stack_events.randomize_object_pose, + mode="reset", + params={ + "pose_range": {"x": (0.4, 0.6), "y": (-0.10, 0.10), "z": (0.0203, 0.0203), "yaw": (-1.0, 1.0)}, + "min_separation": 0.1, + "asset_cfgs": [SceneEntityCfg("cube_1"), SceneEntityCfg("cube_2"), SceneEntityCfg("cube_3")], + }, + ) + + @configclass class ObservationsCfg: """Observation specifications for the MDP.""" diff --git a/source/isaaclab_tasks/test/contrib/stack/test_so101_pose_ik.py b/source/isaaclab_tasks/test/contrib/stack/test_so101_pose_ik.py new file mode 100644 index 00000000000..10e5086c8f2 --- /dev/null +++ b/source/isaaclab_tasks/test/contrib/stack/test_so101_pose_ik.py @@ -0,0 +1,190 @@ +# 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 + +"""Sim-free unit tests for the SO-101 IK layer. + +The SO-101 controller is a thin subclass of the core +:class:`~isaaclab.controllers.DifferentialIKController`; the only SO-101-specific behavior is the +wrist-only orientation joint mask. The generic IK features it relies on (the ``adaptive_dls`` +ik-method, per-axis orientation weighting, and null-space joint-limit avoidance) are tested in +``source/isaaclab/test/controllers/test_differential_ik_features.py``. These tests cover the mask +plus the IK-Abs env/action wiring with hand-built tensors -- no gym.make, USD, or GPU. +""" + +import dataclasses +import math + +import pytest +import torch + +from isaaclab_tasks.contrib.stack.config.so101.pose_ik_controller import ( + SO101PoseIKController, + SO101PoseIKControllerCfg, +) + +# The SO-101 arm IK acts over 5 joints; the geometric Jacobian is (N, 6, 5). +_NUM_JOINTS = 5 +_ID_QUAT = [0.0, 0.0, 0.0, 1.0] # xyzw identity + + +def _quat_xyzw(axis: list[float], angle: float) -> list[float]: + """Build a unit xyzw quaternion from an axis (need not be unit) and angle [rad].""" + norm = math.sqrt(sum(a * a for a in axis)) or 1.0 + s = math.sin(angle / 2.0) + return [axis[0] / norm * s, axis[1] / norm * s, axis[2] / norm * s, math.cos(angle / 2.0)] + + +def _make_controller( + num_envs: int = 1, + orientation_weight=1.0, + joint_limit_avoidance_gain: float = 0.0, + joint_limit_avoidance_margin: float = 0.3, +): + cfg = SO101PoseIKControllerCfg( + command_type="pose", + use_relative_mode=False, + ik_method="adaptive_dls", + ik_params={"lambda_min": 0.05, "lambda_max": 0.2, "sigma_thresh": 0.02}, + orientation_weight=orientation_weight, + joint_limit_avoidance_gain=joint_limit_avoidance_gain, + joint_limit_avoidance_margin=joint_limit_avoidance_margin, + ) + return SO101PoseIKController(cfg=cfg, num_envs=num_envs, device="cpu") + + +def test_action_dim_is_seven(): + """The controller advertises a 7D pose command: [pos_xyz, quat_xyzw] (inherited from core).""" + assert _make_controller().action_dim == 7 + + +def test_orientation_joint_mask_zeros_unmasked_orientation_columns(): + """The SO-101 orientation joint mask zeros the orientation-row columns of the masked-out joints + (so they serve position only), while the position rows and the task error are unchanged. + + This is the only SO-101-specific addition over the core controller: only ``wrist_flex`` / + ``wrist_roll`` (the last two columns) may serve orientation, so ``shoulder_pan`` (col 0) never + drives it and the base does not swing to track a commanded orientation. + """ + ee_pos = torch.tensor([[0.3, 0.0, 0.2]]) + ee_quat = torch.tensor([_ID_QUAT]) + jac = torch.arange(6 * _NUM_JOINTS, dtype=torch.float32).reshape(1, 6, _NUM_JOINTS) + cmd = torch.tensor([[0.31, 0.0, 0.2] + _quat_xyzw([0.3, 0.5, 0.8], 0.7)]) + + # weight 1.0 isolates the mask effect (no per-axis scaling on top) + c = _make_controller(orientation_weight=1.0) + c.set_orientation_joint_mask(torch.tensor([0.0, 0.0, 0.0, 1.0, 1.0])) # wrist joints only + c.set_command(cmd) + task_jac, err = c._compute_pose_task(ee_pos, ee_quat, jac) + + # position rows keep every joint (unchanged from the raw Jacobian linear block) + torch.testing.assert_close(task_jac[:, :3, :], jac[:, 0:3, :]) + # orientation rows: masked-out joints (cols 0..2) zeroed; allowed wrist joints (cols 3,4) kept + torch.testing.assert_close(task_jac[:, 3:6, 0:3], torch.zeros(1, 3, 3)) + torch.testing.assert_close(task_jac[:, 3:6, 3:5], jac[:, 3:6, 3:5]) + + # the mask limits which joints reduce the orientation error; it does not alter the error + base = _make_controller(orientation_weight=1.0) + base.set_command(cmd) + _, eb = base._compute_pose_task(ee_pos, ee_quat, jac) + torch.testing.assert_close(err, eb) + + +def test_mask_none_leaves_orientation_unmasked(): + """Without a mask, the SO-101 pose task matches the (orientation-weighted) core task.""" + ee_pos = torch.tensor([[0.3, 0.0, 0.2]]) + ee_quat = torch.tensor([_ID_QUAT]) + jac = torch.arange(6 * _NUM_JOINTS, dtype=torch.float32).reshape(1, 6, _NUM_JOINTS) + c = _make_controller(orientation_weight=1.0) + c.set_command(torch.tensor([[0.31, 0.0, 0.2] + _quat_xyzw([1.0, 0.0, 0.0], 0.5)])) + task_jac, _ = c._compute_pose_task(ee_pos, ee_quat, jac) + torch.testing.assert_close(task_jac, jac) + + +def test_compute_returns_joint_targets_shape(): + """End-to-end compute (adaptive DLS + orientation weight + mask + JLA) returns one target per + joint.""" + c = _make_controller(orientation_weight=0.5, joint_limit_avoidance_gain=0.5) + c.set_orientation_joint_mask(torch.tensor([0.0, 0.0, 0.0, 1.0, 1.0])) + c.set_joint_pos_limits(torch.full((_NUM_JOINTS,), -1.0), torch.full((_NUM_JOINTS,), 1.0)) + ee_pos = torch.tensor([[0.3, 0.0, 0.2]]) + ee_quat = torch.tensor([_ID_QUAT]) + c.set_command(torch.tensor([[0.31, 0.0, 0.2] + _ID_QUAT])) + jac = torch.zeros(1, 6, _NUM_JOINTS) + for i in range(_NUM_JOINTS): + jac[0, i, i] = 1.0 + out = c.compute(ee_pos, ee_quat, jac, torch.zeros(1, _NUM_JOINTS)) + assert out.shape == (1, _NUM_JOINTS) + + +def test_action_cfg_points_at_custom_action_and_controller(): + """The action cfg wires the custom action class and the SO-101 controller cfg.""" + pytest.importorskip("pxr") # the action term imports UsdPhysics at module load + from isaaclab.utils.string import string_to_callable + + from isaaclab_tasks.contrib.stack.config.so101.pose_ik_action import SO101PoseIKActionCfg + from isaaclab_tasks.contrib.stack.config.so101.pose_ik_action_term import SO101PoseIKAction + + cfg = SO101PoseIKActionCfg( + asset_name="robot", + joint_names=["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll"], + body_name="gripper", + controller=SO101PoseIKControllerCfg(command_type="pose", use_relative_mode=False, ik_method="adaptive_dls"), + ) + # ``class_type`` is a lazy ``{DIR}.pose_ik_action_term:SO101PoseIKAction`` string (so the cfg + # stays importable without Kit); resolve it to confirm it points at the custom term. + assert string_to_callable(str(cfg.class_type)) is SO101PoseIKAction + assert isinstance(cfg.controller, SO101PoseIKControllerCfg) + + +def test_action_cfg_accepts_clip_field(): + """The cfg dataclass accepts a clip value as a plain field (no sim required). + + Verifying that NotImplementedError is raised when clip is set requires constructing the action + term, which needs a live articulation (sim). That path is sim-gated and is not tested here. + """ + pytest.importorskip("pxr") # action term imports UsdPhysics at module load + from isaaclab_tasks.contrib.stack.config.so101.pose_ik_action import SO101PoseIKActionCfg + + clip_value = {"shoulder_pan": (-1.0, 1.0)} + cfg = SO101PoseIKActionCfg( + asset_name="robot", + joint_names=["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll"], + body_name="gripper", + controller=SO101PoseIKControllerCfg(command_type="pose", use_relative_mode=False, ik_method="adaptive_dls"), + clip=clip_value, + ) + # The cfg stores the value -- the refusal happens at action-term construction (sim-gated). + assert cfg.clip == clip_value + + +def test_env_cfg_arm_action_is_pose_and_ordering_matches_pipeline(): + """The IK-Abs env wires the full-pose arm action, the wrist-only orientation mask, and the + 8D action ordering.""" + pytest.importorskip("pxr") + from isaaclab_tasks.contrib.stack.config.so101.pose_ik_action import SO101PoseIKActionCfg + from isaaclab_tasks.contrib.stack.config.so101.stack_ik_abs_env_cfg import ( + SO101CubeStackEnvCfg, + SO101IkActionsCfg, + ) + + cfg = SO101CubeStackEnvCfg() + assert isinstance(cfg.actions.arm_action, SO101PoseIKActionCfg) + controller = cfg.actions.arm_action.controller + assert controller.command_type == "pose" + assert controller.ik_method == "adaptive_dls" + assert controller.orientation_joint_names == ("wrist_flex", "wrist_roll") + assert cfg.actions.arm_action.joint_names == [ + "shoulder_pan", + "shoulder_lift", + "elbow_flex", + "wrist_flex", + "wrist_roll", + ] + # Field order is the positional contract with the pipeline output_order: + # [arm(pos+quat), gripper] -> [pos_xyz, quat_xyzw, gripper]. + assert [f.name for f in dataclasses.fields(SO101IkActionsCfg)] == [ + "arm_action", + "gripper_action", + ] diff --git a/source/isaaclab_tasks/test/contrib/test_so101_teleop_pipeline.py b/source/isaaclab_tasks/test/contrib/test_so101_teleop_pipeline.py new file mode 100644 index 00000000000..220dbc29cb7 --- /dev/null +++ b/source/isaaclab_tasks/test/contrib/test_so101_teleop_pipeline.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 + +"""Sim-free tests for the SO-101 XR teleop pipeline wiring. + +These tests build the IsaacTeleop pipeline standalone (no ``gym.make``, USD, GPU, or XR +device) and verify the flattened action width and element order, plus the external-input +contract the Lab-side device wiring depends on. + +The retargeter math itself (trigger -> closedness, clutch rebasing, orientation calibration) is +unit tested in Isaac Teleop (``test_so101_retargeters.py``); this file guards only the Lab-side +wiring: + +- The pose retargeter emits a fixed 7-element output; the flattened action passes the full pose + through and concatenates the gripper channel into the 8D action + ``[pos_x, pos_y, pos_z, quat_x, quat_y, quat_z, quat_w, gripper]``. +- Each entry in ``output_order`` must resolve to a declared element name; an undeclared + name resolves silently to a constant 0.0 instead of raising. +- The clutch declares only the controller input (no ``robot_ee_pos`` / ``robot_base_pos``): the + base-frame rebase is handled upstream by ``target_frame_prim_path`` and the engage home is the + clutch's static reset-origin config, so no live end-effector or base feed is wired. +""" + +import pytest + +pytest.importorskip("isaacteleop") + +from isaacteleop.retargeters import SO101ClutchRetargeter +from isaacteleop.retargeters.SO101.gripper_retargeter import GRIPPER_ELEMENT_LABEL +from isaacteleop.retargeting_engine.deviceio_source_nodes import ControllersSource + +from isaaclab_tasks.contrib.stack.config.so101.stack_ik_abs_env_cfg import _build_so101_stack_pipeline + + +def test_so101_pipeline_action_is_8d(): + """The SO-101 pipeline flattens to an 8D action ``[pos_xyz, quat_xyzw, gripper]``.""" + combiner = _build_so101_stack_pipeline() + + action_type = combiner.output_types()["action"].types[0] + assert action_type.shape == (8,), f"expected an 8D action, got shape {action_type.shape}" + + +def test_so101_pipeline_output_order(): + """The action elements resolve to the declared labels in order (not silent 0.0 fillers).""" + combiner = _build_so101_stack_pipeline() + + # Walk the OutputCombiner -> subgraph -> TensorReorderer to read the resolved output order. + # NOTE: this depends on isaacteleop internals; the width==8 test is the public-API guard. + try: + reorderer = combiner.output_mapping["action"].module._target_module + output_order = reorderer._output_order + except AttributeError: + pytest.skip("relies on isaacteleop internals; public accessor unavailable") + + expected_order = [ + "pos_x", + "pos_y", + "pos_z", + "quat_x", + "quat_y", + "quat_z", + "quat_w", + GRIPPER_ELEMENT_LABEL, + ] + assert output_order == expected_order, ( + f"unexpected output order {output_order}; the pose/gripper elements must resolve to the" + f" declared pos/quat/{GRIPPER_ELEMENT_LABEL!r} labels so they do not become silent 0.0" + " fillers" + ) + + +def test_clutch_consumes_controller_only(): + """The clutch declares only the controller; no ``robot_ee_pos`` / ``robot_base_pos`` inputs. + + The world->base rebase happens upstream in the device via ``target_frame_prim_path`` (set to + the robot base), and the engage home is the clutch's static ``home_base_T_ee`` reset-origin, so + the clutch needs no live end-effector or base feed. This guards that the builder does not wire + inputs the device no longer provides. + """ + clutch = SO101ClutchRetargeter(name="ee_pose") + assert list(clutch.input_spec()) == [ControllersSource.RIGHT] + assert not hasattr(SO101ClutchRetargeter, "ROBOT_EE_POS_INPUT") + assert not hasattr(SO101ClutchRetargeter, "ROBOT_BASE_POS_INPUT") + # The builder still flattens to the 8D action contract. + combiner = _build_so101_stack_pipeline() + assert combiner.output_types()["action"].types[0].shape == (8,) From 1e04efbbed0f2762f17a0e30dc3e5b52625088c4 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 4 Jun 2026 18:19:27 -0700 Subject: [PATCH 016/139] feat(schemas): add articulation-root schema-fragment API --- .../vidurv-schema-frag-articulation.minor.rst | 17 ++ source/isaaclab/isaaclab/sim/__init__.pyi | 4 + .../isaaclab/sim/schemas/__init__.pyi | 4 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 110 ++++++++++++ .../isaaclab/sim/schemas/schemas_cfg.py | 15 ++ .../sim/spawners/from_files/from_files.py | 11 +- .../sim/spawners/from_files/from_files_cfg.py | 32 +++- .../test/sim/test_articulation_fragments.py | 162 ++++++++++++++++++ .../vidurv-schema-frag-articulation.minor.rst | 8 + .../isaaclab_newton/sim/schemas/__init__.pyi | 2 + .../sim/schemas/schemas_cfg.py | 27 +++ .../vidurv-schema-frag-articulation.minor.rst | 8 + .../isaaclab_physx/sim/schemas/__init__.pyi | 2 + .../isaaclab_physx/sim/schemas/schemas_cfg.py | 46 +++++ 14 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst create mode 100644 source/isaaclab/test/sim/test_articulation_fragments.py create mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-articulation.minor.rst create mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-articulation.minor.rst diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst new file mode 100644 index 00000000000..ac26c8bafe9 --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst @@ -0,0 +1,17 @@ +Added +^^^^^ + +* Added the articulation-root schema-fragment API: + :class:`~isaaclab.sim.schemas.ArticulationRootFragment` (marker) and + :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`, which applies a list of + articulation-root fragments with ``UsdPhysics.ArticulationRootAPI`` as a presence-gated anchor + and reproduces the legacy ``fix_root_link`` fixed-joint logic via a spawner-level flag. + +Changed +^^^^^^^ + +* Changed the spawner ``articulation_props`` slot + (:attr:`~isaaclab.sim.spawners.UsdFileCfg.articulation_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.ArticulationRootFragment` fragments, and added the spawner-level + :attr:`~isaaclab.sim.spawners.UsdFileCfg.fix_root_link` flag. Legacy single cfgs continue to + work through a transition bridge in the spawn writer. diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index d1e36946aa2..1808813a392 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -61,11 +61,13 @@ __all__ = [ "NewtonSDFCollisionPropertiesCfg", "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyPropertiesCfg", + "ArticulationRootFragment", "RigidBodyBaseCfg", "RigidBodyFragment", "SchemaFragment", "UsdPhysicsCollisionCfg", "UsdPhysicsRigidBodyCfg", + "apply_articulation_root_properties", "apply_collision_properties", "apply_mass_properties", "apply_namespaced", @@ -217,6 +219,7 @@ from .schemas import ( MESH_APPROXIMATION_TOKENS, PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, + ArticulationRootFragment, ArticulationRootPropertiesCfg, BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, @@ -244,6 +247,7 @@ from .schemas import ( UsdPhysicsCollisionCfg, UsdPhysicsRigidBodyCfg, activate_contact_sensors, + apply_articulation_root_properties, apply_collision_properties, apply_mass_properties, apply_namespaced, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index baea5a920f9..f4386effec1 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", + "apply_articulation_root_properties", "apply_collision_properties", "apply_mass_properties", "apply_namespaced", @@ -40,6 +41,7 @@ __all__ = [ "MassFragment", "MassPropertiesCfg", "MeshCollisionBaseCfg", + "ArticulationRootFragment", "RigidBodyFragment", "SchemaFragment", "UsdPhysicsCollisionCfg", @@ -61,6 +63,7 @@ from .schemas import ( PHYSX_MESH_COLLISION_CFGS, USD_MESH_COLLISION_CFGS, activate_contact_sensors, + apply_articulation_root_properties, apply_collision_properties, apply_mass_properties, apply_namespaced, @@ -86,6 +89,7 @@ from .schemas_actuators import ( ) from .schemas_cfg import ( ArticulationRootBaseCfg, + ArticulationRootFragment, BoundingCubePropertiesCfg, BoundingSpherePropertiesCfg, CollisionBaseCfg, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index abbe115541b..d774028354b 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -267,6 +267,116 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ +def apply_articulation_root_properties( + prim_path: str, + fragments, + stage: Usd.Stage | None = None, + fix_root_link: bool | None = None, +) -> bool: + """Apply a list of articulation-root fragments to a prim. + + Applies ``UsdPhysics.ArticulationRootAPI`` as the anchor (presence-gated: this writer only + runs when the ``articulation_props`` slot carries fragments), then dispatches each fragment + via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Backend fragments carry + backend-specific funcs, so core never imports a backend. Finally, if ``fix_root_link`` is not + ``None``, the world-to-root fixed-joint logic from the legacy + :func:`modify_articulation_root_properties` writer is applied. + + Args: + prim_path: The prim path to apply the articulation-root schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.ArticulationRootFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + fix_root_link: Whether to fix the root link of the articulation. This is a non-USD, + spawner-level behaviour flag (it is not a fragment field). See + :attr:`~isaaclab.sim.spawners.UsdFileCfg.fix_root_link` for the semantics. Defaults + to None, in which case the root link is not modified. + + Returns: + True if the properties were successfully set. + + Raises: + NotImplementedError: When the root prim is not a rigid body and a fixed joint is to be created. + """ + if stage is None: + stage = get_current_stage() + articulation_prim = stage.GetPrimAtPath(prim_path) + # apply the defining anchor (presence-gated by the caller) + if not UsdPhysics.ArticulationRootAPI(articulation_prim): + UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) + # dispatch each fragment via its own applier (backend funcs live in backend packages) + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, prim_path, stage) + + # fix root link based on input + # we do the fixed joint processing later to not interfere with setting other properties. + # this logic is reproduced from the legacy ``modify_articulation_root_properties`` writer. + if fix_root_link is not None: + # check if a global fixed joint exists under the root prim + existing_fixed_joint_prim = find_global_fixed_joint_prim(prim_path) + + # if we found a fixed joint, enable/disable it based on the input + # otherwise, create a fixed joint between the world and the root link + if existing_fixed_joint_prim is not None: + logger.info( + f"Found an existing fixed joint for the articulation: '{prim_path}'. Setting it to: {fix_root_link}." + ) + existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) + elif fix_root_link: + logger.info(f"Creating a fixed joint for the articulation: '{prim_path}'.") + + # note: we have to assume that the root prim is a rigid body, + # i.e. we don't handle the case where the root prim is not a rigid body but has articulation api on it + # Currently, there is no obvious way to get first rigid body link identified by the PhysX parser + if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): + raise NotImplementedError( + f"The articulation prim '{prim_path}' does not have the RigidBodyAPI applied." + " To create a fixed joint, we need to determine the first rigid body link in" + " the articulation tree. However, this is not implemented yet." + ) + + # create a fixed joint between the root link and the world frame + from omni.physx.scripts import utils as physx_utils + + physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim) + + # Having a fixed joint on a rigid body is not treated as "fixed base articulation". + # instead, it is treated as a part of the maximal coordinate tree. + # Moving the articulation root to the parent solves this issue. This is a limitation of the PhysX parser. + # get parent prim + parent_prim = articulation_prim.GetParent() + # apply api to parent + UsdPhysics.ArticulationRootAPI.Apply(parent_prim) + parent_applied = parent_prim.GetAppliedSchemas() + if "PhysxArticulationAPI" not in parent_applied: + parent_prim.AddAppliedSchema("PhysxArticulationAPI") + + # copy the attributes + # -- usd attributes + usd_articulation_api = UsdPhysics.ArticulationRootAPI(articulation_prim) + for attr_name in usd_articulation_api.GetSchemaAttributeNames(): + attr = articulation_prim.GetAttribute(attr_name) + parent_attr = parent_prim.GetAttribute(attr_name) + if not parent_attr: + parent_attr = parent_prim.CreateAttribute(attr_name, attr.GetTypeName()) + parent_attr.Set(attr.Get()) + # -- physx attributes (copy by name prefix) + for attr in articulation_prim.GetAttributes(): + aname = attr.GetName() + if aname.startswith("physxArticulation:"): + parent_attr = parent_prim.GetAttribute(aname) + if not parent_attr: + parent_attr = parent_prim.CreateAttribute(aname, attr.GetTypeName()) + parent_attr.Set(attr.Get()) + + # remove api from root + articulation_prim.RemoveAppliedSchema("PhysxArticulationAPI") + articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) + + return True + + def define_articulation_root_properties( prim_path: str, cfg: schemas_cfg.ArticulationRootBaseCfg, stage: Usd.Stage | None = None ): diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index 29478e843cf..1b848dd5c18 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -183,6 +183,21 @@ class CollisionFragment(SchemaFragment): pass +@configclass +class ArticulationRootFragment(SchemaFragment): + """Marker base for articulation-root fragments; types the ``articulation_props`` slot. + + Articulation-root fragments author backend-specific articulation properties (solver + iterations, sleep / stabilization thresholds, self-collision toggles). The defining + ``UsdPhysics.ArticulationRootAPI`` anchor is applied by the articulation-root family + writer (:func:`~isaaclab.sim.schemas.apply_articulation_root_properties`) only when the + ``articulation_props`` slot carries fragments (presence-gated, matching the legacy + :meth:`~isaaclab.sim.schemas.modify_articulation_root_properties` behaviour). + """ + + pass + + @configclass class UsdPhysicsCollisionCfg(CollisionFragment): """``physics:*`` collision attributes from `UsdPhysics.CollisionAPI`_. 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 ff51fa97375..38e6f36ddaf 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -369,7 +369,16 @@ def _spawn_from_usd_file( # modify articulation root properties if cfg.articulation_props is not None: - schemas.modify_articulation_root_properties(prim_path, cfg.articulation_props) + # transition routing: new fragment list -> apply_*; legacy single cfg -> modify_* + articulation_frags = ( + cfg.articulation_props if isinstance(cfg.articulation_props, (list, tuple)) else [cfg.articulation_props] + ) + if articulation_frags and all(isinstance(f, schemas.SchemaFragment) for f in articulation_frags): + schemas.apply_articulation_root_properties( + prim_path, articulation_frags, fix_root_link=getattr(cfg, "fix_root_link", None) + ) + else: + schemas.modify_articulation_root_properties(prim_path, cfg.articulation_props) # modify tendon properties if cfg.fixed_tendons_props is not None: schemas.modify_fixed_tendon_properties(prim_path, cfg.fixed_tendons_props) 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 22857f8d45b..fabc62c5860 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 @@ -34,8 +34,36 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): scale: tuple[float, float, float] | None = None """Scale of the asset. Defaults to None, in which case the scale is not modified.""" - articulation_props: schemas.ArticulationRootPropertiesCfg | None = None - """Properties to apply to the articulation root.""" + articulation_props: ( + schemas.ArticulationRootBaseCfg + | schemas.ArticulationRootFragment + | list[schemas.ArticulationRootFragment] + | None + ) = None + """Properties to apply to the articulation root. + + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.ArticulationRootBaseCfg`) + or a list of :class:`~isaaclab.sim.schemas.ArticulationRootFragment` fragments + (e.g. ``[PhysxArticulationCfg(...), NewtonArticulationCfg(...)]``). When a fragment list is + given, ``UsdPhysics.ArticulationRootAPI`` is applied as the anchor (presence-gated) and each + fragment writes its own namespace. + """ + + fix_root_link: bool | None = None + """Whether to fix the root link of the articulation. Defaults to None. + + This is a non-USD, spawner-level behaviour flag consumed by + :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` when + :attr:`articulation_props` is given as a fragment list: + + * If set to None, the root link is not modified. + * If the articulation already has a fixed root link, this flag enables or disables the fixed joint. + * If the articulation does not have a fixed root link, this flag creates a fixed joint between the + world frame and the root link (named "FixedJoint" under the articulation prim). + + When :attr:`articulation_props` is given as a legacy cfg, set + :attr:`~isaaclab.sim.schemas.ArticulationRootBaseCfg.fix_root_link` on that cfg instead. + """ fixed_tendons_props: schemas.FixedTendonPropertiesCfg | None = None """Properties to apply to the fixed tendons (if any).""" diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py new file mode 100644 index 00000000000..d6245b3dee2 --- /dev/null +++ b/source/isaaclab/test/sim/test_articulation_fragments.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 + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +from pxr import UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + + +def _make_xform(stage, path="/World/Art"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# ArticulationRootFragment marker + metadata +# ------------------------------------------------------------------------------------- + + +def test_articulation_fragment_metadata_defaults(): + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import ArticulationRootFragment, SchemaFragment + + cfg = PhysxArticulationCfg(articulation_enabled=True) + assert isinstance(cfg, ArticulationRootFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physxArticulation" + assert type(cfg)._usd_applied_schema == "PhysxArticulationAPI" + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.articulation_enabled is True and cfg.enabled_self_collisions is None + + +# ------------------------------------------------------------------------------------- +# PhysxArticulationCfg writes physxArticulation:* namespace +# ------------------------------------------------------------------------------------- + + +def test_physx_articulation_fragment_writes_physx_namespace(): + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/A1") + UsdPhysics.ArticulationRootAPI.Apply(prim) + apply_namespaced( + PhysxArticulationCfg(articulation_enabled=True, enabled_self_collisions=False, sleep_threshold=0.1), + "/World/A1", + stage, + ) + assert prim.GetAttribute("physxArticulation:articulationEnabled").Get() is True + assert prim.GetAttribute("physxArticulation:enabledSelfCollisions").Get() is False + assert abs(prim.GetAttribute("physxArticulation:sleepThreshold").Get() - 0.1) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# NewtonArticulationCfg writes newton:* namespace +# ------------------------------------------------------------------------------------- + + +def test_newton_articulation_fragment_writes_newton_namespace(): + from isaaclab_newton.sim.schemas import NewtonArticulationCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/A2") + UsdPhysics.ArticulationRootAPI.Apply(prim) + apply_namespaced(NewtonArticulationCfg(self_collision_enabled=True), "/World/A2", stage) + assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True + + +# ------------------------------------------------------------------------------------- +# apply_articulation_root_properties dispatch (anchor + multi-namespace composition) +# ------------------------------------------------------------------------------------- + + +def test_apply_articulation_root_properties_composes_namespaces(): + from isaaclab_newton.sim.schemas import NewtonArticulationCfg + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/A3") + apply_articulation_root_properties( + "/World/A3", + [ + PhysxArticulationCfg(enabled_self_collisions=True, solver_position_iteration_count=8), + NewtonArticulationCfg(self_collision_enabled=True), + ], + stage, + ) + prim = stage.GetPrimAtPath("/World/A3") + assert bool(UsdPhysics.ArticulationRootAPI(prim)) # presence-gated anchor applied + assert prim.GetAttribute("physxArticulation:enabledSelfCollisions").Get() is True + assert prim.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 + assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True + + +# ------------------------------------------------------------------------------------- +# fix_root_link spawner-level flag: toggles an existing fixed joint +# ------------------------------------------------------------------------------------- + + +def test_apply_articulation_root_properties_toggles_existing_fixed_joint(): + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # root prim with a rigid body and an articulation root + root = _make_xform(stage, "/World/A4") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + # author an existing global fixed joint between the world and the root link + joint = UsdPhysics.FixedJoint.Define(stage, "/World/A4/FixedJoint") + joint.CreateBody1Rel().SetTargets(["/World/A4"]) + joint.CreateJointEnabledAttr(True) + + apply_articulation_root_properties( + "/World/A4", + [PhysxArticulationCfg(articulation_enabled=True)], + stage, + fix_root_link=False, + ) + assert joint.GetJointEnabledAttr().Get() is False + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_newton.sim.schemas import NewtonArticulationCfg # noqa: F401 + from isaaclab_physx.sim.schemas import PhysxArticulationCfg # noqa: F401 + + from isaaclab.sim.schemas import ( # noqa: F401 + ArticulationRootFragment, + SchemaFragment, + apply_articulation_root_properties, + ) diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-articulation.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-articulation.minor.rst new file mode 100644 index 00000000000..85c12eb716b --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-articulation.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.NewtonArticulationCfg`, the ``newton:*`` + single-namespace articulation-root fragment (``newton:selfCollisionEnabled`` via + ``NewtonArticulationRootAPI``). It composes with + :class:`~isaaclab_physx.sim.schemas.PhysxArticulationCfg` in an ``articulation_props`` fragment + list applied via :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi index f7413adbb88..fd8864dcd97 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -7,6 +7,7 @@ __all__ = [ "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyCfg", "MujocoRigidBodyPropertiesCfg", + "NewtonArticulationCfg", "NewtonArticulationRootPropertiesCfg", "NewtonCollisionCfg", "NewtonCollisionPropertiesCfg", @@ -22,6 +23,7 @@ from .schemas_cfg import ( MujocoJointDrivePropertiesCfg, MujocoRigidBodyCfg, MujocoRigidBodyPropertiesCfg, + NewtonArticulationCfg, NewtonArticulationRootPropertiesCfg, NewtonCollisionCfg, NewtonCollisionPropertiesCfg, 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 edf007790b5..76a51c41d65 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -9,6 +9,7 @@ from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, + ArticulationRootFragment, CollisionBaseCfg, CollisionFragment, DeformableBodyPropertiesBaseCfg, @@ -381,3 +382,29 @@ class NewtonArticulationRootPropertiesCfg(ArticulationRootBaseCfg): Newton's resolver checks this native attribute first before falling back to ``physxArticulation:enabledSelfCollisions``. """ + + +@configclass +class NewtonArticulationCfg(ArticulationRootFragment): + """``newton:*`` articulation-root attributes for Newton. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) carrying + Newton-native self-collision control. It owns the ``NewtonArticulationRootAPI`` applied + schema. Composes with :class:`~isaaclab_physx.sim.schemas.PhysxArticulationCfg` in an + ``articulation_props`` fragment list; the ``UsdPhysics.ArticulationRootAPI`` anchor is + applied by :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonArticulationRootAPI" + + 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 the PhysX namespace + (:attr:`~isaaclab_physx.sim.schemas.PhysxArticulationCfg.enabled_self_collisions`). + """ diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-articulation.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-articulation.minor.rst new file mode 100644 index 00000000000..12b1a4211d5 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-articulation.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.schemas.PhysxArticulationCfg`, the ``physxArticulation:*`` + single-namespace articulation-root fragment (PhysX ``PhysxArticulationAPI``). It carries + ``articulation_enabled``, ``enabled_self_collisions``, solver position / velocity iteration + counts, and sleep / stabilization thresholds, and composes in an ``articulation_props`` fragment + list applied via :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 767ba36f06d..264c0e9e5c7 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -15,6 +15,7 @@ __all__ = [ "JointDrivePropertiesCfg", "MeshCollisionPropertiesCfg", "OmniPhysicsDeformableBodyPropertiesCfg", + "PhysxArticulationCfg", "PhysxArticulationRootPropertiesCfg", "PhysxCollisionCfg", "PhysxCollisionPropertiesCfg", @@ -51,6 +52,7 @@ from .schemas_cfg import ( JointDrivePropertiesCfg, MeshCollisionPropertiesCfg, OmniPhysicsDeformableBodyPropertiesCfg, + PhysxArticulationCfg, PhysxArticulationRootPropertiesCfg, PhysxCollisionCfg, PhysxCollisionPropertiesCfg, 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 c81ea84f710..1b5c9926425 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -10,6 +10,7 @@ from isaaclab.sim.schemas.schemas_cfg import ( ArticulationRootBaseCfg, + ArticulationRootFragment, CollisionBaseCfg, CollisionFragment, DeformableBodyPropertiesBaseCfg, @@ -536,6 +537,51 @@ class PhysxArticulationRootPropertiesCfg(ArticulationRootBaseCfg): """The mass-normalized kinetic energy threshold below which an articulation may participate in stabilization.""" +@configclass +class PhysxArticulationCfg(ArticulationRootFragment): + """``physxArticulation:*`` articulation-root attributes from `PhysxArticulationAPI`_. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) for the PhysX + articulation add-on schema. Applied alongside other articulation-root fragments via + :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`, which applies the + ``UsdPhysics.ArticulationRootAPI`` anchor (presence-gated). This fragment owns the + ``PhysxArticulationAPI`` applied schema. + + .. _PhysxArticulationAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_articulation_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physxArticulation" + _usd_applied_schema: ClassVar[str | None] = "PhysxArticulationAPI" + + articulation_enabled: bool | None = None + """Whether to enable or disable the articulation. + + PhysX honors this per-articulation at sim time via ``physxArticulation:articulationEnabled``: + setting False makes PhysX skip the articulation in its solver passes. + """ + + enabled_self_collisions: bool | None = None + """Whether self-collisions between bodies in the same articulation are enabled. + + Written to ``physxArticulation:enabledSelfCollisions``. The Newton-native counterpart is + :attr:`~isaaclab_newton.sim.schemas.NewtonArticulationCfg.self_collision_enabled` + (``newton:selfCollisionEnabled``). + """ + + solver_position_iteration_count: int | None = None + """Solver position iteration counts for the articulation.""" + + solver_velocity_iteration_count: int | None = None + """Solver velocity iteration counts for the articulation.""" + + sleep_threshold: float | None = None + """Mass-normalized kinetic energy threshold below which an actor may go to sleep [m²/s²].""" + + stabilization_threshold: float | None = None + """Mass-normalized kinetic energy threshold below which an articulation may participate in + stabilization [m²/s²].""" + + @configclass class ArticulationRootPropertiesCfg(PhysxArticulationRootPropertiesCfg): """Deprecated: use :class:`PhysxArticulationRootPropertiesCfg` or the solver-common base class. From 064ba8e7aed0254c1370b93d3d974e48a70c84ee Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Fri, 5 Jun 2026 12:37:05 -0700 Subject: [PATCH 017/139] docs(schemas): mark transition shim if/else for removal post-migration --- source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 38e6f36ddaf..b7a866d5f30 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -369,7 +369,7 @@ def _spawn_from_usd_file( # modify articulation root properties if cfg.articulation_props is not None: - # transition routing: new fragment list -> apply_*; legacy single cfg -> modify_* + # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> modify_* articulation_frags = ( cfg.articulation_props if isinstance(cfg.articulation_props, (list, tuple)) else [cfg.articulation_props] ) From af0c39281b5d4102ee2c05cf4880e639d8038245 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 19:51:31 -0700 Subject: [PATCH 018/139] fix(schemas): resolve articulation root before applying fragments apply_articulation_root_properties applied UsdPhysics.ArticulationRootAPI unconditionally on the input prim. USD assets author the root on a child prim (the root link / fixed joint), so this stamped a SECOND root on the top prim and wrote the fragment properties + fix_root_link logic to the wrong prim -- a duplicate root that also violates the single-root asset requirement. Descend the subtree to the existing root and tune it in place (matching the legacy @apply_nested modify_articulation_root_properties writer); only define a fresh root on the input prim when the subtree has none (primitive or programmatic spawns). Fragments and the fix_root_link reparent logic now target the resolved root. Also guard an invalid input prim path. Add a regression test covering the child-root case (verified to fail before the fix). --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 82 +++++++++++++++---- .../test/sim/test_articulation_fragments.py | 38 +++++++++ 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index d774028354b..9f98c837bc2 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -267,6 +267,32 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ +def _find_articulation_root_prim(prim: Usd.Prim) -> Usd.Prim | None: + """Return the first prim at or under ``prim`` that already has ``UsdPhysics.ArticulationRootAPI``. + + USD assets author the articulation root on a child prim (the root link / fixed joint), so the + search descends the subtree like the legacy ``@apply_nested`` writer. Nested articulation roots + are not allowed, so the search stops at the first match per branch. Instanced prims are skipped + (their prototypes cannot be authored on). Returns ``None`` when no prim in the subtree carries + the articulation root. + + Args: + prim: The root of the subtree to search. + + Returns: + The prim that owns ``UsdPhysics.ArticulationRootAPI``, or ``None`` if none is found. + """ + queue = [prim] + while queue: + current = queue.pop(0) + if current.IsInstance(): + continue + if current.HasAPI(UsdPhysics.ArticulationRootAPI): + return current + queue.extend(current.GetChildren()) + return None + + def apply_articulation_root_properties( prim_path: str, fragments, @@ -275,15 +301,23 @@ def apply_articulation_root_properties( ) -> bool: """Apply a list of articulation-root fragments to a prim. - Applies ``UsdPhysics.ArticulationRootAPI`` as the anchor (presence-gated: this writer only - runs when the ``articulation_props`` slot carries fragments), then dispatches each fragment - via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Backend fragments carry - backend-specific funcs, so core never imports a backend. Finally, if ``fix_root_link`` is not - ``None``, the world-to-root fixed-joint logic from the legacy - :func:`modify_articulation_root_properties` writer is applied. + Resolves the articulation root before writing: USD assets author + ``UsdPhysics.ArticulationRootAPI`` on a child prim (the root link / fixed joint), so this + writer descends the subtree under ``prim_path`` and tunes the existing root in place (matching + the legacy :func:`modify_articulation_root_properties` writer). Only when no prim in the + subtree carries the root does it apply ``UsdPhysics.ArticulationRootAPI`` on ``prim_path`` + itself (define-fresh, e.g. for primitive or programmatic spawns). This guarantees exactly one + articulation root rather than stamping a duplicate on the input prim. + + Each fragment is then dispatched to the resolved root via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Backend fragments carry backend-specific + funcs, so core never imports a backend. Finally, if ``fix_root_link`` is not ``None``, the + world-to-root fixed-joint logic from the legacy :func:`modify_articulation_root_properties` + writer is applied to the resolved root. Args: - prim_path: The prim path to apply the articulation-root schemas on. + prim_path: The prim path to search for the articulation root under (the root may be on a + descendant); the schemas are applied to the resolved root prim. fragments: An iterable of :class:`~isaaclab.sim.schemas.ArticulationRootFragment` instances. stage: The stage where to find the prim. Defaults to None, in which case the current stage is used. @@ -296,42 +330,54 @@ def apply_articulation_root_properties( True if the properties were successfully set. Raises: + ValueError: When the prim path is not valid. NotImplementedError: When the root prim is not a rigid body and a fixed joint is to be created. """ if stage is None: stage = get_current_stage() - articulation_prim = stage.GetPrimAtPath(prim_path) - # apply the defining anchor (presence-gated by the caller) - if not UsdPhysics.ArticulationRootAPI(articulation_prim): - UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) - # dispatch each fragment via its own applier (backend funcs live in backend packages) + input_prim = stage.GetPrimAtPath(prim_path) + # check if prim path is valid + if not input_prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + # USD assets author the ArticulationRootAPI on a child prim, and nested roots are not allowed, + # so tune the single existing root in place (matching the legacy @apply_nested writer) rather + # than stamping a duplicate on the input prim. Only define a fresh root on the input prim when + # the subtree has none (e.g. primitive or programmatic spawns). This keeps exactly one + # ArticulationRootAPI in the tree. + articulation_prim = _find_articulation_root_prim(input_prim) + if articulation_prim is None: + UsdPhysics.ArticulationRootAPI.Apply(input_prim) + articulation_prim = input_prim + root_path = articulation_prim.GetPath().pathString + # dispatch each fragment via its own applier to the resolved root prim (backend funcs live in + # backend packages, so core never imports a backend) for cfg in fragments: func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) - func(cfg, prim_path, stage) + func(cfg, root_path, stage) # fix root link based on input # we do the fixed joint processing later to not interfere with setting other properties. # this logic is reproduced from the legacy ``modify_articulation_root_properties`` writer. if fix_root_link is not None: - # check if a global fixed joint exists under the root prim - existing_fixed_joint_prim = find_global_fixed_joint_prim(prim_path) + # check if a global fixed joint exists under the resolved root prim + existing_fixed_joint_prim = find_global_fixed_joint_prim(root_path) # if we found a fixed joint, enable/disable it based on the input # otherwise, create a fixed joint between the world and the root link if existing_fixed_joint_prim is not None: logger.info( - f"Found an existing fixed joint for the articulation: '{prim_path}'. Setting it to: {fix_root_link}." + f"Found an existing fixed joint for the articulation: '{root_path}'. Setting it to: {fix_root_link}." ) existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) elif fix_root_link: - logger.info(f"Creating a fixed joint for the articulation: '{prim_path}'.") + logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") # note: we have to assume that the root prim is a rigid body, # i.e. we don't handle the case where the root prim is not a rigid body but has articulation api on it # Currently, there is no obvious way to get first rigid body link identified by the PhysX parser if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): raise NotImplementedError( - f"The articulation prim '{prim_path}' does not have the RigidBodyAPI applied." + f"The articulation prim '{root_path}' does not have the RigidBodyAPI applied." " To create a fixed joint, we need to determine the first rigid body link in" " the articulation tree. However, this is not implemented yet." ) diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index d6245b3dee2..e7105a812c4 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -115,6 +115,44 @@ def test_apply_articulation_root_properties_composes_namespaces(): assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True +# ------------------------------------------------------------------------------------- +# Regression: root on a CHILD prim (USD assets) must be tuned in place, not duplicated +# ------------------------------------------------------------------------------------- + + +def test_apply_articulation_root_properties_tunes_existing_child_root(): + """When the articulation root lives on a child prim (as in USD assets), the writer must tune + that existing root rather than stamp a second ``ArticulationRootAPI`` on the input (top) prim + -- a duplicate root mis-writes the properties and violates the 'exactly one root' invariant. + """ + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + top = _make_xform(stage, "/World/Asset") + child = _make_xform(stage, "/World/Asset/base") + UsdPhysics.ArticulationRootAPI.Apply(child) # asset already carries its root on a child prim + + apply_articulation_root_properties( + "/World/Asset", + [PhysxArticulationCfg(solver_position_iteration_count=8)], + stage, + ) + + # the existing child root is tuned ... + assert child.HasAPI(UsdPhysics.ArticulationRootAPI) + assert child.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 + # ... and NO duplicate root / stray write is added on the top prim + assert not top.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not top.GetAttribute("physxArticulation:solverPositionIterationCount").HasAuthoredValue() + # exactly one ArticulationRootAPI exists in the subtree + roots = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert len(roots) == 1 and roots[0] == child + + # ------------------------------------------------------------------------------------- # fix_root_link spawner-level flag: toggles an existing fixed joint # ------------------------------------------------------------------------------------- From e80c7aa609d61dce70e19783e6647e1cdf5ea4b1 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 19:53:45 -0700 Subject: [PATCH 019/139] refactor(schemas): reuse get_first_matching_child_prim for root lookup Replace the hand-rolled subtree BFS with the existing get_first_matching_child_prim query helper (predicate = HasAPI(ArticulationRootAPI)). Same find-or-define behavior; the helper also validates the path, so the bespoke helper and the separate IsValid guard are dropped. Pass traverse_instance_prims=False to keep parity with the legacy @apply_nested writer, which does not author through instances. --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 50 ++++++------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 9f98c837bc2..0f9d7c0deac 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -24,6 +24,7 @@ create_prim, find_global_fixed_joint_prim, get_all_matching_child_prims, + get_first_matching_child_prim, safe_set_attribute_on_usd_prim, safe_set_attribute_on_usd_schema, ) @@ -267,32 +268,6 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ -def _find_articulation_root_prim(prim: Usd.Prim) -> Usd.Prim | None: - """Return the first prim at or under ``prim`` that already has ``UsdPhysics.ArticulationRootAPI``. - - USD assets author the articulation root on a child prim (the root link / fixed joint), so the - search descends the subtree like the legacy ``@apply_nested`` writer. Nested articulation roots - are not allowed, so the search stops at the first match per branch. Instanced prims are skipped - (their prototypes cannot be authored on). Returns ``None`` when no prim in the subtree carries - the articulation root. - - Args: - prim: The root of the subtree to search. - - Returns: - The prim that owns ``UsdPhysics.ArticulationRootAPI``, or ``None`` if none is found. - """ - queue = [prim] - while queue: - current = queue.pop(0) - if current.IsInstance(): - continue - if current.HasAPI(UsdPhysics.ArticulationRootAPI): - return current - queue.extend(current.GetChildren()) - return None - - def apply_articulation_root_properties( prim_path: str, fragments, @@ -335,19 +310,22 @@ def apply_articulation_root_properties( """ if stage is None: stage = get_current_stage() - input_prim = stage.GetPrimAtPath(prim_path) - # check if prim path is valid - if not input_prim.IsValid(): - raise ValueError(f"Prim path '{prim_path}' is not valid.") # USD assets author the ArticulationRootAPI on a child prim, and nested roots are not allowed, # so tune the single existing root in place (matching the legacy @apply_nested writer) rather - # than stamping a duplicate on the input prim. Only define a fresh root on the input prim when - # the subtree has none (e.g. primitive or programmatic spawns). This keeps exactly one - # ArticulationRootAPI in the tree. - articulation_prim = _find_articulation_root_prim(input_prim) + # than stamping a duplicate on the input prim. ``get_first_matching_child_prim`` validates the + # path and descends depth-first; instance proxies are not traversed because the root cannot be + # authored onto a prototype's descendants. + articulation_prim = get_first_matching_child_prim( + prim_path, + lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), + stage, + traverse_instance_prims=False, + ) + # only define a fresh root on the input prim when the subtree has none (e.g. primitive or + # programmatic spawns). This keeps exactly one ArticulationRootAPI in the tree. if articulation_prim is None: - UsdPhysics.ArticulationRootAPI.Apply(input_prim) - articulation_prim = input_prim + articulation_prim = stage.GetPrimAtPath(prim_path) + UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) root_path = articulation_prim.GetPath().pathString # dispatch each fragment via its own applier to the resolved root prim (backend funcs live in # backend packages, so core never imports a backend) From 3fa37b858765136fdf24051c5ed34573a9fd40e2 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 19:55:13 -0700 Subject: [PATCH 020/139] style(schemas): trim verbose comments in articulation root writer The function docstring already explains the find-or-define rationale, so collapse the inline comments to terse intent. The fix_root_link block is left untouched -- it is reproduced verbatim from the legacy writer. --- source/isaaclab/isaaclab/sim/schemas/schemas.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 0f9d7c0deac..2b1988b61cf 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -310,25 +310,20 @@ def apply_articulation_root_properties( """ if stage is None: stage = get_current_stage() - # USD assets author the ArticulationRootAPI on a child prim, and nested roots are not allowed, - # so tune the single existing root in place (matching the legacy @apply_nested writer) rather - # than stamping a duplicate on the input prim. ``get_first_matching_child_prim`` validates the - # path and descends depth-first; instance proxies are not traversed because the root cannot be - # authored onto a prototype's descendants. + # tune the existing root in place (it may live on a child prim); instance proxies can't be + # authored on, so don't traverse them articulation_prim = get_first_matching_child_prim( prim_path, lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), stage, traverse_instance_prims=False, ) - # only define a fresh root on the input prim when the subtree has none (e.g. primitive or - # programmatic spawns). This keeps exactly one ArticulationRootAPI in the tree. + # no existing root in the subtree: define one on the input prim if articulation_prim is None: articulation_prim = stage.GetPrimAtPath(prim_path) UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) root_path = articulation_prim.GetPath().pathString - # dispatch each fragment via its own applier to the resolved root prim (backend funcs live in - # backend packages, so core never imports a backend) + # dispatch each fragment to the resolved root via its own applier for cfg in fragments: func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) func(cfg, root_path, stage) From 6892d5d63f940ea3227e3d24b0cd63f42dbf934a Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 8 Jun 2026 20:18:07 -0700 Subject: [PATCH 021/139] test(schemas): cover the fix_root_link create-new-joint path The create-new-joint branch of apply_articulation_root_properties was sim-unexercised (only the toggle-existing-joint case was tested). Add two tests: (1) fix_root_link=True with no existing joint creates a fixed joint and reparents the articulation root from the rigid-body root link to its parent; (2) fix_root_link=True on a non-rigid-body root raises NotImplementedError. --- .../test/sim/test_articulation_fragments.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index e7105a812c4..8f16c6ab057 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -12,6 +12,8 @@ """Rest everything follows.""" +import pytest + from pxr import UsdGeom, UsdPhysics import isaaclab.sim as sim_utils @@ -184,6 +186,67 @@ def test_apply_articulation_root_properties_toggles_existing_fixed_joint(): assert joint.GetJointEnabledAttr().Get() is False +# ------------------------------------------------------------------------------------- +# fix_root_link spawner-level flag: creates a fixed joint and reparents the root +# ------------------------------------------------------------------------------------- + + +def test_apply_articulation_root_properties_creates_fixed_joint_and_reparents_root(): + """fix_root_link=True with no existing fixed joint: a fixed joint is created and the + articulation root is moved from the rigid-body root link to its parent (PhysX parser + limitation -- a fixed joint on a rigid body is otherwise treated as a maximal-coordinate tree). + """ + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # parent xform + a rigid-body root link carrying the articulation root + _make_xform(stage, "/World/Robot") + root = _make_xform(stage, "/World/Robot/base") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + # no existing global fixed joint -> the writer must create one + assert not any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + + apply_articulation_root_properties( + "/World/Robot", + [PhysxArticulationCfg(articulation_enabled=True)], + stage, + fix_root_link=True, + ) + + parent = stage.GetPrimAtPath("/World/Robot") + # a fixed joint was created ... + assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + # ... and the articulation root was moved from the root link to its parent + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) + + +def test_apply_articulation_root_properties_fix_root_link_requires_rigid_body(): + """fix_root_link=True on a non-rigid-body root raises NotImplementedError: the writer cannot + determine the first rigid body link to anchor the fixed joint to.""" + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/Robot2") + UsdPhysics.ArticulationRootAPI.Apply(root) # articulation root but NOT a rigid body + with pytest.raises(NotImplementedError): + apply_articulation_root_properties( + "/World/Robot2", + [PhysxArticulationCfg(articulation_enabled=True)], + stage, + fix_root_link=True, + ) + + # ------------------------------------------------------------------------------------- # public imports # ------------------------------------------------------------------------------------- From 2754d55be46f80083a2a931a2c7a5667facc7295 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 26 Jun 2026 09:50:43 +0200 Subject: [PATCH 022/139] Migrate OVPhysX Articulation onto OvPhysxView (view series, part 2) (#6225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ### 🔗 Stacked on top of #6224 (Part 1 — `OvPhysxView`) > This PR depends on **#6224** and is stacked on top of it. It targets > `develop`, but because #6224 has not merged yet, the diff below includes the > Part 1 `OvPhysxView` commits **as well as** the Part 2 migration. **Please > review #6224 first.** Once #6224 merges, this branch will be rebased onto > `develop` and the diff will shrink to the articulation migration only. # Description Part 2 of the OVPhysX view-migration series. It migrates the OVPhysX **`Articulation`** onto the new `OvPhysxView` binding manager introduced in **#6224 (Part 1)**. The goal of the series is to *dogfood* `OvPhysxView` on the real asset classes to surface weaknesses before the rigid-object/collection/sensor migrations, and to consolidate the scattered `create_tensor_binding` / `binding.read` / `binding.write` calls behind one managed surface. ### What changed * `Articulation._initialize_impl` now builds a single `OvPhysxView(physx, pattern, device)` and populates it via `try_binding_for` (best-effort: tensor types that do not apply to these prims are skipped, the asset still raises if *no* binding could be created). All binding creation, caching, and the CPU/GPU device policy now live in the view. * `ArticulationData` is constructed from the view; its counts come from the view metadata, and both `_get_binding` helpers (asset + data container) delegate to `OvPhysxView.try_binding_for`. * **Every write now goes through `root_view.set_attribute(...)`** instead of calling `binding.write` on a binding obtained from the view — the ~70 property / state / target / wrench / tendon writes, including the per-step actuation path. This brings the view's read-only, device, dtype, and contiguity guards to the asset write path, which previously bypassed them. * **Every read now goes through `root_view.read_into(...)`.** `ArticulationData` drops its bespoke `_get_read_view` reinterpret cache; the view now caches the `float32` reinterpret per destination buffer (keeping the wheel's read cache warm) and derives the structured layout from the binding shape, so the transform / spatial-vector / scalar read helpers collapse to one implementation. CPU-only reads keep their pinned-host staging. * `Articulation.root_view` now returns the `OvPhysxView` instead of a raw `dict[TensorType, TensorBinding]` (**breaking** — see migration note below). * The articulation tests route through the new API (`root_view.get_attribute(...)`, `root_view.try_binding_for(...)`). ### Breaking change / migration `Articulation.root_view` returns an `OvPhysxView`, not a dict: * `root_view[tensor_type]` → `root_view.try_binding_for(tensor_type)` * `tensor_type in root_view` (availability) → `root_view.try_binding_for(tensor_type) is not None` * full-tensor read → `root_view.get_attribute(tensor_type)` ### Findings surfaced (the point of the exercise) * **CPU-only property writes work through `set_attribute`.** mass/COM/inertia/DOF limits/stiffness/… are CPU-resident even on a GPU sim, and `set_attribute` refuses to stage CPU↔GPU. This is fine because the asset *already* pre-stages those to pinned CPU buffers, so the source is on the binding's native (CPU) device and the no-stage policy is satisfied with no change. (An initial assumption that the device policy would block these turned out to be wrong — verified by the green `cuda:0` run.) * `read_into` allocates a fresh `float32` reinterpret view per call, which would defeat the wheel binding's internal read cache that the data container caches by `(tensor_type, ptr)`; hot-path reads were kept on the cached-view path. * Per-step writes re-resolve the binding through `set_attribute` (a dict lookup + cheap guards) rather than the previously cached binding handle; the cost is negligible because the pre-built `float32` write-view passes through the reinterpret unchanged. A "bound writer" handle on the view would remove even that lookup — a possible Part 1 follow-up. * The view rejects 0-count bindings (stricter than the old loop, which could store a phantom binding) — a correctness improvement. ## Type of change - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] This change requires a documentation update ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have added tests that prove my fix is effective or that my feature works (articulation tests route through the new API) - [x] I have updated the changelog (`source/isaaclab_ovphysx/changelog.d/`) - [ ] I have updated the documentation accordingly - [x] I have added my changes to the right `changelog.d` fragment(s) ### Testing Run kitless; cpu and cuda must be separate invocations (process-global device lock): ``` 101 passed, 4 xfailed # -k cpu 101 passed, 4 xfailed # -k cuda ``` --- ...r-feat-ovphysx_articulation_view.major.rst | 13 + .../assets/articulation/articulation.py | 385 +++++++++--------- .../assets/articulation/articulation_data.py | 203 +++------ .../test/mock_interfaces/__init__.py | 2 +- .../test/mock_interfaces/views/__init__.py | 2 +- .../views/mock_ovphysx_bindings.py | 137 +++++++ .../test/assets/test_articulation.py | 115 +++--- .../test/assets/test_articulation_helpers.py | 4 +- 8 files changed, 460 insertions(+), 401 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation_view.major.rst diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation_view.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation_view.major.rst new file mode 100644 index 00000000000..789881fe48f --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation_view.major.rst @@ -0,0 +1,13 @@ +Changed +^^^^^^^ + +* **Breaking:** :attr:`~isaaclab_ovphysx.assets.Articulation.root_view` now returns an + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw + ``dict`` mapping ``TensorType`` to ``TensorBinding``. The view owns all tensor-binding + creation, caching, reads, and writes for the articulation. Address bindings by attribute + name or ``TensorType`` member through + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.try_binding_for` / + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.get_attribute` rather than indexing the + dict, e.g. replace ``root_view[tensor_type]`` with + ``root_view.try_binding_for(tensor_type)`` and ``tensor_type in root_view`` with + ``root_view.try_binding_for(tensor_type) is not None``. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py index e61bec9b738..0ee81535a5b 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py @@ -32,6 +32,7 @@ from isaaclab_ovphysx.assets.kernels import _body_wrench_to_world from isaaclab_ovphysx.cloner import queue_ovphysx_replication from isaaclab_ovphysx.physics import OvPhysxManager +from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView from .articulation_data import ArticulationData from .kernels import ( @@ -86,9 +87,9 @@ def __init__(self, cfg: ArticulationCfg): """ super().__init__(cfg) queue_ovphysx_replication(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] = {} + # the binding manager is created in ``_initialize_impl``; it owns all + # TensorBinding creation, caching, and the CPU/GPU device policy. + self._root_view: OvPhysxView | None = None """ Properties @@ -148,20 +149,23 @@ def body_names(self) -> list[str]: return self._body_names @property - def root_view(self) -> dict[int, Any]: + def root_view(self) -> OvPhysxView: """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. + On OVPhysX this is an :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`: a + string-keyed binding manager over the per-tensor-type ``TensorBinding`` handles, + rather than the single opaque view object used by the PhysX and Newton backends. + Address attributes by their lowercased ``TensorType`` name (e.g. + ``root_view.get_attribute("articulation_dof_stiffness")``) or by the + :class:`~isaaclab_ovphysx.tensor_types.TensorType` member itself. For high-level + state access (instance counts, prim paths, transforms), prefer 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 + return self._root_view @property def instantaneous_wrench_composer(self) -> WrenchComposer: @@ -238,22 +242,21 @@ def write_data_to_sim(self) -> None: outputs=[self._wrench_buf], device=self._device, ) - binding = self._get_binding(TT.LINK_WRENCH) - if binding is not None: - binding.write(self._wrench_buf) + if self._get_binding(TT.LINK_WRENCH) is not None: + self._root_view.set_attribute(TT.LINK_WRENCH, self._wrench_buf) inst.reset() # apply actuator models self._apply_actuator_model() # 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) + if self._effort_write_view is not None: + self._root_view.set_attribute(TT.DOF_ACTUATION_FORCE, self._effort_write_view) # 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) - if self._vel_target_binding is not None: - self._vel_target_binding.write(self._vel_target_write_view) + if self._pos_target_write_view is not None: + self._root_view.set_attribute(TT.DOF_POSITION_TARGET, self._pos_target_write_view) + if self._vel_target_write_view is not None: + self._root_view.set_attribute(TT.DOF_VELOCITY_TARGET, self._vel_target_write_view) def update(self, dt: float) -> None: """Updates the simulation data. @@ -452,8 +455,7 @@ def write_root_link_pose_to_sim_index( # Let the data class handle the invalidation of pose-dependent properties. if not skip_forward: self.data._reset_pose() - binding = self._get_binding(TT.ROOT_POSE) - binding.write(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) + self._root_view.set_attribute(TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) def write_root_link_pose_to_sim_mask( self, @@ -492,8 +494,7 @@ def write_root_link_pose_to_sim_mask( # Let the data class handle the invalidation of pose-dependent properties. if not skip_forward: self.data._reset_pose() - binding = self._get_binding(TT.ROOT_POSE) - binding.write(self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) + self._root_view.set_attribute(TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) def write_root_com_pose_to_sim_index( self, @@ -533,8 +534,7 @@ def write_root_com_pose_to_sim_index( # Let the data class handle the invalidation of pose-dependent properties. if not skip_forward: self.data._reset_pose(from_link=False) - binding = self._get_binding(TT.ROOT_POSE) - binding.write(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) + self._root_view.set_attribute(TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) def write_root_com_pose_to_sim_mask( self, @@ -574,8 +574,7 @@ def write_root_com_pose_to_sim_mask( # Let the data class handle the invalidation of pose-dependent properties. if not skip_forward: self.data._reset_pose(from_link=False) - binding = self._get_binding(TT.ROOT_POSE) - binding.write(self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) + self._root_view.set_attribute(TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) def write_root_velocity_to_sim_index( self, @@ -676,8 +675,9 @@ def write_root_com_velocity_to_sim_index( # Let the data class handle the invalidation of velocity-dependent properties. if not skip_forward: self.data._reset_velocity() - binding = self._get_binding(TT.ROOT_VELOCITY) - binding.write(self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids) + self._root_view.set_attribute( + TT.ROOT_VELOCITY, self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids + ) def write_root_com_velocity_to_sim_mask( self, @@ -719,8 +719,9 @@ def write_root_com_velocity_to_sim_mask( # Let the data class handle the invalidation of velocity-dependent properties. if not skip_forward: self.data._reset_velocity() - binding = self._get_binding(TT.ROOT_VELOCITY) - binding.write(self.data._root_com_vel_w.data.view(wp.float32), mask=env_mask_wp) + self._root_view.set_attribute( + TT.ROOT_VELOCITY, self.data._root_com_vel_w.data.view(wp.float32), mask=env_mask_wp + ) def write_root_link_velocity_to_sim_index( self, @@ -768,8 +769,9 @@ def write_root_link_velocity_to_sim_index( # Let the data class handle the invalidation of velocity-dependent properties. if not skip_forward: self.data._reset_velocity(from_com=False) - binding = self._get_binding(TT.ROOT_VELOCITY) - binding.write(self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids) + self._root_view.set_attribute( + TT.ROOT_VELOCITY, self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids + ) def write_root_link_velocity_to_sim_mask( self, @@ -817,8 +819,9 @@ def write_root_link_velocity_to_sim_mask( # Let the data class handle the invalidation of velocity-dependent properties. if not skip_forward: self.data._reset_velocity(from_com=False) - binding = self._get_binding(TT.ROOT_VELOCITY) - binding.write(self.data._root_com_vel_w.data.view(wp.float32), mask=env_mask_wp) + self._root_view.set_attribute( + TT.ROOT_VELOCITY, self.data._root_com_vel_w.data.view(wp.float32), mask=env_mask_wp + ) def write_joint_position_to_sim_index( self, @@ -861,8 +864,7 @@ def write_joint_position_to_sim_index( if not skip_forward: self._data._reset_pose() self._data._reset_velocity() - binding = self._get_binding(TT.DOF_POSITION) - binding.write(self._data._joint_pos_buf.data, indices=env_ids) + self._root_view.set_attribute(TT.DOF_POSITION, self._data._joint_pos_buf.data, indices=env_ids) def write_joint_position_to_sim_mask( self, @@ -907,8 +909,7 @@ def write_joint_position_to_sim_mask( if not skip_forward: self._data._reset_pose() self._data._reset_velocity() - binding = self._get_binding(TT.DOF_POSITION) - binding.write(self._data._joint_pos_buf.data, mask=env_mask_wp) + self._root_view.set_attribute(TT.DOF_POSITION, self._data._joint_pos_buf.data, mask=env_mask_wp) def write_joint_velocity_to_sim_index( self, @@ -967,8 +968,7 @@ def write_joint_velocity_to_sim_index( self._data._joint_acc.timestamp = self._data._sim_timestamp if not skip_forward: self._data._reset_velocity() - binding = self._get_binding(TT.DOF_VELOCITY) - binding.write(self._data._joint_vel_buf.data, indices=env_ids) + self._root_view.set_attribute(TT.DOF_VELOCITY, self._data._joint_vel_buf.data, indices=env_ids) def write_joint_velocity_to_sim_mask( self, @@ -1029,8 +1029,7 @@ def write_joint_velocity_to_sim_mask( self._data._joint_acc.timestamp = self._data._sim_timestamp if not skip_forward: self._data._reset_velocity() - binding = self._get_binding(TT.DOF_VELOCITY) - binding.write(self._data._joint_vel_buf.data, mask=env_mask_wp) + self._root_view.set_attribute(TT.DOF_VELOCITY, self._data._joint_vel_buf.data, mask=env_mask_wp) def write_joint_state_to_sim_mask( self, @@ -1120,8 +1119,7 @@ def write_joint_stiffness_to_sim_index( ) 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) + self._root_view.set_attribute(TT.DOF_STIFFNESS, self.data._cpu_joint_stiffness, indices=cpu_env_ids) def write_joint_stiffness_to_sim_mask( self, @@ -1164,8 +1162,9 @@ def write_joint_stiffness_to_sim_mask( 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)) + self._root_view.set_attribute( + TT.DOF_STIFFNESS, self.data._cpu_joint_stiffness, mask=self._get_cpu_env_mask(env_mask_wp) + ) def write_joint_damping_to_sim_index( self, @@ -1208,8 +1207,7 @@ def write_joint_damping_to_sim_index( ) 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) + self._root_view.set_attribute(TT.DOF_DAMPING, self.data._cpu_joint_damping, indices=cpu_env_ids) def write_joint_damping_to_sim_mask( self, @@ -1252,8 +1250,9 @@ def write_joint_damping_to_sim_mask( 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)) + self._root_view.set_attribute( + TT.DOF_DAMPING, self.data._cpu_joint_damping, mask=self._get_cpu_env_mask(env_mask_wp) + ) def write_joint_position_limit_to_sim_index( self, @@ -1352,8 +1351,7 @@ def write_joint_position_limit_to_sim_index( 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) + self._root_view.set_attribute(TT.DOF_LIMIT, self.data._cpu_joint_position_limit, indices=cpu_env_ids) def write_joint_position_limit_to_sim_mask( self, @@ -1449,8 +1447,9 @@ def write_joint_position_limit_to_sim_mask( 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)) + self._root_view.set_attribute( + TT.DOF_LIMIT, self.data._cpu_joint_position_limit, mask=self._get_cpu_env_mask(env_mask_wp) + ) def write_joint_velocity_limit_to_sim_index( self, @@ -1493,8 +1492,7 @@ def write_joint_velocity_limit_to_sim_index( ) 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) + self._root_view.set_attribute(TT.DOF_MAX_VELOCITY, self.data._cpu_joint_velocity_limit, indices=cpu_env_ids) def write_joint_velocity_limit_to_sim_mask( self, @@ -1537,8 +1535,9 @@ def write_joint_velocity_limit_to_sim_mask( 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)) + self._root_view.set_attribute( + TT.DOF_MAX_VELOCITY, self.data._cpu_joint_velocity_limit, mask=self._get_cpu_env_mask(env_mask_wp) + ) def write_joint_effort_limit_to_sim_index( self, @@ -1581,8 +1580,7 @@ def write_joint_effort_limit_to_sim_index( ) 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) + self._root_view.set_attribute(TT.DOF_MAX_FORCE, self.data._cpu_joint_effort_limit, indices=cpu_env_ids) def write_joint_effort_limit_to_sim_mask( self, @@ -1625,8 +1623,9 @@ def write_joint_effort_limit_to_sim_mask( 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)) + self._root_view.set_attribute( + TT.DOF_MAX_FORCE, self.data._cpu_joint_effort_limit, mask=self._get_cpu_env_mask(env_mask_wp) + ) def write_joint_armature_to_sim_index( self, @@ -1669,8 +1668,7 @@ def write_joint_armature_to_sim_index( ) 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) + self._root_view.set_attribute(TT.DOF_ARMATURE, self.data._cpu_joint_armature, indices=cpu_env_ids) def write_joint_armature_to_sim_mask( self, @@ -1713,8 +1711,9 @@ def write_joint_armature_to_sim_mask( 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)) + self._root_view.set_attribute( + TT.DOF_ARMATURE, self.data._cpu_joint_armature, mask=self._get_cpu_env_mask(env_mask_wp) + ) def write_joint_friction_coefficient_to_sim_index( self, @@ -1788,8 +1787,7 @@ def write_joint_friction_coefficient_to_sim_index( 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) + self._root_view.set_attribute(TT.DOF_FRICTION_PROPERTIES, cpu_friction, indices=cpu_env_ids) def write_joint_friction_coefficient_to_sim_mask( self, @@ -1841,8 +1839,9 @@ def write_joint_friction_coefficient_to_sim_mask( 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)) + self._root_view.set_attribute( + TT.DOF_FRICTION_PROPERTIES, cpu_friction, mask=self._get_cpu_env_mask(env_mask_wp) + ) def write_joint_dynamic_friction_coefficient_to_sim_index( self, @@ -1902,8 +1901,7 @@ def write_joint_dynamic_friction_coefficient_to_sim_index( 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) + self._root_view.set_attribute(TT.DOF_FRICTION_PROPERTIES, cpu_friction, indices=cpu_env_ids) def write_joint_dynamic_friction_coefficient_to_sim_mask( self, @@ -1947,8 +1945,9 @@ def write_joint_dynamic_friction_coefficient_to_sim_mask( 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)) + self._root_view.set_attribute( + TT.DOF_FRICTION_PROPERTIES, cpu_friction, mask=self._get_cpu_env_mask(env_mask_wp) + ) def write_joint_viscous_friction_coefficient_to_sim_index( self, @@ -2009,8 +2008,7 @@ def write_joint_viscous_friction_coefficient_to_sim_index( 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) + self._root_view.set_attribute(TT.DOF_FRICTION_PROPERTIES, cpu_friction, indices=cpu_env_ids) def write_joint_viscous_friction_coefficient_to_sim_mask( self, @@ -2055,8 +2053,9 @@ def write_joint_viscous_friction_coefficient_to_sim_mask( 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)) + self._root_view.set_attribute( + TT.DOF_FRICTION_PROPERTIES, cpu_friction, mask=self._get_cpu_env_mask(env_mask_wp) + ) """ Operations - Setters. @@ -2100,8 +2099,7 @@ def set_masses_index( ) 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) + self._root_view.set_attribute(TT.BODY_MASS, self.data._cpu_body_mass, indices=cpu_env_ids) def set_masses_mask( self, @@ -2142,8 +2140,7 @@ def set_masses_mask( 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)) + self._root_view.set_attribute(TT.BODY_MASS, self.data._cpu_body_mass, mask=self._get_cpu_env_mask(env_mask_wp)) def set_coms_index( self, @@ -2186,8 +2183,7 @@ def set_coms_index( 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) + self._root_view.set_attribute(TT.BODY_COM_POSE, self.data._cpu_body_coms, indices=cpu_env_ids) def set_coms_mask( self, @@ -2231,8 +2227,9 @@ def set_coms_mask( 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)) + self._root_view.set_attribute( + TT.BODY_COM_POSE, self.data._cpu_body_coms, mask=self._get_cpu_env_mask(env_mask_wp) + ) def set_inertias_index( self, @@ -2272,8 +2269,7 @@ def set_inertias_index( ) 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) + self._root_view.set_attribute(TT.BODY_INERTIA, self.data._cpu_body_inertia, indices=cpu_env_ids) def set_inertias_mask( self, @@ -2314,8 +2310,9 @@ def set_inertias_mask( 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)) + self._root_view.set_attribute( + TT.BODY_INERTIA, self.data._cpu_body_inertia, mask=self._get_cpu_env_mask(env_mask_wp) + ) def set_joint_position_target_index( self, @@ -2354,8 +2351,7 @@ def set_joint_position_target_index( 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) + self._root_view.set_attribute(TT.DOF_POSITION_TARGET, self._data._joint_pos_target, indices=env_ids) def set_joint_position_target_mask( self, @@ -2391,8 +2387,7 @@ def set_joint_position_target_mask( 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) + self._root_view.set_attribute(TT.DOF_POSITION_TARGET, self._data._joint_pos_target, mask=env_mask_wp) def set_joint_velocity_target_index( self, @@ -2431,8 +2426,7 @@ def set_joint_velocity_target_index( 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) + self._root_view.set_attribute(TT.DOF_VELOCITY_TARGET, self._data._joint_vel_target, indices=env_ids) def set_joint_velocity_target_mask( self, @@ -2468,8 +2462,7 @@ def set_joint_velocity_target_mask( 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) + self._root_view.set_attribute(TT.DOF_VELOCITY_TARGET, self._data._joint_vel_target, mask=env_mask_wp) def set_joint_effort_target_index( self, @@ -2508,8 +2501,7 @@ def set_joint_effort_target_index( 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) + self._root_view.set_attribute(TT.DOF_ACTUATION_FORCE, self._data._joint_effort_target, indices=env_ids) def set_joint_effort_target_mask( self, @@ -2545,8 +2537,7 @@ def set_joint_effort_target_mask( 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) + self._root_view.set_attribute(TT.DOF_ACTUATION_FORCE, self._data._joint_effort_target, mask=env_mask_wp) """ Operations - Tendons. @@ -2591,8 +2582,9 @@ def set_fixed_tendon_stiffness_index( 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) + self._root_view.set_attribute( + TT.FIXED_TENDON_STIFFNESS, self._data._fixed_tendon_stiffness.data, indices=env_ids + ) def set_fixed_tendon_stiffness_mask( self, @@ -2635,8 +2627,9 @@ def set_fixed_tendon_stiffness_mask( 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) + self._root_view.set_attribute( + TT.FIXED_TENDON_STIFFNESS, self._data._fixed_tendon_stiffness.data, mask=env_mask_wp + ) def set_fixed_tendon_damping_index( self, @@ -2677,8 +2670,7 @@ def set_fixed_tendon_damping_index( 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) + self._root_view.set_attribute(TT.FIXED_TENDON_DAMPING, self._data._fixed_tendon_damping.data, indices=env_ids) def set_fixed_tendon_damping_mask( self, @@ -2721,8 +2713,7 @@ def set_fixed_tendon_damping_mask( 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) + self._root_view.set_attribute(TT.FIXED_TENDON_DAMPING, self._data._fixed_tendon_damping.data, mask=env_mask_wp) def set_fixed_tendon_limit_stiffness_index( self, @@ -2763,8 +2754,9 @@ def set_fixed_tendon_limit_stiffness_index( 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) + self._root_view.set_attribute( + TT.FIXED_TENDON_LIMIT_STIFFNESS, self._data._fixed_tendon_limit_stiffness.data, indices=env_ids + ) def set_fixed_tendon_limit_stiffness_mask( self, @@ -2807,8 +2799,9 @@ def set_fixed_tendon_limit_stiffness_mask( 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) + self._root_view.set_attribute( + TT.FIXED_TENDON_LIMIT_STIFFNESS, self._data._fixed_tendon_limit_stiffness.data, mask=env_mask_wp + ) def set_fixed_tendon_position_limit_index( self, @@ -2855,8 +2848,7 @@ def set_fixed_tendon_position_limit_index( device=self._device, copy=False, ) - binding = self._get_binding(TT.FIXED_TENDON_LIMIT) - binding.write(flat_src, indices=env_ids) + self._root_view.set_attribute(TT.FIXED_TENDON_LIMIT, flat_src, indices=env_ids) def set_fixed_tendon_position_limit_mask( self, @@ -2903,8 +2895,7 @@ def set_fixed_tendon_position_limit_mask( device=self._device, copy=False, ) - binding = self._get_binding(TT.FIXED_TENDON_LIMIT) - binding.write(flat_src, mask=env_mask_wp) + self._root_view.set_attribute(TT.FIXED_TENDON_LIMIT, flat_src, mask=env_mask_wp) def set_fixed_tendon_rest_length_index( self, @@ -2945,8 +2936,9 @@ def set_fixed_tendon_rest_length_index( 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) + self._root_view.set_attribute( + TT.FIXED_TENDON_REST_LENGTH, self._data._fixed_tendon_rest_length.data, indices=env_ids + ) def set_fixed_tendon_rest_length_mask( self, @@ -2989,8 +2981,9 @@ def set_fixed_tendon_rest_length_mask( 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) + self._root_view.set_attribute( + TT.FIXED_TENDON_REST_LENGTH, self._data._fixed_tendon_rest_length.data, mask=env_mask_wp + ) def set_fixed_tendon_offset_index( self, @@ -3031,8 +3024,7 @@ def set_fixed_tendon_offset_index( 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) + self._root_view.set_attribute(TT.FIXED_TENDON_OFFSET, self._data._fixed_tendon_offset.data, indices=env_ids) def set_fixed_tendon_offset_mask( self, @@ -3075,8 +3067,7 @@ def set_fixed_tendon_offset_mask( 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) + self._root_view.set_attribute(TT.FIXED_TENDON_OFFSET, self._data._fixed_tendon_offset.data, mask=env_mask_wp) def write_fixed_tendon_properties_to_sim_index( self, @@ -3108,9 +3099,8 @@ def write_fixed_tendon_properties_to_sim_index( (TT.FIXED_TENDON_REST_LENGTH, self._data._fixed_tendon_rest_length), (TT.FIXED_TENDON_OFFSET, self._data._fixed_tendon_offset), ): - binding = self._get_binding(tt) - if binding is not None: - binding.write(buf.data, indices=env_ids) + if self._get_binding(tt) is not None: + self._root_view.set_attribute(tt, 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: @@ -3121,7 +3111,7 @@ def write_fixed_tendon_properties_to_sim_index( device=self._device, copy=False, ) - binding.write(flat_src, indices=env_ids) + self._root_view.set_attribute(TT.FIXED_TENDON_LIMIT, flat_src, indices=env_ids) def write_fixed_tendon_properties_to_sim_mask( self, @@ -3143,9 +3133,8 @@ def write_fixed_tendon_properties_to_sim_mask( (TT.FIXED_TENDON_REST_LENGTH, self._data._fixed_tendon_rest_length), (TT.FIXED_TENDON_OFFSET, self._data._fixed_tendon_offset), ): - binding = self._get_binding(tt) - if binding is not None: - binding.write(buf.data, mask=env_mask_wp) + if self._get_binding(tt) is not None: + self._root_view.set_attribute(tt, buf.data, mask=env_mask_wp) binding = self._get_binding(TT.FIXED_TENDON_LIMIT) if binding is not None: flat_src = wp.array( @@ -3155,7 +3144,7 @@ def write_fixed_tendon_properties_to_sim_mask( device=self._device, copy=False, ) - binding.write(flat_src, mask=env_mask_wp) + self._root_view.set_attribute(TT.FIXED_TENDON_LIMIT, flat_src, mask=env_mask_wp) def set_spatial_tendon_stiffness_index( self, @@ -3197,8 +3186,9 @@ def set_spatial_tendon_stiffness_index( 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) + self._root_view.set_attribute( + TT.SPATIAL_TENDON_STIFFNESS, self._data._spatial_tendon_stiffness.data, indices=env_ids + ) def set_spatial_tendon_stiffness_mask( self, @@ -3241,8 +3231,9 @@ def set_spatial_tendon_stiffness_mask( 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) + self._root_view.set_attribute( + TT.SPATIAL_TENDON_STIFFNESS, self._data._spatial_tendon_stiffness.data, mask=env_mask_wp + ) def set_spatial_tendon_damping_index( self, @@ -3281,8 +3272,9 @@ def set_spatial_tendon_damping_index( 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) + self._root_view.set_attribute( + TT.SPATIAL_TENDON_DAMPING, self._data._spatial_tendon_damping.data, indices=env_ids + ) def set_spatial_tendon_damping_mask( self, @@ -3325,8 +3317,9 @@ def set_spatial_tendon_damping_mask( 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) + self._root_view.set_attribute( + TT.SPATIAL_TENDON_DAMPING, self._data._spatial_tendon_damping.data, mask=env_mask_wp + ) def set_spatial_tendon_limit_stiffness_index( self, @@ -3369,8 +3362,9 @@ def set_spatial_tendon_limit_stiffness_index( 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) + self._root_view.set_attribute( + TT.SPATIAL_TENDON_LIMIT_STIFFNESS, self._data._spatial_tendon_limit_stiffness.data, indices=env_ids + ) def set_spatial_tendon_limit_stiffness_mask( self, @@ -3413,8 +3407,9 @@ def set_spatial_tendon_limit_stiffness_mask( 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) + self._root_view.set_attribute( + TT.SPATIAL_TENDON_LIMIT_STIFFNESS, self._data._spatial_tendon_limit_stiffness.data, mask=env_mask_wp + ) def set_spatial_tendon_offset_index( self, @@ -3455,8 +3450,7 @@ def set_spatial_tendon_offset_index( 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) + self._root_view.set_attribute(TT.SPATIAL_TENDON_OFFSET, self._data._spatial_tendon_offset.data, indices=env_ids) def set_spatial_tendon_offset_mask( self, @@ -3499,8 +3493,9 @@ def set_spatial_tendon_offset_mask( 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) + self._root_view.set_attribute( + TT.SPATIAL_TENDON_OFFSET, self._data._spatial_tendon_offset.data, mask=env_mask_wp + ) def write_spatial_tendon_properties_to_sim_index( self, @@ -3527,9 +3522,8 @@ def write_spatial_tendon_properties_to_sim_index( (TT.SPATIAL_TENDON_LIMIT_STIFFNESS, self._data._spatial_tendon_limit_stiffness), (TT.SPATIAL_TENDON_OFFSET, self._data._spatial_tendon_offset), ): - binding = self._get_binding(tt) - if binding is not None: - binding.write(buf.data, indices=env_ids) + if self._get_binding(tt) is not None: + self._root_view.set_attribute(tt, buf.data, indices=env_ids) def write_spatial_tendon_properties_to_sim_mask( self, @@ -3545,9 +3539,8 @@ def write_spatial_tendon_properties_to_sim_mask( (TT.SPATIAL_TENDON_LIMIT_STIFFNESS, self._data._spatial_tendon_limit_stiffness), (TT.SPATIAL_TENDON_OFFSET, self._data._spatial_tendon_offset), ): - binding = self._get_binding(tt) - if binding is not None: - binding.write(buf.data, mask=env_mask_wp) + if self._get_binding(tt) is not None: + self._root_view.set_attribute(tt, buf.data, mask=env_mask_wp) """ Internal helper. @@ -3610,33 +3603,33 @@ def has_articulation_root_api(prim) -> bool: TT.BODY_COM_POSE, TT.BODY_INERTIA, ] + self._root_view = OvPhysxView(self._ovphysx, pattern=pattern, device=self._device) + # ``try_binding_for`` creates and caches each binding, returning ``None`` for tensor + # types that do not apply to these prims (so a minimal articulation that lacks some + # of these types is skipped rather than failing the whole init). for tt in eager_types: - 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) + self._root_view.try_binding_for(tt) - if not self._bindings: + if not self._root_view.available_attributes: raise RuntimeError( f"OVPhysX could not create any articulation bindings for pattern {pattern!r}. " f"Check that prim_path={self.cfg.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 - self._num_bodies = sample.body_count - self._is_fixed_base = sample.is_fixed_base - self._joint_names = list(sample.dof_names) - self._body_names = list(sample.body_names) + # read metadata from the view (any instantiated binding carries it) + self._num_instances = self._root_view.count + self._num_joints = self._root_view.dof_count + self._num_bodies = self._root_view.body_count + self._is_fixed_base = self._root_view.is_fixed_base + self._joint_names = list(self._root_view.dof_names) + self._body_names = list(self._root_view.body_names) # 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). + # eagerly create tendon bindings now that the counts are known, through the same + # view, so ArticulationData reads them via a cached lookup (no lazy callback). if self._num_fixed_tendons > 0: for tt in ( TT.FIXED_TENDON_STIFFNESS, @@ -3646,10 +3639,7 @@ def has_articulation_root_api(prim) -> bool: 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) + self._root_view.try_binding_for(tt) if self._num_spatial_tendons > 0: for tt in ( TT.SPATIAL_TENDON_STIFFNESS, @@ -3657,13 +3647,10 @@ def has_articulation_root_api(prim) -> bool: 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) + self._root_view.try_binding_for(tt) - # construct the data container; counts come from the bindings - self._data = ArticulationData(self._bindings, self._device) + # construct the data container; counts come from the view's bindings + self._data = ArticulationData(self._root_view, 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 @@ -3803,9 +3790,8 @@ def _process_tendons(self) -> None: self._fixed_tendon_names = [] self._spatial_tendon_names = [] - sample = next(iter(self._bindings.values())) - self._num_fixed_tendons = getattr(sample, "fixed_tendon_count", 0) - self._num_spatial_tendons = getattr(sample, "spatial_tendon_count", 0) + self._num_fixed_tendons = self._root_view.fixed_tendon_count + self._num_spatial_tendons = self._root_view.spatial_tendon_count if self._num_fixed_tendons > 0 or self._num_spatial_tendons > 0: stage_path = OvPhysxManager._stage_path @@ -3864,16 +3850,7 @@ def _get_binding(self, tensor_type: int): Returns: A TensorBinding object, or ``None`` if the binding could not be created. """ - 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 + return self._root_view.try_binding_for(tensor_type) def _resolve_joint_values(self, pattern_dict: dict[str, float], buffer: wp.array) -> None: """Resolve a ``{pattern: value}`` dict into a per-joint buffer. @@ -3920,6 +3897,9 @@ def _nst(self) -> int: def _invalidate_initialize_callback(self, event) -> None: """Invalidate the asset on simulation reset.""" super()._invalidate_initialize_callback(event) + # Drop the view (and the bindings it caches) on stop so a destroyed/stale binding is + # not held across the reset; ``_initialize_impl`` rebuilds a fresh view on the next play. + self._root_view = None """ Internal helpers -- Actuators. @@ -4185,9 +4165,10 @@ def _broadcast_scalar_to_2d( def _resolve_env_mask(self, env_mask: wp.array | None) -> wp.array: """Resolve an environment mask to a ``wp.bool`` array on ``self._device``. - 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. + OVPhysX (like Newton) writes through the view's ``set_attribute(mask=...)``, which + forwards to the binding's native masked write, so the mask is preserved end-to-end; + no ``torch.nonzero`` conversion is needed. ``None`` returns the pre-allocated + all-true mask. """ if env_mask is None: return self._ALL_TRUE_ENV_MASK @@ -4240,9 +4221,9 @@ def _resolve_spatial_tendon_mask(self, tendon_mask: wp.array | None) -> wp.array 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. + :paramref:`env_mask` is normally on ``self._device``; ``set_attribute(mask=...)`` + requires the mask on the binding's native 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 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 922c12edfaa..c0a660cd0ea 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py @@ -32,6 +32,7 @@ vec13f, ) from isaaclab_ovphysx.physics import OvPhysxManager +from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView from .kernels import _fd_joint_acc @@ -72,30 +73,28 @@ class ArticulationData(BaseArticulationData): __backend_name__: str = "ovphysx" """The name of the backend for the articulation data.""" - def __init__(self, bindings: dict[int, Any], device: str) -> None: + def __init__(self, view: OvPhysxView, device: str) -> None: """Initialize the articulation data container. Args: - 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. + view: The :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager + for this articulation. All counts (instances, bodies, DOFs, + fixed/spatial tendons) are derived from the view 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 - - # 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_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) + self._view = view + + # The view exposes the articulation metadata (instance count, dof_count, + # body_count, fixed/spatial tendon counts) read from any instantiated binding. + self.num_instances = view.count + self.num_bodies = view.body_count + self.num_joints = view.dof_count + self.num_fixed_tendons = view.fixed_tendon_count + self.num_spatial_tendons = view.spatial_tendon_count # private aliases used throughout _create_buffers and property bodies self._num_instances = self.num_instances self._num_bodies = self.num_bodies @@ -109,8 +108,6 @@ def __init__(self, bindings: dict[int, Any], device: str) -> None: 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 = {} # obtain gravity from the simulation configuration (fall back to standard # gravity when the simulation has not been configured yet, e.g. in unit tests) @@ -1553,35 +1550,37 @@ def _create_buffers(self) -> None: # noqa: C901 # 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. + def _binding_read(self, tensor_type: int, dst: wp.array) -> None: + """Refresh *dst* from the binding via the view, staging 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. + GPU-resident state bindings (pose, velocity, …) fill *dst* directly through + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.read_into`, which reinterprets a + structured *dst* and reuses that reinterpret across calls so the wheel's read cache + stays warm. CPU-only property bindings (mass, COM, limits, stiffness, …) are read + into a pinned-host staging buffer first (the view does not stage across devices), + then :func:`wp.copy` moves the data to the simulation device. 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) + self._view.read_into(tensor_type, dst) return - # Route through a lazily-allocated pinned-host staging buffer. + # Route through a lazily-allocated pinned-host staging buffer (read_into refuses to + # cross devices), then copy to the simulation device. staging = self._cpu_staging_buffers.get(tensor_type) if staging is None: - staging = wp.zeros(binding.shape, dtype=wp.float32, device="cpu", pinned=True) + staging = wp.zeros(self._view.binding_for(tensor_type).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. + self._view.read_into(tensor_type, staging) + # Build a flat float32 view of dst matching the staging's flat shape. if dst.dtype == wp.float32: view = dst else: view = wp.array( ptr=dst.ptr, - shape=binding.shape, + shape=staging.shape, dtype=wp.float32, device=str(dst.device), copy=False, @@ -1741,11 +1740,11 @@ def _read_cpu(tensor_type): ]: binding = self._get_binding(tt) if binding is not None: - self._binding_read(tt, binding, buf.data) + self._binding_read(tt, 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._binding_read(TT.FIXED_TENDON_LIMIT, 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). @@ -1759,7 +1758,7 @@ def _read_cpu(tensor_type): ]: binding = self._get_binding(tt) if binding is not None: - self._binding_read(tt, binding, buf.data) + self._binding_read(tt, buf.data) buf.timestamp = self._sim_timestamp def _pin_proxy_arrays(self) -> None: @@ -1920,135 +1919,47 @@ def _invalidate_initialize_callback(self, event) -> None: val.timestamp = -1.0 def _get_binding(self, tensor_type: int): - """Return the cached binding for :paramref:`tensor_type`, or ``None`` if absent. + """Return the binding for :paramref:`tensor_type`, or ``None`` if unavailable. - 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. - - For structured-dtype buffers (transformf, spatial_vectorf), the view - reinterprets the same GPU memory as a flat float32 array matching the - binding's shape. For plain float32 buffers, returns the array as-is. - - The returned view is cached so that ``binding.read(view)`` sees the - same object on every call, enabling the binding's internal read cache. + Delegates to :attr:`root_view`'s + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.try_binding_for`, which returns the + cached binding (creating it on first access) or ``None`` for tensor types that do + not apply to these prims. 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``. + The TensorBinding, or ``None`` if not available for these prims. """ - if not hasattr(self, "_read_view_cache"): - self._read_view_cache = {} - cache_key = (tensor_type, wp_array.ptr) - cached = self._read_view_cache.get(cache_key) - if cached is not None: - return cached - - binding = self._get_binding(tensor_type) - if binding is None: - self._read_view_cache[cache_key] = None - return None - - if floats_per_elem > 0: - view = wp.array( - ptr=wp_array.ptr, - shape=binding.shape, - dtype=wp.float32, - device=str(wp_array.device), - copy=False, - ) - else: - view = wp_array - - self._read_view_cache[cache_key] = view - return view + return self._view.try_binding_for(tensor_type) def _read_binding_into_buf(self, tensor_type: int, buf: TimestampedBuffer) -> None: - """Read from an ovphysx binding into a :class:`TimestampedBuffer`, skipping if fresh. + """Refresh *buf* from the matching binding via the view, skipping if fresh or absent. - Args: - tensor_type: TensorType key. - buf: Timestamped buffer to refresh. - """ - if buf.timestamp >= self._sim_timestamp: - return - view = self._get_read_view(tensor_type, buf.data) - if view is None: - return - 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. - - 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._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. + Reads route through :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.read_into`, which + derives the structured reinterpret from the binding shape (so transform, spatial-vector, + and scalar buffers all use the same path) and reuses that reinterpret across calls; + CPU-only property bindings are staged onto the simulation device inside + :meth:`_binding_read`. Args: tensor_type: TensorType key. - buf: Timestamped :class:`wp.spatial_vectorf` buffer to refresh. + buf: Timestamped buffer to refresh. """ if buf.timestamp >= self._sim_timestamp: return - view = self._get_read_view(tensor_type, buf.data, 6) - if view is None: + if self._get_binding(tensor_type) is None: return - self._get_binding(tensor_type).read(view) + self._binding_read(tensor_type, buf.data) buf.timestamp = self._sim_timestamp - 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 + # ``read_into`` derives the reinterpret from the binding shape and ``_binding_read`` handles + # CPU-only staging, so the transform / spatial-vector / scalar read paths are now identical; + # keep the distinct names as aliases for call-site readability. + _read_transform_binding = _read_binding_into_buf + _read_spatial_vector_binding = _read_binding_into_buf + _read_scalar_binding = _read_binding_into_buf def _get_pos_from_transform(self, transform: wp.array) -> wp.array: """Return a position view aliased into a transform array. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/test/mock_interfaces/__init__.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/test/mock_interfaces/__init__.py index 2c6c0e67ffc..a1b56269d5a 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/test/mock_interfaces/__init__.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/test/mock_interfaces/__init__.py @@ -3,4 +3,4 @@ # # SPDX-License-Identifier: BSD-3-Clause -from .views import MockTensorBinding, MockOvPhysxBindingSet +from .views import MockOvPhysxBindingSet, MockOvPhysxView, MockTensorBinding diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/test/mock_interfaces/views/__init__.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/test/mock_interfaces/views/__init__.py index 6f133a18202..b15f3248379 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/test/mock_interfaces/views/__init__.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/test/mock_interfaces/views/__init__.py @@ -3,4 +3,4 @@ # # SPDX-License-Identifier: BSD-3-Clause -from .mock_ovphysx_bindings import MockTensorBinding, MockOvPhysxBindingSet +from .mock_ovphysx_bindings import MockOvPhysxBindingSet, MockOvPhysxView, MockTensorBinding 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 51e96d9bb42..603e9f448d6 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 @@ -158,6 +158,129 @@ def set_random_data(self, low: float = -1.0, high: float = 1.0) -> None: self._data = np.random.uniform(low, high, self._shape).astype(np.float32) +class MockOvPhysxView: + """Mock of :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` over a dict of + :class:`MockTensorBinding`. + + Lets a unit test inject a working ``_root_view`` into an OVPhysX asset/data class + without standing up the real view, a real ``PhysX``, or a USD stage. It mirrors the + consumed surface of ``OvPhysxView`` -- ``binding_for`` / ``try_binding_for``, + ``get_attribute`` / ``read_into`` / ``set_attribute``, the discoverability helpers, + and the metadata passthrough -- delegating reads/writes to the mock bindings, which + already implement ``read``/``write`` against numpy buffers. + + Names resolve like the real view: pass either a :class:`TensorType` member or its + lowercased name (e.g. ``"articulation_dof_stiffness"``). Unlike the real view this + keeps everything on CPU and applies no device/dtype/read-only guards -- it is a test + double for the binding-routing surface, not the device policy. + """ + + def __init__(self, bindings: dict[int, MockTensorBinding]): + self._bindings = bindings + + def _resolve(self, name): + """Resolve a TensorType member or its lowercased name to the member.""" + if isinstance(name, str): + enum_cls = type(next(iter(self._bindings))) + try: + return enum_cls[name.upper()] + except KeyError: + raise KeyError(f"Unknown attribute {name!r}") from None + return name + + def _sample(self) -> MockTensorBinding: + """A representative binding to read shared metadata from.""" + return next(iter(self._bindings.values())) + + # -- core read / write ----------------------------------------------------- + + def get_attribute(self, name, *, out=None): + """Read the full attribute tensor; fill ``out`` if given, else allocate float32.""" + binding = self._bindings[self._resolve(name)] + if out is not None: + binding.read(out) + return out + import warp as wp + + buf = wp.zeros(tuple(binding.shape), dtype=wp.float32, device="cpu") + binding.read(buf) + return buf + + def read_into(self, name, dst) -> None: + """Fill ``dst`` in place from the attribute binding.""" + self._bindings[self._resolve(name)].read(dst) + + def set_attribute(self, name, values, *, indices=None, mask=None) -> None: + """Write a full attribute tensor; ``indices``/``mask`` select which rows apply.""" + self._bindings[self._resolve(name)].write(values, indices=indices, mask=mask) + + # -- raw binding access ---------------------------------------------------- + + def binding_for(self, name) -> MockTensorBinding: + """Return the underlying binding, raising ``KeyError`` if absent for these prims.""" + return self._bindings[self._resolve(name)] + + def try_binding_for(self, name) -> MockTensorBinding | None: + """Like :meth:`binding_for`, but return ``None`` when the attribute is absent.""" + return self._bindings.get(self._resolve(name)) + + # -- discoverability ------------------------------------------------------- + + def has_attribute(self, name) -> bool: + """Return whether a binding is instantiated for ``name`` on these prims.""" + return self._resolve(name) in self._bindings + + def __contains__(self, name) -> bool: + return self.has_attribute(name) + + @property + def available_attributes(self) -> list[str]: + """Names with a live binding (lowercased ``TensorType`` members).""" + return sorted(tt.name.lower() for tt in self._bindings) + + # -- metadata passthrough (from a sample binding) -------------------------- + + @property + def count(self) -> int: + return self._sample().count + + @property + def dof_count(self) -> int: + return self._sample().dof_count + + @property + def body_count(self) -> int: + return self._sample().body_count + + @property + def joint_count(self) -> int: + return self._sample().joint_count + + @property + def is_fixed_base(self) -> bool: + return self._sample().is_fixed_base + + @property + def dof_names(self) -> list[str]: + return list(self._sample().dof_names) + + @property + def body_names(self) -> list[str]: + return list(self._sample().body_names) + + @property + def joint_names(self) -> list[str]: + return list(self._sample().joint_names) + + @property + def fixed_tendon_count(self) -> int: + return self._sample().fixed_tendon_count + + @property + def spatial_tendon_count(self) -> int: + return self._sample().spatial_tendon_count + + class MockOvPhysxBindingSet: """Factory that creates a full set of MockTensorBinding objects for a given articulation configuration. @@ -307,6 +430,20 @@ def __init__( } ) + @property + def view(self) -> MockOvPhysxView: + """A mock :class:`OvPhysxView` over this set's bindings. + + Inject as an asset's ``_root_view`` to exercise the migrated binding-routing + code paths without a real view or ``PhysX``. Cached so repeated access returns + the same object, like the single view an asset holds. + """ + v = getattr(self, "_view", None) + if v is None: + v = MockOvPhysxView(self.bindings) + self._view = v + return v + def set_random_data(self) -> None: """Fill all bindings with random data.""" for b in self.bindings.values(): diff --git a/source/isaaclab_ovphysx/test/assets/test_articulation.py b/source/isaaclab_ovphysx/test/assets/test_articulation.py index 76df62e1abb..6a34225ba17 100644 --- a/source/isaaclab_ovphysx/test/assets/test_articulation.py +++ b/source/isaaclab_ovphysx/test/assets/test_articulation.py @@ -19,11 +19,14 @@ 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``). +adapted to OVPhysX by going through +:attr:`~isaaclab_ovphysx.assets.Articulation.root_view`, an +:class:`~isaaclab_ovphysx.sim.views.OvPhysxView` over the per-tensor-type bindings +(``root_view.get_attribute(tensor_type)`` / +: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 -------------------------- @@ -51,7 +54,6 @@ import sys -import numpy as np import pytest import torch import warp as wp @@ -94,26 +96,25 @@ _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()`` / " + "ovphysx wheel side. ``Articulation.root_view`` is an ``OvPhysxView`` over " + "the per-tensor-type bindings 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*. + """Read an OVPhysX attribute 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*. + accessors; on OVPhysX we go through the equivalent + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.get_attribute`, which returns a + freshly allocated ``float32`` array on the attribute's native device (CPU for + CPU-only property types), then 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) + arr = articulation.root_view.get_attribute(tensor_type) + return wp.to_torch(arr).to(device) # Session-locked device. Set on the first parametrized test that runs and @@ -365,15 +366,17 @@ def test_initialization_floating_base_non_root(sim, num_articulations, device, a # 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 + # ``root_view`` is an ``OvPhysxView`` over the per-tensor-type bindings, 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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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. @@ -428,15 +431,17 @@ def test_initialization_floating_base(sim, num_articulations, device, add_ground # 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 + # ``root_view`` is an ``OvPhysxView`` over the per-tensor-type bindings, 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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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. @@ -490,15 +495,17 @@ def test_initialization_fixed_base(sim, num_articulations, device): # 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 + # ``root_view`` is an ``OvPhysxView`` over the per-tensor-type bindings, 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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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. @@ -561,15 +568,17 @@ def test_initialization_fixed_base_single_joint(sim, num_articulations, device, # 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 + # ``root_view`` is an ``OvPhysxView`` over the per-tensor-type bindings, 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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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. @@ -634,11 +643,13 @@ def test_initialization_hand_with_tendons(sim, num_articulations, device): # 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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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) @@ -690,15 +701,17 @@ def test_initialization_floating_base_made_fixed_base(sim, num_articulations, de # 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 + # ``root_view`` is an ``OvPhysxView`` over the per-tensor-type bindings, 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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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. @@ -756,15 +769,17 @@ def test_initialization_fixed_base_made_floating_base(sim, num_articulations, de # 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 + # ``root_view`` is an ``OvPhysxView`` over the per-tensor-type bindings, 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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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 + binding = articulation.root_view.try_binding_for(tt) + if binding is not None: + assert binding.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. diff --git a/source/isaaclab_ovphysx/test/assets/test_articulation_helpers.py b/source/isaaclab_ovphysx/test/assets/test_articulation_helpers.py index da85e53facb..1356daec70d 100644 --- a/source/isaaclab_ovphysx/test/assets/test_articulation_helpers.py +++ b/source/isaaclab_ovphysx/test/assets/test_articulation_helpers.py @@ -84,7 +84,9 @@ def _make_articulation_shell() -> Articulation: num_fixed_tendons=1, num_spatial_tendons=1, ) - object.__setattr__(articulation, "_bindings", bindings.bindings) + # The migrated Articulation reads tendon counts off its OvPhysxView; inject the mock + # view over these bindings so the metadata passthrough resolves without a real view. + object.__setattr__(articulation, "_root_view", bindings.view) 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) From 713ddcfb7dcc3f2465c22c36f9d19b985c5d92de Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 26 Jun 2026 13:56:11 +0200 Subject: [PATCH 023/139] Cache PhysX articulation COM offsets (#6268) ## Summary - cache PhysX body-frame COM offsets as model properties instead of re-reading them every sim timestamp - invalidate all PhysX COM-offset-dependent cached buffers after set_coms_index writes - apply the same model-property cache policy and dependent invalidation to OVPhysX articulation COM offsets - add PhysX and OVPhysX regression tests for timestamp reuse, setter cache updates, joint writes, partial writes, and dependent invalidation - add changelog fragments for the touched packages This intentionally keeps the scope to the cached-property fix; it does not include PR #6260's local-only composition fast path. ## Profiling - G1 PhysX, 4096 env quick profile before the dependent-invalidation follow-up: compose_to_body_frame dropped from ~7.25 ms to ~0.24 ms - after the cache fix, normal step time was 69.20 ms vs 68.76 ms with compose skipped in the same quick profile - full-run wall timings were noisy on the laptop/GPU, likely thermal throttling, but no longer showed the pre-fix COM-read cliff ## Test Plan - python3 tools/changelog/cli.py check develop - pre-commit run --from-ref origin/develop --to-ref HEAD - git diff --check origin/develop...HEAD - ./isaaclab.sh -p -m py_compile source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py source/isaaclab/test/assets/test_articulation_iface.py source/isaaclab_ovphysx/test/assets/test_articulation.py - ./isaaclab.sh -p -m pytest source/isaaclab/test/assets/test_articulation_iface.py -k 'physx_set_coms_index_invalidates_body_com_pose_b_dependents or physx_body_com_pose_b_is_cached_across_sim_timestamps or physx_set_coms_index_updates_body_com_pose_b_cache or physx_joint_position_write_preserves_body_com_pose_b_cache or physx_partial_set_coms_index_initializes_cold_body_com_pose_b_cache' -q - ./isaaclab.sh -p -m pytest source/isaaclab_ovphysx/test/assets/test_articulation.py -k 'body_com_pose_b_cache_and_set_coms_invalidation' -q --- .../isaaclab/changelog.d/physx-com-cache.skip | 1 + .../test/assets/test_articulation_iface.py | 127 ++++++++++++++++++ .../changelog.d/physx-com-cache.rst | 5 + .../assets/articulation/articulation.py | 10 +- .../assets/articulation/articulation_data.py | 26 +++- .../test/assets/test_articulation.py | 45 +++++++ .../changelog.d/physx-com-cache.rst | 5 + .../assets/articulation/articulation.py | 7 + .../assets/articulation/articulation_data.py | 30 ++++- .../test/assets/test_articulation.py | 10 +- 10 files changed, 253 insertions(+), 13 deletions(-) create mode 100644 source/isaaclab/changelog.d/physx-com-cache.skip create mode 100644 source/isaaclab_ovphysx/changelog.d/physx-com-cache.rst create mode 100644 source/isaaclab_physx/changelog.d/physx-com-cache.rst diff --git a/source/isaaclab/changelog.d/physx-com-cache.skip b/source/isaaclab/changelog.d/physx-com-cache.skip new file mode 100644 index 00000000000..66ddcc4fedd --- /dev/null +++ b/source/isaaclab/changelog.d/physx-com-cache.skip @@ -0,0 +1 @@ +Tests only; user-facing fragments live in the backend packages. diff --git a/source/isaaclab/test/assets/test_articulation_iface.py b/source/isaaclab/test/assets/test_articulation_iface.py index 8dbf19291c8..989cfa4667f 100644 --- a/source/isaaclab/test/assets/test_articulation_iface.py +++ b/source/isaaclab/test/assets/test_articulation_iface.py @@ -1097,6 +1097,133 @@ def test_body_com_pose_b(self, backend, num_instances, num_joints, num_bodies, d name="body_com_pose_b", ) + @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") + def test_physx_body_com_pose_b_is_cached_across_sim_timestamps(self): + art, view = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") + + num_get_coms_calls = 0 + get_coms = view.get_coms + + def counted_get_coms(): + nonlocal num_get_coms_calls + num_get_coms_calls += 1 + return get_coms() + + view.get_coms = counted_get_coms + + art.data.update(dt=0.01) + art.data.body_com_pose_b + assert num_get_coms_calls == 1 + + art.data.update(dt=0.01) + art.data.body_com_pose_b + assert num_get_coms_calls == 1 + + @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") + def test_physx_set_coms_index_updates_body_com_pose_b_cache(self): + art, view = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") + + num_get_coms_calls = 0 + get_coms = view.get_coms + + def counted_get_coms(): + nonlocal num_get_coms_calls + num_get_coms_calls += 1 + return get_coms() + + view.get_coms = counted_get_coms + + coms = wp.zeros((art.num_instances, art.num_bodies), dtype=wp.transformf, device="cpu") + art.set_coms_index(coms=coms, full_data=True) + art.data.body_com_pose_b + + assert num_get_coms_calls == 0 + + @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") + def test_physx_joint_position_write_preserves_body_com_pose_b_cache(self): + art, view = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") + + num_get_coms_calls = 0 + get_coms = view.get_coms + + def counted_get_coms(): + nonlocal num_get_coms_calls + num_get_coms_calls += 1 + return get_coms() + + view.get_coms = counted_get_coms + + art.data.update(dt=0.01) + art.data.body_com_pose_b + assert num_get_coms_calls == 1 + + joint_pos = torch.zeros((art.num_instances, art.num_joints), device="cpu") + art.write_joint_position_to_sim_index(position=joint_pos, full_data=True) + art.data.body_com_pose_b + + assert num_get_coms_calls == 1 + + @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") + def test_physx_partial_set_coms_index_initializes_cold_body_com_pose_b_cache(self): + art, view = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") + initial_coms = view.get_coms().numpy().copy() + + num_get_coms_calls = 0 + get_coms = view.get_coms + + def counted_get_coms(): + nonlocal num_get_coms_calls + num_get_coms_calls += 1 + return get_coms() + + view.get_coms = counted_get_coms + + coms = wp.zeros((1, 1), dtype=wp.transformf, device="cpu") + art.set_coms_index( + coms=coms, + env_ids=wp.array([0], dtype=wp.int32, device="cpu"), + body_ids=wp.array([0], dtype=wp.int32, device="cpu"), + ) + body_com_pose_b = art.data.body_com_pose_b.torch + + assert num_get_coms_calls == 1 + torch.testing.assert_close(body_com_pose_b[1, 1], torch.from_numpy(initial_coms[1, 1])) + + @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") + def test_physx_set_coms_index_invalidates_body_com_pose_b_dependents(self): + art, _ = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") + + art.data.update(dt=0.01) + dependent_buffers = [ + ("root_com_pose_w", art.data._root_com_pose_w), + ("root_com_vel_w", art.data._root_com_vel_w), + ("root_link_vel_w", art.data._root_link_vel_w), + ("body_com_pose_w", art.data._body_com_pose_w), + ("body_com_vel_w", art.data._body_com_vel_w), + ("body_link_vel_w", art.data._body_link_vel_w), + ("root_link_lin_vel_b", art.data._root_link_lin_vel_b), + ("root_link_ang_vel_b", art.data._root_link_ang_vel_b), + ("root_com_lin_vel_b", art.data._root_com_lin_vel_b), + ("root_com_ang_vel_b", art.data._root_com_ang_vel_b), + ("root_state_w", art.data._root_state_w), + ("root_link_state_w", art.data._root_link_state_w), + ("root_com_state_w", art.data._root_com_state_w), + ("body_state_w", art.data._body_state_w), + ("body_link_state_w", art.data._body_link_state_w), + ("body_com_state_w", art.data._body_com_state_w), + ("body_com_jacobian_w", art.data._body_com_jacobian_w), + ("mass_matrix", art.data._mass_matrix), + ("gravity_compensation_forces", art.data._gravity_compensation_forces), + ] + for _, buffer in dependent_buffers: + buffer.timestamp = art.data._sim_timestamp + + coms = wp.zeros((art.num_instances, art.num_bodies), dtype=wp.transformf, device="cpu") + art.set_coms_index(coms=coms, full_data=True) + + for name, buffer in dependent_buffers: + assert buffer.timestamp < art.data._sim_timestamp, name + @_backends @_default_dims @_default_devices diff --git a/source/isaaclab_ovphysx/changelog.d/physx-com-cache.rst b/source/isaaclab_ovphysx/changelog.d/physx-com-cache.rst new file mode 100644 index 00000000000..e5b79b26c5c --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/physx-com-cache.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed repeated OVPhysX articulation body-frame center-of-mass pose reads by caching them as model + properties and invalidating dependent buffers when center-of-mass offsets are updated. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py index 0ee81535a5b..1c7566a55ae 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py @@ -2178,9 +2178,8 @@ def set_coms_index( 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 + self._data._body_com_pose_b.timestamp = self._data._sim_timestamp + self._data._reset_body_com_pose_b_dependents() cpu_env_ids = self._get_cpu_env_ids(env_ids) wp.copy(self.data._cpu_body_coms, self._data._body_com_pose_b.data) self._root_view.set_attribute(TT.BODY_COM_POSE, self.data._cpu_body_coms, indices=cpu_env_ids) @@ -2223,9 +2222,8 @@ def set_coms_mask( 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 + self._data._body_com_pose_b.timestamp = self._data._sim_timestamp + self._data._reset_body_com_pose_b_dependents() wp.copy(self.data._cpu_body_coms, self._data._body_com_pose_b.data) self._root_view.set_attribute( TT.BODY_COM_POSE, self.data._cpu_body_coms, mask=self._get_cpu_env_mask(env_mask_wp) 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 c0a660cd0ea..b3d485e26a9 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py @@ -245,6 +245,29 @@ def _reset_velocity(self, from_com: bool = True) -> None: # Force a kinematic refresh on the next FK-dependent read. self._fk_timestamp = -1.0 + def _reset_body_com_pose_b_dependents(self) -> None: + """Reset cached properties derived from body-frame center-of-mass offsets.""" + reset_timestamps( + [ + self._root_com_pose_w, + self._root_com_vel_w, + self._root_link_vel_w, + self._body_com_pose_w, + self._body_com_vel_w, + self._body_link_vel_w, + self._root_link_lin_vel_b, + self._root_link_ang_vel_b, + self._root_com_lin_vel_b, + self._root_com_ang_vel_b, + self._root_state_w_buf, + self._root_link_state_w_buf, + self._root_com_state_w_buf, + self._body_state_w_buf, + self._body_link_state_w_buf, + self._body_com_state_w_buf, + ] + ) + """ Names. """ @@ -974,7 +997,8 @@ def body_com_pose_b(self) -> ProxyArray: 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. """ - self._read_transform_binding(TT.BODY_COM_POSE, self._body_com_pose_b) + if self._body_com_pose_b.timestamp < 0.0: + self._read_transform_binding(TT.BODY_COM_POSE, self._body_com_pose_b) 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 diff --git a/source/isaaclab_ovphysx/test/assets/test_articulation.py b/source/isaaclab_ovphysx/test/assets/test_articulation.py index 6a34225ba17..39c22080f53 100644 --- a/source/isaaclab_ovphysx/test/assets/test_articulation.py +++ b/source/isaaclab_ovphysx/test/assets/test_articulation.py @@ -1993,6 +1993,51 @@ def test_write_root_state(sim, num_articulations, device, with_offset, state_loc torch.testing.assert_close(rand_state[..., 7:], articulation.data.root_link_vel_w.torch) +@pytest.mark.parametrize("device", ["cpu"]) +def test_body_com_pose_b_cache_and_set_coms_invalidation(sim, device): + """Body-frame COM offsets stay cached and invalidate derived buffers after writes.""" + sim._app_control_on_stop_handle = None + articulation_cfg = generate_articulation_cfg(articulation_type="single_joint_implicit") + articulation, _ = generate_articulation(articulation_cfg, 2, device=device) + + sim.reset() + articulation.update(sim.cfg.dt) + + articulation.data.body_com_pose_b + first_timestamp = articulation.data._body_com_pose_b.timestamp + articulation.update(sim.cfg.dt) + articulation.data.body_com_pose_b + assert articulation.data._body_com_pose_b.timestamp == first_timestamp + + dependent_buffers = [ + ("root_com_pose_w", articulation.data._root_com_pose_w), + ("root_com_vel_w", articulation.data._root_com_vel_w), + ("root_link_vel_w", articulation.data._root_link_vel_w), + ("body_com_pose_w", articulation.data._body_com_pose_w), + ("body_com_vel_w", articulation.data._body_com_vel_w), + ("body_link_vel_w", articulation.data._body_link_vel_w), + ("root_link_lin_vel_b", articulation.data._root_link_lin_vel_b), + ("root_link_ang_vel_b", articulation.data._root_link_ang_vel_b), + ("root_com_lin_vel_b", articulation.data._root_com_lin_vel_b), + ("root_com_ang_vel_b", articulation.data._root_com_ang_vel_b), + ("root_state_w", articulation.data._root_state_w_buf), + ("root_link_state_w", articulation.data._root_link_state_w_buf), + ("root_com_state_w", articulation.data._root_com_state_w_buf), + ("body_state_w", articulation.data._body_state_w_buf), + ("body_link_state_w", articulation.data._body_link_state_w_buf), + ("body_com_state_w", articulation.data._body_com_state_w_buf), + ] + for _, buffer in dependent_buffers: + buffer.timestamp = articulation.data._sim_timestamp + + coms = wp.zeros((articulation.num_instances, articulation.num_bodies), dtype=wp.transformf, device=device) + articulation.set_coms_index(coms=coms) + + assert articulation.data._body_com_pose_b.timestamp == articulation.data._sim_timestamp + for name, buffer in dependent_buffers: + assert buffer.timestamp < articulation.data._sim_timestamp, name + + @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_setting_articulation_root_prim_path(sim, device): """Test that the articulation root prim path can be set explicitly.""" diff --git a/source/isaaclab_physx/changelog.d/physx-com-cache.rst b/source/isaaclab_physx/changelog.d/physx-com-cache.rst new file mode 100644 index 00000000000..bda2f58ef8e --- /dev/null +++ b/source/isaaclab_physx/changelog.d/physx-com-cache.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed repeated PhysX articulation body-frame center-of-mass pose reads by caching them as model + properties and invalidating dependent buffers when center-of-mass offsets are updated. diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py index 9d0aa641ba9..39d9604e392 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py @@ -2357,6 +2357,11 @@ def set_coms_index( self.assert_shape_and_dtype(coms, (self.num_instances, self.num_bodies), wp.transformf, "coms") else: self.assert_shape_and_dtype(coms, (env_ids.shape[0], body_ids.shape[0]), wp.transformf, "coms") + if self.data._body_com_pose_b.timestamp < 0.0 and ( + env_ids.shape[0] != self.num_instances or body_ids.shape[0] != self.num_bodies + ): + # Partial writes need the current model-property cache as a base for untouched entries. + self.data.body_com_pose_b # Warp kernels can ingest torch tensors directly, so we don't need to convert to warp arrays here. wp.launch( shared_kernels.write_body_com_pose_to_buffer, @@ -2372,6 +2377,8 @@ def set_coms_index( ], device=self.device, ) + self.data._body_com_pose_b.timestamp = self.data._sim_timestamp + self.data._reset_body_com_pose_b_dependents() # 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) 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 b0a6be457c0..1be67ac9698 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py @@ -196,6 +196,32 @@ def _reset_velocity(self, from_com: bool = True) -> None: ) self._fk_timestamp = -1.0 + def _reset_body_com_pose_b_dependents(self) -> None: + """Reset cached properties derived from body-frame center-of-mass offsets.""" + reset_timestamps( + [ + self._root_com_pose_w, + self._root_com_vel_w, + self._root_link_vel_w, + self._body_com_pose_w, + self._body_com_vel_w, + self._body_link_vel_w, + self._root_link_lin_vel_b, + self._root_link_ang_vel_b, + self._root_com_lin_vel_b, + self._root_com_ang_vel_b, + self._root_state_w, + self._root_link_state_w, + self._root_com_state_w, + self._body_state_w, + self._body_link_state_w, + self._body_com_state_w, + self._body_com_jacobian_w, + self._mass_matrix, + self._gravity_compensation_forces, + ] + ) + """ Names. """ @@ -914,8 +940,8 @@ def body_com_pose_b(self) -> ProxyArray: 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: - # set the buffer data and timestamp + if self._body_com_pose_b.timestamp < 0.0: + # Body-frame CoM offsets are model properties; cache them until an explicit CoM write updates them. self._body_com_pose_b.data.assign(self._root_view.get_coms().view(wp.transformf)) self._body_com_pose_b.timestamp = self._sim_timestamp diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index af36a365cb6..ac4755962a9 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -1753,8 +1753,9 @@ def test_body_root_state(sim, num_articulations, device, with_offset): 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) - articulation.root_view.set_coms( - wp.from_torch(com.cpu(), dtype=wp.float32), wp.from_torch(env_idx.cpu(), dtype=wp.int32) + articulation.set_coms_index( + coms=wp.from_torch(com.to(device).contiguous(), dtype=wp.transformf), + env_ids=wp.from_torch(env_idx, dtype=wp.int32), ) # check they are set @@ -1869,8 +1870,9 @@ def test_write_root_state(sim, num_articulations, device, with_offset, state_loc com = wp.to_torch(articulation.root_view.get_coms()) new_com = offset com[:, 0, :3] = new_com.squeeze(-2) - articulation.root_view.set_coms( - wp.from_torch(com.cpu(), dtype=wp.float32), wp.from_torch(env_idx.cpu(), dtype=wp.int32) + articulation.set_coms_index( + coms=wp.from_torch(com.to(device).contiguous(), dtype=wp.transformf), + env_ids=wp.from_torch(env_idx, dtype=wp.int32), ) # check they are set From d18c3345bea6e9abb7a910e470a3d5c64e765788 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Fri, 26 Jun 2026 12:09:34 -0400 Subject: [PATCH 024/139] [IsaacLab CI] Add light weight post-merge integration tests (#6247) # Description All other tests are treated as a part of the full suite and do not get run on push only the following: [rendering-correctness](https://github.com/isaac-sim/IsaacLab/actions/runs/27923944023/job/82630331274#logs) (~20m) [rendering-correctness-kitless](https://github.com/isaac-sim/IsaacLab/actions/runs/27923944023/job/82630331226#logs) (~20m) ## 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 --- .github/actions/run-package-tests/action.yml | 10 + .github/actions/run-tests/action.yml | 37 +++- .github/test-subsets/postmerge-rendering.toml | 34 ++++ .github/workflows/build.yaml | 175 +++++++++++++----- tools/conftest.py | 85 ++++++++- 5 files changed, 286 insertions(+), 55 deletions(-) create mode 100644 .github/test-subsets/postmerge-rendering.toml diff --git a/.github/actions/run-package-tests/action.yml b/.github/actions/run-package-tests/action.yml index 264e01a158b..ee3b40b1d33 100644 --- a/.github/actions/run-package-tests/action.yml +++ b/.github/actions/run-package-tests/action.yml @@ -61,6 +61,14 @@ inputs: description: 'Comma-separated list of specific test files to include' default: '' required: false + test-node-ids-file: + description: 'TOML file containing exact pytest node IDs to run' + default: '' + required: false + test-node-ids-key: + description: 'Top-level key in test-node-ids-file containing the node IDs for this job' + default: '' + required: false pytest-options: description: 'Additional pytest options' default: '' @@ -148,6 +156,8 @@ runs: curobo-only: ${{ inputs.curobo-only }} quarantined-only: ${{ inputs.quarantined-only }} include-files: ${{ inputs.include-files }} + test-node-ids-file: ${{ inputs.test-node-ids-file }} + test-node-ids-key: ${{ inputs.test-node-ids-key }} volume-mount-source: ${{ github.workspace }} extra-pip-packages: ${{ inputs.extra-pip-packages }} diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index ceea3a1c4e6..1a9b5308336 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -53,6 +53,14 @@ inputs: description: 'Comma-separated list of specific test file paths to include (e.g., source/pkg/test/test_a.py,source/pkg/test/test_b.py)' default: '' required: false + test-node-ids-file: + description: 'TOML file containing exact pytest node IDs to run' + default: '' + required: false + test-node-ids-key: + description: 'Top-level key in test-node-ids-file containing the node IDs for this job' + default: '' + required: false shard-index: description: 'Zero-based index of this shard (used with shard-count to split tests across parallel jobs)' default: '' @@ -93,6 +101,8 @@ runs: local shard_count="${13}" local volume_mount_source="${14}" local extra_pip_packages="${15}" + local test_node_ids_file="${16}" + local test_node_ids_key="${17}" local logs_pid="" local wait_pid="" local docker_wait_file="/tmp/.docker_exit_${container_name}" @@ -130,10 +140,25 @@ runs: if [ -n "$include_files" ]; then echo "Include files: $include_files" fi + if [ -n "$test_node_ids_file" ]; then + echo "Test node IDs file: $test_node_ids_file" + fi + if [ -n "$test_node_ids_key" ]; then + echo "Test node IDs key: $test_node_ids_key" + fi if [ -n "$shard_index" ] && [ -n "$shard_count" ]; then echo "Shard: $shard_index of $shard_count" fi + if [ -n "$test_node_ids_file" ] || [ -n "$test_node_ids_key" ]; then + if [ -z "$test_node_ids_file" ] || [ -z "$test_node_ids_key" ]; then + echo "Both test-node-ids-file and test-node-ids-key must be set together" + return 1 + fi + export TEST_NODE_IDS_FILE="$test_node_ids_file" + export TEST_NODE_IDS_KEY="$test_node_ids_key" + fi + # Create reports directory mkdir -p "$reports_dir" @@ -170,6 +195,16 @@ runs: echo "Setting TEST_INCLUDE_FILES=$include_files_compact" fi + if [ -n "${TEST_NODE_IDS:-}" ]; then + docker_env_vars="$docker_env_vars -e TEST_NODE_IDS" + echo "Setting TEST_NODE_IDS" + fi + + if [ -n "${TEST_NODE_IDS_FILE:-}" ]; then + docker_env_vars="$docker_env_vars -e TEST_NODE_IDS_FILE -e TEST_NODE_IDS_KEY" + echo "Setting TEST_NODE_IDS_FILE=$TEST_NODE_IDS_FILE TEST_NODE_IDS_KEY=$TEST_NODE_IDS_KEY" + fi + if [ -n "$shard_index" ] && [ -n "$shard_count" ]; then docker_env_vars="$docker_env_vars -e TEST_SHARD_INDEX=$shard_index -e TEST_SHARD_COUNT=$shard_count" echo "Setting TEST_SHARD_INDEX=$shard_index TEST_SHARD_COUNT=$shard_count" @@ -392,7 +427,7 @@ runs: } # Call the function with provided parameters - run_tests "${{ inputs.test-path }}" "${{ inputs.result-file }}" "${{ inputs.container-name }}" "${{ inputs.image-tag }}" "${{ inputs.reports-dir }}" "${{ inputs.pytest-options }}" "${{ inputs.filter-pattern }}" "${{ inputs.exclude-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" "${{ inputs.extra-pip-packages }}" + run_tests "${{ inputs.test-path }}" "${{ inputs.result-file }}" "${{ inputs.container-name }}" "${{ inputs.image-tag }}" "${{ inputs.reports-dir }}" "${{ inputs.pytest-options }}" "${{ inputs.filter-pattern }}" "${{ inputs.exclude-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" "${{ inputs.extra-pip-packages }}" "${{ inputs.test-node-ids-file }}" "${{ inputs.test-node-ids-key }}" - name: Kill container on cancellation if: cancelled() diff --git a/.github/test-subsets/postmerge-rendering.toml b/.github/test-subsets/postmerge-rendering.toml new file mode 100644 index 00000000000..ee52d8f1f54 --- /dev/null +++ b/.github/test-subsets/postmerge-rendering.toml @@ -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 + +# Stable rendering correctness tests used by post-merge CI. +rendering-correctness = [ + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[newton-isaacsim_rtx-albedo]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[newton-isaacsim_rtx-depth]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[newton-isaacsim_rtx-rgb]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[newton-isaacsim_rtx-semantic_segmentation]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-isaacsim_rtx-albedo]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-isaacsim_rtx-depth]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-isaacsim_rtx-rgb]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-isaacsim_rtx-semantic_segmentation]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-newton_warp-depth]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-newton_warp-rgb]", + "source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py::test_rendering_registered_tasks[Isaac-Cartpole-Camera-Direct-albedo-cartpole]", + "source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py::test_rendering_registered_tasks[Isaac-Cartpole-Camera-Direct-depth-cartpole]", + "source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py::test_rendering_registered_tasks[Isaac-Cartpole-Camera-Direct-None-cartpole]", + "source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py::test_rendering_registered_tasks[Isaac-Cartpole-Camera-Direct-rgb-cartpole]", +] + +rendering-correctness-kitless = [ + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[newton-newton_warp-depth]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[newton-newton_warp-rgb]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[newton-ovrtx-albedo]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[newton-ovrtx-depth]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovphysx-newton_warp-depth]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovphysx-newton_warp-rgb]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovphysx-ovrtx-albedo]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovphysx-ovrtx-rgb]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovphysx-ovrtx-semantic_segmentation]", +] diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5d715d4e294..bfcc691d9cf 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -4,7 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause # region help -# Every test job runs on every PR. +# Every test job runs on every PR and merge queue candidate. Pushes to protected branches run the +# post-merge integration tests. # # ============================================================================= # CI DEBUGGING TIPS @@ -46,6 +47,11 @@ name: Docker + Tests on: + push: + branches: + - main + - develop + - 'release/**' pull_request: types: [opened, synchronize, reopened] branches: @@ -67,7 +73,6 @@ permissions: env: NGC_API_KEY: ${{ secrets.NGC_API_KEY }} - CI_IMAGE_TAG: isaac-lab-ci:${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}-${{ github.sha }} jobs: changes: @@ -100,8 +105,13 @@ jobs: $'^\\.github/workflows/build\\.yaml$\tThis workflow file' $'^\\.github/workflows/config\\.yaml$\tBase image config' $'^\\.github/actions/\tCI actions' + $'^\\.github/test-subsets/\tCI test subset config' ) - triggered_jobs="Docker build jobs + all test-* matrix jobs" + if [ "$EVENT_NAME" = "push" ]; then + triggered_jobs="Docker base build job + rendering-correctness + rendering-correctness-kitless" + else + triggered_jobs="Docker build jobs + all test-* matrix jobs" + fi render_table() { local files="$1" entry regex desc count sample shown @@ -181,6 +191,7 @@ jobs: isaacsim_image_name: ${{ steps.load.outputs.isaacsim_image_name }} isaacsim_image_tag: ${{ steps.load.outputs.isaacsim_image_tag }} isaaclab_image_name: ${{ steps.load.outputs.isaaclab_image_name }} + ci_image_tag: ${{ steps.image_tag.outputs.ci_image_tag }} steps: - uses: actions/checkout@v6 with: @@ -194,6 +205,26 @@ jobs: echo "isaacsim_image_name=$(yq -r .isaacsim_image_name "$f")" >> "$GITHUB_OUTPUT" echo "isaacsim_image_tag=$(yq -r .isaacsim_image_tag "$f")" >> "$GITHUB_OUTPUT" echo "isaaclab_image_name=$(yq -r .isaaclab_image_name "$f")" >> "$GITHUB_OUTPUT" + - id: image_tag + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + + if [ "$EVENT_NAME" = "pull_request" ]; then + ref_component="pr-${PR_NUMBER}" + else + ref_component="$REF_NAME" + fi + + # Sanitize the ref name for use as a Docker tag suffix. + sanitized_ref=$(echo "$ref_component" | sed 's/[^a-zA-Z0-9._-]/-/g') + echo "ci_image_tag=isaac-lab-ci:${sanitized_ref}-${SHA}" >> "$GITHUB_OUTPUT" + echo "CI image tag: isaac-lab-ci:${sanitized_ref}-${SHA}" #region build jobs build: @@ -211,7 +242,7 @@ jobs: - name: Build and push to ECR uses: ./.github/actions/ecr-build-push-pull with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.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 @@ -221,7 +252,9 @@ jobs: name: Build cuRobo Docker Image runs-on: [self-hosted, gpu] needs: [changes, config] - if: needs.changes.outputs.run_docker_tests == 'true' + if: >- + github.event_name != 'push' && + needs.changes.outputs.run_docker_tests == 'true' steps: - name: Checkout Code uses: actions/checkout@v6 @@ -232,7 +265,7 @@ jobs: - name: Build and push to ECR uses: ./.github/actions/ecr-build-push-pull with: - image-tag: ${{ env.CI_IMAGE_TAG }}-curobo + image-tag: ${{ needs.config.outputs.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 @@ -247,7 +280,9 @@ jobs: timeout-minutes: 180 continue-on-error: true needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -255,7 +290,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" @@ -270,7 +305,9 @@ jobs: timeout-minutes: 180 continue-on-error: true needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -278,7 +315,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" @@ -293,7 +330,9 @@ jobs: timeout-minutes: 180 continue-on-error: true needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -301,7 +340,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" @@ -315,7 +354,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -323,7 +364,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "not isaaclab_" @@ -336,7 +377,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -344,7 +387,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "not isaaclab_" @@ -357,7 +400,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -365,7 +410,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "not isaaclab_" @@ -378,7 +423,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -386,7 +433,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_rl" @@ -398,7 +445,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -406,7 +455,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_mimic" @@ -417,7 +466,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -425,7 +476,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_contrib" @@ -436,7 +487,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -444,7 +497,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_teleop" @@ -455,7 +508,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -463,7 +518,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_visualizers" @@ -474,7 +529,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -482,7 +539,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_assets" @@ -493,7 +550,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -501,7 +560,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_newton" @@ -512,7 +571,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -520,7 +581,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_physx" @@ -531,7 +592,9 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -539,7 +602,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_ov" @@ -556,14 +619,14 @@ jobs: docker run --rm \ -v "$PWD":/workspace/isaaclab \ --entrypoint bash \ - "${{ env.CI_IMAGE_TAG }}" \ + "${{ needs.config.outputs.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 }}" \ + runtime_identity="$(docker run --rm --entrypoint bash "${{ needs.config.outputs.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}" @@ -578,7 +641,9 @@ jobs: timeout-minutes: 120 continue-on-error: true needs: [build-curobo, config] - if: needs.build-curobo.result == 'success' + if: >- + github.event_name != 'push' && + needs.build-curobo.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -586,7 +651,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }}-curobo + image-tag: ${{ needs.config.outputs.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 @@ -604,14 +669,14 @@ jobs: docker run --rm \ -v "$PWD":/workspace/isaaclab \ --entrypoint bash \ - "${{ env.CI_IMAGE_TAG }}-curobo" \ + "${{ needs.config.outputs.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" \ + runtime_identity="$(docker run --rm --entrypoint bash "${{ needs.config.outputs.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}" @@ -626,7 +691,9 @@ jobs: timeout-minutes: 120 continue-on-error: true needs: [build-curobo, config] - if: needs.build-curobo.result == 'success' + if: >- + github.event_name != 'push' && + needs.build-curobo.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -634,7 +701,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }}-curobo + image-tag: ${{ needs.config.outputs.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 @@ -648,7 +715,9 @@ jobs: timeout-minutes: 300 continue-on-error: true needs: [build, config] - if: needs.build.result == 'success' + if: >- + github.event_name != 'push' && + needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -656,7 +725,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" @@ -667,7 +736,7 @@ jobs: name: "rendering-correctness" runs-on: [self-hosted, gpu] timeout-minutes: 120 - continue-on-error: true + continue-on-error: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} needs: [build, config] if: needs.build.result == 'success' steps: @@ -677,7 +746,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" @@ -687,13 +756,15 @@ jobs: test_rendering_dexsuite_kuka_hetero.py, test_rendering_registered_tasks.py, test_rendering_shadow_hand.py + test-node-ids-file: ${{ github.event_name == 'push' && '.github/test-subsets/postmerge-rendering.toml' || '' }} + test-node-ids-key: ${{ github.event_name == 'push' && 'rendering-correctness' || '' }} 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 + continue-on-error: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} needs: [build, config] if: needs.build.result == 'success' steps: @@ -703,7 +774,7 @@ jobs: lfs: true - uses: ./.github/actions/run-package-tests with: - image-tag: ${{ env.CI_IMAGE_TAG }} + image-tag: ${{ needs.config.outputs.ci_image_tag }} isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" @@ -713,6 +784,8 @@ jobs: test_rendering_dexsuite_kuka_homo_kitless.py, test_rendering_dexsuite_kuka_hetero_kitless.py, test_rendering_shadow_hand_kitless.py + test-node-ids-file: ${{ github.event_name == 'push' && '.github/test-subsets/postmerge-rendering.toml' || '' }} + test-node-ids-key: ${{ github.event_name == 'push' && 'rendering-correctness-kitless' || '' }} container-name: isaac-lab-rendering-correctness-kitless-test #endregion @@ -733,7 +806,7 @@ jobs: # lfs: true # - uses: ./.github/actions/run-package-tests # with: -# image-tag: ${{ env.CI_IMAGE_TAG }} +# image-tag: ${{ needs.config.outputs.ci_image_tag }} # isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} # isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} # quarantined-only: "true" diff --git a/tools/conftest.py b/tools/conftest.py index a45f8cd9183..6052ca05b25 100644 --- a/tools/conftest.py +++ b/tools/conftest.py @@ -13,6 +13,7 @@ from dataclasses import dataclass import pytest +import tomllib from junitparser import Error, JUnitXml, TestCase, TestSuite from prettytable import PrettyTable @@ -424,6 +425,7 @@ class _PassContext: timeout: int startup_deadline: int env: dict + pytest_targets: list[str] _RESULT_PRIORITY = { @@ -498,7 +500,7 @@ def _run_one_pass( cmd += ["-m", "isaacsim_ci"] if k_expr is not None: cmd += ["-k", k_expr] - cmd.append(str(ctx.test_file)) + cmd += ctx.pytest_targets # -- Run with retry on startup hang or hard timeout ----------------- returncode, stdout_data, stderr_data, kill_reason = -1, b"", b"", "" @@ -729,12 +731,13 @@ def _run_one_pass( ) -def run_individual_tests(test_files, workspace_root, isaacsim_ci): +def run_individual_tests(test_files, workspace_root, isaacsim_ci, test_node_ids_by_file=None): """Run each test file separately, ensuring one finishes before starting the next.""" failed_tests = [] test_status = {} xml_reports = [] cold_cache_applied = False + test_node_ids_by_file = test_node_ids_by_file or {} for test_file in test_files: print(f"\n\n🚀 Running {test_file} independently...\n") @@ -762,6 +765,8 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci): extra = COLD_CACHE_BUFFER if is_cold_cache_test else 0 startup_deadline = min(timeout, STARTUP_DEADLINE + extra) + pytest_targets = test_node_ids_by_file.get(os.path.normpath(test_file), [str(test_file)]) + ctx = _PassContext( test_file=test_file, file_name=file_name, @@ -770,6 +775,7 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci): timeout=timeout, startup_deadline=startup_deadline, env=env, + pytest_targets=pytest_targets, ) if is_device_split_file(test_file, source=test_content): @@ -865,6 +871,60 @@ def _collect_test_files( return test_files +def _load_test_node_ids_from_toml(workspace_root: str) -> list[str]: + """Load exact pytest node IDs from a TOML file configured in the environment.""" + node_ids_file = os.environ.get("TEST_NODE_IDS_FILE") + node_ids_key = os.environ.get("TEST_NODE_IDS_KEY") + if not (node_ids_file or node_ids_key): + return [] + if not (node_ids_file and node_ids_key): + pytest.exit("Both TEST_NODE_IDS_FILE and TEST_NODE_IDS_KEY must be set together", returncode=1) + + path = node_ids_file if os.path.isabs(node_ids_file) else os.path.join(workspace_root, node_ids_file) + + try: + with open(os.path.normpath(path), "rb") as stream: + node_ids = tomllib.load(stream).get(node_ids_key) + except OSError as exc: + pytest.exit(f"Could not read TEST_NODE_IDS_FILE {node_ids_file!r}: {exc}", returncode=1) + except tomllib.TOMLDecodeError as exc: + pytest.exit(f"{node_ids_file}: invalid TOML: {exc}", returncode=1) + + if not node_ids: + pytest.exit(f"{node_ids_key!r} not found or empty in {node_ids_file}", returncode=1) + if not isinstance(node_ids, list) or not all(isinstance(node_id, str) for node_id in node_ids): + pytest.exit(f"{node_ids_key!r} must be a TOML array of strings in {node_ids_file}", returncode=1) + + return node_ids + + +def _collect_test_node_ids_by_file(workspace_root: str) -> dict[str, list[str]]: + """Group exact pytest node IDs by absolute test file path.""" + node_ids = [line.strip() for line in os.environ.get("TEST_NODE_IDS", "").splitlines() if line.strip()] + node_ids.extend(_load_test_node_ids_from_toml(workspace_root)) + if len(node_ids) != len(set(node_ids)): + pytest.exit("Configured test node IDs contain duplicates", returncode=1) + + grouped: dict[str, list[str]] = {} + for node_id in node_ids: + normalized_node_id = node_id.replace("\\", "/") + if "::" not in normalized_node_id: + pytest.exit(f"Configured test node ID must include '::': {node_id}", returncode=1) + + file_part, test_part = normalized_node_id.split("::", 1) + if os.path.isabs(file_part): + abs_file = os.path.normpath(file_part) + else: + abs_file = os.path.normpath(os.path.join(workspace_root, file_part)) + + if not os.path.exists(abs_file): + pytest.exit(f"Configured test node ID file does not exist: {node_id}", returncode=1) + + grouped.setdefault(abs_file, []).append(f"{normalized_node_id.split('::', 1)[0]}::{test_part}") + + return grouped + + def _write_empty_report(): """Write an empty JUnit XML report so downstream CI steps find a valid file.""" os.makedirs("tests", exist_ok=True) @@ -892,6 +952,8 @@ def pytest_sessionstart(session): isaacsim_ci = os.environ.get("ISAACSIM_CI_SHORT", "false") == "true" + test_node_ids_by_file = _collect_test_node_ids_by_file(workspace_root) + # Parse include files list (comma-separated paths) include_files = set() if include_files_str: @@ -899,6 +961,7 @@ def pytest_sessionstart(session): f = f.strip() if f: include_files.add(os.path.basename(f)) + include_files.update(os.path.basename(path) for path in test_node_ids_by_file) # Also try to get from pytest config if hasattr(session.config, "option") and hasattr(session.config.option, "filter_pattern"): @@ -912,11 +975,15 @@ def pytest_sessionstart(session): print(f"Filter pattern: '{filter_pattern}'") print(f"Exclude pattern: '{exclude_pattern}'") print(f"Include files: {include_files if include_files else 'none'}") + print(f"Test node IDs: {sum(len(node_ids) for node_ids in test_node_ids_by_file.values())}") print(f"Quarantined-only mode: {quarantined_only}") print(f"Curobo-only mode: {curobo_only}") print(f"TEST_FILTER_PATTERN env var: '{os.environ.get('TEST_FILTER_PATTERN', 'NOT_SET')}'") print(f"TEST_EXCLUDE_PATTERN env var: '{os.environ.get('TEST_EXCLUDE_PATTERN', 'NOT_SET')}'") print(f"TEST_INCLUDE_FILES env var: '{os.environ.get('TEST_INCLUDE_FILES', 'NOT_SET')}'") + print(f"TEST_NODE_IDS env var: '{'SET' if os.environ.get('TEST_NODE_IDS') else 'NOT_SET'}'") + print(f"TEST_NODE_IDS_FILE env var: '{os.environ.get('TEST_NODE_IDS_FILE', 'NOT_SET')}'") + print(f"TEST_NODE_IDS_KEY env var: '{os.environ.get('TEST_NODE_IDS_KEY', 'NOT_SET')}'") print(f"TEST_QUARANTINED_ONLY env var: '{os.environ.get('TEST_QUARANTINED_ONLY', 'NOT_SET')}'") print(f"TEST_CUROBO_ONLY env var: '{os.environ.get('TEST_CUROBO_ONLY', 'NOT_SET')}'") print("=" * 50) @@ -939,6 +1006,13 @@ def pytest_sessionstart(session): new_test_files.append(test_file) test_files = new_test_files + if test_node_ids_by_file: + configured_files = set(test_node_ids_by_file) + test_files = [test_file for test_file in test_files if os.path.normpath(test_file) in configured_files] + missing_files = sorted(configured_files - {os.path.normpath(test_file) for test_file in test_files}) + if missing_files: + pytest.exit(f"Configured test node ID files were not collected: {missing_files}", returncode=1) + if not test_files: if quarantined_only: print("No quarantined tests configured — nothing to run.") @@ -954,9 +1028,14 @@ def pytest_sessionstart(session): print(f"Found {len(test_files)} test files after filtering:") for test_file in test_files: print(f" - {test_file}") + if test_node_ids_by_file: + for node_id in test_node_ids_by_file[os.path.normpath(test_file)]: + print(f" {node_id}") # Run all tests individually - failed_tests, test_status, xml_reports = run_individual_tests(test_files, workspace_root, isaacsim_ci) + failed_tests, test_status, xml_reports = run_individual_tests( + test_files, workspace_root, isaacsim_ci, test_node_ids_by_file + ) print("failed tests:", failed_tests) From 4e3fc52f45f747734f823a4b10ced1daa9897ee7 Mon Sep 17 00:00:00 2001 From: vidurv-nvidia Date: Fri, 26 Jun 2026 12:57:42 -0500 Subject: [PATCH 025/139] Adding Tendon USD data classes and writers (#6265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Adds the **tendons** schema-fragment API. - `FixedTendonFragment` / `SpatialTendonFragment` markers + `PhysxFixedTendonCfg` / `PhysxSpatialTendonCfg` in `isaaclab_physx`. These are data carriers that override `func` to the existing multi-instance `modify_fixed_tendon_properties` / `modify_spatial_tendon_properties` writers (tune-not-apply), since tendon attributes live under multi-instance `PhysxTendonAxisRootAPI:*` schemas that the generic `apply_namespaced` writer cannot handle. - The spawner `fixed_tendons_props` / `spatial_tendons_props` slots now also accept fragment lists. This PR is purely **additive** and self-contained: it builds only on the single-namespace schema-fragment base (`SchemaFragment` + `apply_namespaced`) already in `develop`, existing call sites are untouched (a transition bridge routes legacy single cfgs to the existing `define_`/`modify_` writers), and it does **not** depend on any other open PR. ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots N/A — non-visual API 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 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 --- .../vidurv-schema-frag-tendons.minor.rst | 29 ++ source/isaaclab/isaaclab/sim/__init__.pyi | 8 + .../isaaclab/sim/schemas/__init__.pyi | 8 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 93 ++++ .../isaaclab/sim/schemas/schemas_cfg.py | 29 ++ .../sim/spawners/from_files/from_files.py | 28 +- .../sim/spawners/from_files/from_files_cfg.py | 20 +- .../test/sim/test_tendon_fragments.py | 425 ++++++++++++++++++ .../vidurv-schema-frag-tendons.minor.rst | 7 + .../isaaclab_newton/sim/schemas/__init__.pyi | 6 + .../isaaclab_newton/sim/schemas/schemas.py | 59 +++ .../sim/schemas/schemas_cfg.py | 30 ++ .../vidurv-schema-frag-tendons.minor.rst | 22 + .../isaaclab_physx/sim/schemas/__init__.pyi | 8 + .../isaaclab_physx/sim/schemas/schemas.py | 126 +++++- .../isaaclab_physx/sim/schemas/schemas_cfg.py | 96 ++++ 16 files changed, 984 insertions(+), 10 deletions(-) create mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst create mode 100644 source/isaaclab/test/sim/test_tendon_fragments.py create mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py create mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst new file mode 100644 index 00000000000..c32fab0d668 --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -0,0 +1,29 @@ +Added +^^^^^ + +* Added the tendon schema-fragment markers + :class:`~isaaclab.sim.schemas.FixedTendonFragment` and + :class:`~isaaclab.sim.schemas.SpatialTendonFragment`, which type the spawner + ``fixed_tendons_props`` / ``spatial_tendons_props`` slots. +* Added :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` and + :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties`, the family writers that + dispatch a list of tendon fragments via each fragment's ``func``. Tendons are a + *tune-not-apply* family, so the writers tune the existing multi-instance tendon schemas + without applying a new anchor schema. + +Changed +^^^^^^^ + +* Changed the spawner ``fixed_tendons_props`` / ``spatial_tendons_props`` slots + (:attr:`~isaaclab.sim.spawners.from_files.FileCfg.fixed_tendons_props`, + :attr:`~isaaclab.sim.spawners.from_files.FileCfg.spatial_tendons_props`) to also accept one + or more tendon fragments. Legacy single cfgs continue to work through a transition bridge in + the spawn writer. +* :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` and + :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties` now raise + ``ValueError`` when the prim at ``prim_path`` does not exist in the stage. + Callers that previously relied on an implicit no-op for invalid paths must + either validate the path beforehand or catch ``ValueError``. + The aggregated return value is now ``False`` whenever any fragment applier + reports failure; callers must not assume the return is always ``True`` even + when the prim is valid. diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index d1e36946aa2..1204269f630 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -44,6 +44,7 @@ __all__ = [ "ConvexHullPropertiesCfg", "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", + "FixedTendonFragment", "FixedTendonPropertiesCfg", "JointDriveBaseCfg", "MassCfg", @@ -64,12 +65,15 @@ __all__ = [ "RigidBodyBaseCfg", "RigidBodyFragment", "SchemaFragment", + "SpatialTendonFragment", "UsdPhysicsCollisionCfg", "UsdPhysicsRigidBodyCfg", "apply_collision_properties", + "apply_fixed_tendon_properties", "apply_mass_properties", "apply_namespaced", "apply_rigid_body_properties", + "apply_spatial_tendon_properties", "SDFMeshPropertiesCfg", "SpatialTendonPropertiesCfg", "TriangleMeshPropertiesCfg", @@ -226,6 +230,7 @@ from .schemas import ( ConvexHullPropertiesCfg, DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, + FixedTendonFragment, FixedTendonPropertiesCfg, JointDriveBaseCfg, MassCfg, @@ -238,6 +243,7 @@ from .schemas import ( RigidBodyFragment, SchemaFragment, SDFMeshPropertiesCfg, + SpatialTendonFragment, SpatialTendonPropertiesCfg, TriangleMeshPropertiesCfg, TriangleMeshSimplificationPropertiesCfg, @@ -245,9 +251,11 @@ from .schemas import ( UsdPhysicsRigidBodyCfg, activate_contact_sensors, apply_collision_properties, + apply_fixed_tendon_properties, apply_mass_properties, apply_namespaced, apply_rigid_body_properties, + apply_spatial_tendon_properties, define_articulation_root_properties, define_collision_properties, define_deformable_body_properties, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index baea5a920f9..ef116b499ce 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -9,9 +9,11 @@ __all__ = [ "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", "apply_collision_properties", + "apply_fixed_tendon_properties", "apply_mass_properties", "apply_namespaced", "apply_rigid_body_properties", + "apply_spatial_tendon_properties", "define_actuator_properties", "define_articulation_root_properties", "define_collision_properties", @@ -35,6 +37,7 @@ __all__ = [ "CollisionFragment", "DeformableBodyPropertiesBaseCfg", "DeformableBodyPropertiesCfg", + "FixedTendonFragment", "JointDriveBaseCfg", "MassCfg", "MassFragment", @@ -42,6 +45,7 @@ __all__ = [ "MeshCollisionBaseCfg", "RigidBodyFragment", "SchemaFragment", + "SpatialTendonFragment", "UsdPhysicsCollisionCfg", "UsdPhysicsRigidBodyCfg", "MujocoJointDrivePropertiesCfg", @@ -62,9 +66,11 @@ from .schemas import ( USD_MESH_COLLISION_CFGS, activate_contact_sensors, apply_collision_properties, + apply_fixed_tendon_properties, apply_mass_properties, apply_namespaced, apply_rigid_body_properties, + apply_spatial_tendon_properties, define_articulation_root_properties, define_collision_properties, define_deformable_body_properties, @@ -92,6 +98,7 @@ from .schemas_cfg import ( CollisionFragment, DeformableBodyPropertiesBaseCfg, DeformableBodyPropertiesCfg, + FixedTendonFragment, JointDriveBaseCfg, MassCfg, MassFragment, @@ -100,6 +107,7 @@ from .schemas_cfg import ( RigidBodyBaseCfg, RigidBodyFragment, SchemaFragment, + SpatialTendonFragment, UsdPhysicsCollisionCfg, UsdPhysicsRigidBodyCfg, ) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index abbe115541b..1e596973b03 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -990,6 +990,48 @@ def modify_joint_drive_properties( """ +def apply_fixed_tendon_properties( + prim_path: str, fragments: Iterable[schemas_cfg.FixedTendonFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of fixed-tendon fragments to a prim. + + Fixed tendons are a *tune-not-apply* family: the applied ``PhysxTendonAxisRootAPI`` + multi-instance schemas already exist on the prim (authored in the source asset). This writer + therefore applies no anchor schema; it only dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`, which tunes the existing instances. + Backend fragments carry backend-specific funcs, so core never imports a backend. + + Each fragment tunes only its own schema and returns ``False`` when that schema is not + present on the prim. A prim carries a single tendon backend, so compose backends across + prims rather than mixing PhysX and Mujoco fragments in one list on one prim. + + Args: + prim_path: The prim path to apply the fixed-tendon schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.FixedTendonFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if all fragments applied successfully, False if any fragment reported failure. + + Raises: + ValueError: If the prim at ``prim_path`` is not valid. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the sibling apply_* writers) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + # tune-not-apply: the PhysxTendonAxisRootAPI instances already exist; apply no anchor. + # aggregate per-fragment results so a reported failure is not silently masked. + success = True + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + success = bool(func(cfg, prim_path, stage)) and success + return success + + @apply_nested def modify_fixed_tendon_properties( prim_path: str, cfg: schemas_cfg.PhysxFixedTendonPropertiesCfg, stage: Usd.Stage | None = None @@ -1021,6 +1063,9 @@ def modify_fixed_tendon_properties( Raises: ValueError: If the input prim path is not valid. """ + # Retained for backward compatibility with callers passing PhysxFixedTendonPropertiesCfg + # directly. Will be removed in a future release once callers adopt the fragment-based + # apply_fixed_tendon_properties path. # get stage handle if stage is None: stage = get_current_stage() @@ -1048,6 +1093,7 @@ def modify_fixed_tendon_properties( camel_case=False, ) else: + # NOTE: ``mjc:*`` branch (``MjcTendon`` prim) kept inline; future split candidate into isaaclab_newton. # only stiffness and damping in the cfg map to mjc attributes for attr_name, value in cfg.items(): safe_set_attribute_on_usd_prim( @@ -1062,6 +1108,50 @@ def modify_fixed_tendon_properties( """ +def apply_spatial_tendon_properties( + prim_path: str, fragments: Iterable[schemas_cfg.SpatialTendonFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of spatial-tendon fragments to a prim. + + Spatial tendons are a *tune-not-apply* family: the applied + ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` multi-instance schemas + already exist on the prim (authored in the source asset). This writer therefore applies no + anchor schema; it only dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`, which tunes the existing instances. + Backend fragments carry backend-specific funcs, so core never imports a backend. + + Each fragment tunes only its own schema and returns ``False`` when that schema is not + present on the prim. A prim carries a single tendon backend, so compose backends across + prims rather than mixing PhysX and Mujoco fragments in one list on one prim. + + Args: + prim_path: The prim path to apply the spatial-tendon schemas on. + fragments: An iterable of :class:`~isaaclab.sim.schemas.SpatialTendonFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if all fragments applied successfully, False if any fragment reported failure. + + Raises: + ValueError: If the prim at ``prim_path`` is not valid. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the sibling apply_* writers) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + # tune-not-apply: the PhysxTendonAttachmentRootAPI / PhysxTendonAttachmentLeafAPI instances + # already exist; apply no anchor. + # aggregate per-fragment results so a reported failure is not silently masked. + success = True + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + success = bool(func(cfg, prim_path, stage)) and success + return success + + @apply_nested def modify_spatial_tendon_properties( prim_path: str, cfg: schemas_cfg.PhysxSpatialTendonPropertiesCfg, stage: Usd.Stage | None = None @@ -1095,6 +1185,9 @@ def modify_spatial_tendon_properties( Raises: ValueError: If the input prim path is not valid. """ + # Retained for backward compatibility with callers passing PhysxSpatialTendonPropertiesCfg + # directly. Will be removed in a future release once callers adopt the fragment-based + # apply_spatial_tendon_properties path. # obtain stage if stage is None: stage = get_current_stage() diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index 29478e843cf..7729f661bba 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -183,6 +183,35 @@ class CollisionFragment(SchemaFragment): pass +@configclass +class FixedTendonFragment(SchemaFragment): + """Marker base for fixed-tendon fragments; types the ``fixed_tendons_props`` slot. + + Fixed tendons are a *tune-not-apply* family: the applied ``PhysxTendonAxisRootAPI`` + multi-instance schemas already exist on the prim (authored in the source asset), so the + family writer (:func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`) does not apply + any anchor schema; it only tunes the existing instances via each fragment's + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. + """ + + pass + + +@configclass +class SpatialTendonFragment(SchemaFragment): + """Marker base for spatial-tendon fragments; types the ``spatial_tendons_props`` slot. + + Spatial tendons are a *tune-not-apply* family: the applied + ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` multi-instance schemas + already exist on the prim (authored in the source asset), so the family writer + (:func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties`) does not apply any anchor + schema; it only tunes the existing instances via each fragment's + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. + """ + + pass + + @configclass class UsdPhysicsCollisionCfg(CollisionFragment): """``physics:*`` collision attributes from `UsdPhysics.CollisionAPI`_. 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 ff51fa97375..8486234b221 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -372,9 +372,33 @@ def _spawn_from_usd_file( schemas.modify_articulation_root_properties(prim_path, cfg.articulation_props) # modify tendon properties if cfg.fixed_tendons_props is not None: - schemas.modify_fixed_tendon_properties(prim_path, cfg.fixed_tendons_props) + # transition shim, remove later: fragment(s) -> apply_*; legacy cfg -> modify_* + # normalize a single fragment to a list so the convenience form (and an empty list) route like a list + fixed_tendon_frags = ( + [cfg.fixed_tendons_props] + if isinstance(cfg.fixed_tendons_props, schemas.SchemaFragment) + else cfg.fixed_tendons_props + ) + if isinstance(fixed_tendon_frags, (list, tuple)) and all( + isinstance(f, schemas.SchemaFragment) for f in fixed_tendon_frags + ): + schemas.apply_fixed_tendon_properties(prim_path, fixed_tendon_frags) + else: + schemas.modify_fixed_tendon_properties(prim_path, cfg.fixed_tendons_props) if cfg.spatial_tendons_props is not None: - schemas.modify_spatial_tendon_properties(prim_path, cfg.spatial_tendons_props) + # transition shim, remove later: fragment(s) -> apply_*; legacy cfg -> modify_* + # normalize a single fragment to a list so the convenience form (and an empty list) route like a list + spatial_tendon_frags = ( + [cfg.spatial_tendons_props] + if isinstance(cfg.spatial_tendons_props, schemas.SchemaFragment) + else cfg.spatial_tendons_props + ) + if isinstance(spatial_tendon_frags, (list, tuple)) and all( + isinstance(f, schemas.SchemaFragment) for f in spatial_tendon_frags + ): + schemas.apply_spatial_tendon_properties(prim_path, spatial_tendon_frags) + else: + schemas.modify_spatial_tendon_properties(prim_path, cfg.spatial_tendons_props) # define drive API on the joints # note: these are only for setting low-level simulation properties. all others should be set or are # and overridden by the articulation/actuator properties. 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 22857f8d45b..d002588cf82 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 @@ -37,11 +37,23 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): articulation_props: schemas.ArticulationRootPropertiesCfg | None = None """Properties to apply to the articulation root.""" - fixed_tendons_props: schemas.FixedTendonPropertiesCfg | None = None - """Properties to apply to the fixed tendons (if any).""" + fixed_tendons_props: ( + schemas.FixedTendonPropertiesCfg | schemas.FixedTendonFragment | list[schemas.FixedTendonFragment] | None + ) = None + """Properties to apply to the fixed tendons (if any). - spatial_tendons_props: schemas.SpatialTendonPropertiesCfg | None = None - """Properties to apply to the spatial tendons (if any).""" + Accepts either the legacy :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg` + or one or more :class:`~isaaclab.sim.schemas.FixedTendonFragment` instances. + """ + + spatial_tendons_props: ( + schemas.SpatialTendonPropertiesCfg | schemas.SpatialTendonFragment | list[schemas.SpatialTendonFragment] | None + ) = None + """Properties to apply to the spatial tendons (if any). + + Accepts either the legacy :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonPropertiesCfg` + or one or more :class:`~isaaclab.sim.schemas.SpatialTendonFragment` instances. + """ joint_drive_props: schemas.JointDriveBaseCfg | None = None """Properties to apply to a joint. diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py new file mode 100644 index 00000000000..d18b6c26f8d --- /dev/null +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -0,0 +1,425 @@ +# 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 pytest + +from pxr import PhysxSchema, Sdf, Usd, UsdGeom + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + + +def _new_sim(): + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + return sim_utils.get_current_stage() + + +def _make_prim_with_schemas(stage, path, schema_tokens): + """Define an Xform and stamp ``apiSchemas`` metadata with the given multi-instance tokens.""" + UsdGeom.Xform.Define(stage, path) + prim = stage.GetPrimAtPath(path) + token_op = Sdf.TokenListOp() + token_op.explicitItems = schema_tokens + prim.SetMetadata("apiSchemas", token_op) + return prim + + +def _make_xform(stage, path="/World/Tendon"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +def _make_fixed_tendon_prim(stage, path, instance="default"): + """Create a prim with a multi-instance PhysxTendonAxisRootAPI applied.""" + prim = _make_xform(stage, path) + PhysxSchema.PhysxTendonAxisRootAPI.Apply(prim, instance) + return prim + + +def _make_spatial_tendon_prim(stage, path, instance="default"): + """Create a prim with a multi-instance PhysxTendonAttachmentRootAPI applied.""" + prim = _make_xform(stage, path) + PhysxSchema.PhysxTendonAttachmentRootAPI.Apply(prim, instance) + return prim + + +def _tendon_attr_prefix(prim, schema_substr): + """Return the applied-schema name used by the writer as the authored-attribute prefix. + + The legacy writer authors ``f"{schema_name}:{camelCase(field)}"`` where ``schema_name`` is + the entry returned by ``prim.GetAppliedSchemas()`` (e.g. ``PhysxTendonAxisRootAPI:t0``). + """ + for schema_name in prim.GetAppliedSchemas(): + if schema_substr in schema_name: + return schema_name + raise AssertionError(f"no applied schema containing {schema_substr!r} on {prim.GetPath()}") + + +# ------------------------------------------------------------------------------------- +# Fixed-tendon marker + metadata defaults +# ------------------------------------------------------------------------------------- + + +def test_fixed_tendon_fragment_metadata_defaults(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + + from isaaclab.sim.schemas import FixedTendonFragment, SchemaFragment + + cfg = PhysxFixedTendonCfg(stiffness=1.0) + assert isinstance(cfg, FixedTendonFragment) and isinstance(cfg, SchemaFragment) + assert cfg.func == "isaaclab_physx.sim.schemas:apply_fixed_tendon" + assert cfg.stiffness == 1.0 and cfg.damping is None + + +def test_spatial_tendon_fragment_metadata_defaults(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg + + from isaaclab.sim.schemas import SchemaFragment, SpatialTendonFragment + + cfg = PhysxSpatialTendonCfg(stiffness=2.0) + assert isinstance(cfg, SpatialTendonFragment) and isinstance(cfg, SchemaFragment) + assert cfg.func == "isaaclab_physx.sim.schemas:apply_spatial_tendon" + assert cfg.stiffness == 2.0 and cfg.damping is None + + +# ------------------------------------------------------------------------------------- +# PhysxFixedTendonCfg writes the multi-instance namespace +# ------------------------------------------------------------------------------------- + + +def test_physx_fixed_tendon_fragment_writes_instanced_namespace(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_fixed_tendon_prim(stage, "/World/FT", instance="t0") + apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=3.0, damping=0.5), "/World/FT", stage) + prefix = _tendon_attr_prefix(prim, "PhysxTendonAxisRootAPI") + assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 3.0) < 1e-6 + assert abs(prim.GetAttribute(f"{prefix}:damping").Get() - 0.5) < 1e-6 + # the ``func`` plumbing field must not be authored as an attribute + assert not prim.HasAttribute(f"{prefix}:func") + + +# ------------------------------------------------------------------------------------- +# PhysxSpatialTendonCfg writes the multi-instance namespace +# ------------------------------------------------------------------------------------- + + +def test_apply_fixed_tendon_writes_all_instances(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_prim_with_schemas(stage, "/World/FTmulti", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"]) + assert apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=9.0), "/World/FTmulti", stage) is True + for inst in ("t0", "t1"): + assert abs(prim.GetAttribute(f"PhysxTendonAxisRootAPI:{inst}:stiffness").Get() - 9.0) < 1e-6 + + +def test_apply_fixed_tendon_descends_to_child_prims(): + # tendon schemas are authored on child joint prims, not the articulation root the spawner + # targets; applying at the root must descend to every descendant carrying the schema. + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/Robot") # root: no tendon schema + child = _make_fixed_tendon_prim(stage, "/World/Robot/joint", instance="t0") # child joint carries it + # apply at the ROOT, not the joint + assert apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=8.0), "/World/Robot", stage) is True + prefix = _tendon_attr_prefix(child, "PhysxTendonAxisRootAPI") + assert abs(child.GetAttribute(f"{prefix}:stiffness").Get() - 8.0) < 1e-6 + + +def test_apply_spatial_tendon_descends_to_child_prims(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/Robot2") # root: no tendon schema + child = _make_prim_with_schemas(stage, "/World/Robot2/joint", ["PhysxTendonAttachmentRootAPI:s0"]) + assert apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=5.0), "/World/Robot2", stage) is True + assert abs(child.GetAttribute("PhysxTendonAttachmentRootAPI:s0:stiffness").Get() - 5.0) < 1e-6 + + +def test_physx_spatial_tendon_fragment_writes_instanced_namespace(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_spatial_tendon_prim(stage, "/World/ST", instance="s0") + apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=4.0, limit_stiffness=0.25), "/World/ST", stage) + prefix = _tendon_attr_prefix(prim, "PhysxTendonAttachmentRootAPI") + assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 4.0) < 1e-6 + assert abs(prim.GetAttribute(f"{prefix}:limitStiffness").Get() - 0.25) < 1e-6 + assert not prim.HasAttribute(f"{prefix}:func") + + +def test_apply_spatial_tendon_writes_all_instances(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg, apply_spatial_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_prim_with_schemas( + stage, + "/World/STmulti", + ["PhysxTendonAttachmentRootAPI:r0", "PhysxTendonAttachmentLeafAPI:l0"], + ) + assert apply_spatial_tendon(PhysxSpatialTendonCfg(stiffness=4.0), "/World/STmulti", stage) is True + assert abs(prim.GetAttribute("PhysxTendonAttachmentRootAPI:r0:stiffness").Get() - 4.0) < 1e-6 + assert abs(prim.GetAttribute("PhysxTendonAttachmentLeafAPI:l0:stiffness").Get() - 4.0) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# apply_fixed_tendon_properties dispatch (tune-not-apply, multi-fragment) +# ------------------------------------------------------------------------------------- + + +def test_apply_fixed_tendon_properties_dispatches_fragments(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg + + from isaaclab.sim.schemas import apply_fixed_tendon_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_fixed_tendon_prim(stage, "/World/FT2", instance="t0") + apply_fixed_tendon_properties( + "/World/FT2", + [PhysxFixedTendonCfg(stiffness=5.0), PhysxFixedTendonCfg(damping=0.75)], + stage, + ) + prefix = _tendon_attr_prefix(prim, "PhysxTendonAxisRootAPI") + assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 5.0) < 1e-6 + assert abs(prim.GetAttribute(f"{prefix}:damping").Get() - 0.75) < 1e-6 + + +def test_apply_spatial_tendon_properties_dispatches_fragments(): + from isaaclab_physx.sim.schemas import PhysxSpatialTendonCfg + + from isaaclab.sim.schemas import apply_spatial_tendon_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_spatial_tendon_prim(stage, "/World/ST2", instance="s0") + apply_spatial_tendon_properties( + "/World/ST2", + [PhysxSpatialTendonCfg(stiffness=6.0), PhysxSpatialTendonCfg(offset=0.1)], + stage, + ) + prefix = _tendon_attr_prefix(prim, "PhysxTendonAttachmentRootAPI") + assert abs(prim.GetAttribute(f"{prefix}:stiffness").Get() - 6.0) < 1e-6 + assert abs(prim.GetAttribute(f"{prefix}:offset").Get() - 0.1) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# Public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_physx.sim.schemas import ( # noqa: F401 + PhysxFixedTendonCfg, + PhysxSpatialTendonCfg, + apply_fixed_tendon, + apply_spatial_tendon, + ) + + from isaaclab.sim.schemas import ( # noqa: F401 + FixedTendonFragment, + SpatialTendonFragment, + apply_fixed_tendon_properties, + apply_spatial_tendon_properties, + ) + + +# ------------------------------------------------------------------------------------- +# core writer parity: invalid-prim guard + aggregated return +# ------------------------------------------------------------------------------------- + + +def test_apply_fixed_tendon_raises_on_invalid_prim(): + from isaaclab.sim.schemas import apply_fixed_tendon_properties + + _new_sim() + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_fixed_tendon_properties("/World/DoesNotExist", [], stage) + + +def test_apply_spatial_tendon_raises_on_invalid_prim(): + from isaaclab.sim.schemas import apply_spatial_tendon_properties + + _new_sim() + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_spatial_tendon_properties("/World/DoesNotExist", [], stage) + + +def test_apply_fixed_tendon_aggregates_fragment_results(): + from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_fixed_tendon_properties + + stage = _new_sim() + _make_prim_with_schemas(stage, "/World/Agg", ["PhysxTendonAxisRootAPI:inst0"]) + + # a fragment whose applier reports failure makes the aggregate False + failing = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + failing.func = lambda cfg, prim_path, stage=None: False + assert apply_fixed_tendon_properties("/World/Agg", [failing], stage) is False + + ok = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + ok.func = lambda cfg, prim_path, stage=None: True + assert apply_fixed_tendon_properties("/World/Agg", [ok], stage) is True + + +def test_apply_fixed_tendon_raises_on_invalid_prim_backend(): + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, apply_fixed_tendon + + _new_sim() + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_fixed_tendon(PhysxFixedTendonCfg(stiffness=1.0), "/World/DoesNotExist", stage) + + +def test_apply_mujoco_fixed_tendon_raises_on_invalid_prim(): + from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg, apply_mujoco_fixed_tendon + + _new_sim() + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_mujoco_fixed_tendon(MujocoFixedTendonCfg(stiffness=1.0), "/World/DoesNotExist", stage) + + +# ------------------------------------------------------------------------------------- +# MujocoFixedTendonCfg — Newton fragment for the mjc: namespace +# ------------------------------------------------------------------------------------- + + +def test_mujoco_fixed_tendon_metadata(): + from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg + + from isaaclab.sim.schemas import FixedTendonFragment + + cfg = MujocoFixedTendonCfg(stiffness=2.0) + assert isinstance(cfg, FixedTendonFragment) + # not namespace-driven: the custom applier writes mjc:* itself, so _usd_namespace stays None + assert type(cfg)._usd_namespace is None + assert cfg.func == "isaaclab_newton.sim.schemas:apply_mujoco_fixed_tendon" + assert not hasattr(cfg, "rest_length") and not hasattr(cfg, "limit_stiffness") + + +def test_apply_mujoco_fixed_tendon_writes_mjc_namespace(): + from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg, apply_mujoco_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + stage.DefinePrim("/World/MjcT", "MjcTendon") + assert apply_mujoco_fixed_tendon(MujocoFixedTendonCfg(stiffness=2.0, damping=0.25), "/World/MjcT", stage) is True + prim = stage.GetPrimAtPath("/World/MjcT") + assert abs(prim.GetAttribute("mjc:stiffness").Get() - 2.0) < 1e-6 + assert abs(prim.GetAttribute("mjc:damping").Get() - 0.25) < 1e-6 + assert not prim.HasAttribute("mjc:func") + + +def test_apply_mujoco_fixed_tendon_returns_false_on_non_mjc_prim(): + from isaaclab_newton.sim.schemas import MujocoFixedTendonCfg, apply_mujoco_fixed_tendon + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/NotMjc") + assert apply_mujoco_fixed_tendon(MujocoFixedTendonCfg(stiffness=2.0), "/World/NotMjc", stage) is False + prim = stage.GetPrimAtPath("/World/NotMjc") + assert not prim.HasAttribute("mjc:stiffness") + + +# ------------------------------------------------------------------------------------- +# legacy-vs-fragment equivalence (the fragment API must be a behavioral no-op swap) +# ------------------------------------------------------------------------------------- + + +def test_legacy_and_fragment_fixed_tendon_produce_identical_attrs(): + """The fragment API must author the same tendon attributes as the legacy writer. + + Verified end-to-end on the Shadow Hand (the real tendon user, + ``FixedTendonPropertiesCfg(limit_stiffness=30.0, damping=0.1)``); replicated here on a synthetic + root + descendant-joint structure so it runs deterministically without asset-server access. Also + exercises the descend-to-child-prims behavior, since the schemas live on descendants of the + applied prim path (as they do on a real articulation). + """ + from isaaclab_physx.sim.schemas import PhysxFixedTendonCfg, PhysxFixedTendonPropertiesCfg + + from isaaclab.sim.schemas import apply_fixed_tendon_properties, modify_fixed_tendon_properties + + stage = _new_sim() + + def _build(root): + # tendon schemas on descendant joints (multi-instance), mirroring the Shadow Hand layout + UsdGeom.Xform.Define(stage, root) + _make_prim_with_schemas(stage, f"{root}/J0", ["PhysxTendonAxisRootAPI:t0", "PhysxTendonAxisRootAPI:t1"]) + _make_prim_with_schemas(stage, f"{root}/nested/J1", ["PhysxTendonAxisRootAPI:t0"]) + + _build("/World/legacy") + _build("/World/fragment") + + # apply each path at the ROOT; both must descend to the child joints + modify_fixed_tendon_properties("/World/legacy", PhysxFixedTendonPropertiesCfg(limit_stiffness=30.0, damping=0.1)) + apply_fixed_tendon_properties("/World/fragment", [PhysxFixedTendonCfg(limit_stiffness=30.0, damping=0.1)]) + + def _collect(root): + attrs = {} + for prim in Usd.PrimRange(stage.GetPrimAtPath(root)): + for schema_name in prim.GetAppliedSchemas(): + if "PhysxTendonAxisRootAPI" not in schema_name: + continue + for suffix in ("limitStiffness", "damping"): + attr = prim.GetAttribute(f"{schema_name}:{suffix}") + if attr and attr.HasAuthoredValue(): + rel = prim.GetPath().pathString[len(root) :] # key relative to root so paths compare + attrs[f"{rel}|{schema_name}:{suffix}"] = attr.Get() + return attrs + + legacy = _collect("/World/legacy") + fragment = _collect("/World/fragment") + + assert legacy, "legacy writer authored no tendon attributes (test would be vacuous)" + assert legacy.keys() == fragment.keys() + for key, value in legacy.items(): + assert abs(fragment[key] - value) < 1e-6 + + +def test_spawn_from_file_with_empty_tendon_lists_is_noop(tmp_path): + # an empty tendon list is type-valid for the slot; the spawner shim must route it through the + # fragment path (a no-op) rather than handing [] to the legacy modify_*_tendon_properties writer. + asset = tmp_path / "mini.usda" + src = Usd.Stage.CreateNew(str(asset)) + UsdGeom.Xform.Define(src, "/Root") + src.SetDefaultPrim(src.GetPrimAtPath("/Root")) + src.GetRootLayer().Save() + del src + + _new_sim() + cfg = sim_utils.UsdFileCfg(usd_path=str(asset), fixed_tendons_props=[], spatial_tendons_props=[]) + cfg.func("/World/Asset", cfg) # must not raise + assert sim_utils.get_current_stage().GetPrimAtPath("/World/Asset").IsValid() diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst new file mode 100644 index 00000000000..75ebce232ad --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -0,0 +1,7 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg` and its applier + :func:`~isaaclab_newton.sim.schemas.apply_mujoco_fixed_tendon` for tuning ``mjc:*`` + fixed-tendon attributes on ``MjcTendon`` prims, + splitting the Mujoco tune path out of the PhysX fixed-tendon applier. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi index f7413adbb88..1a94b3d659a 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "apply_mujoco_fixed_tendon", + "MujocoFixedTendonCfg", "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyCfg", "MujocoRigidBodyPropertiesCfg", @@ -18,7 +20,11 @@ __all__ = [ "NewtonSDFCollisionPropertiesCfg", ] +from .schemas import ( + apply_mujoco_fixed_tendon, +) from .schemas_cfg import ( + MujocoFixedTendonCfg, MujocoJointDrivePropertiesCfg, MujocoRigidBodyCfg, MujocoRigidBodyPropertiesCfg, diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py new file mode 100644 index 00000000000..46727e03937 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py @@ -0,0 +1,59 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Newton-backend schema writers (custom appliers for irregular fragments).""" + +from __future__ import annotations + +import dataclasses + +from pxr import Usd + +from isaaclab.sim.utils import safe_set_attribute_on_usd_prim +from isaaclab.sim.utils.stage import get_current_stage +from isaaclab.utils.string import to_camel_case + +from .schemas_cfg import MujocoFixedTendonCfg + +__all__ = ["apply_mujoco_fixed_tendon"] + + +def apply_mujoco_fixed_tendon(cfg: MujocoFixedTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Write ``mjc:*`` fixed-tendon attributes on a ``MjcTendon`` prim. + + Custom ``func`` override for :class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg`. + No-op (returns False) on any prim whose type is not ``MjcTendon``. + + Args: + cfg: The :class:`MujocoFixedTendonCfg` fragment to apply. + prim_path: The prim path of the ``MjcTendon`` prim. + stage: The stage where to find the prim. Defaults to the current stage. + + Returns: + True if the prim is a ``MjcTendon`` and was tuned, False otherwise. + + Raises: + ValueError: If the prim at ``prim_path`` does not exist in the stage. + """ + if stage is None: + stage = get_current_stage() + root = stage.GetPrimAtPath(prim_path) + if not root.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + values = { + f.name: getattr(cfg, f.name) + for f in dataclasses.fields(cfg) + if f.name != "func" and getattr(cfg, f.name) is not None + } + # Descend the whole subtree (matching legacy apply_nested): ``MjcTendon`` prims may sit below + # the prim_path the spawner targets. Write ``mjc:*`` on every ``MjcTendon`` descendant. + found = False + for prim in Usd.PrimRange(root): + if prim.GetTypeName() != "MjcTendon": + continue + found = True + for attr_name, value in values.items(): + safe_set_attribute_on_usd_prim(prim, f"mjc:{to_camel_case(attr_name, 'cC')}", value, camel_case=False) + return found 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 edf007790b5..85f14b37bc6 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -5,6 +5,7 @@ from __future__ import annotations +from collections.abc import Callable from typing import ClassVar, Literal from isaaclab.sim.schemas.schemas_cfg import ( @@ -12,6 +13,7 @@ CollisionBaseCfg, CollisionFragment, DeformableBodyPropertiesBaseCfg, + FixedTendonFragment, JointDriveBaseCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, @@ -357,6 +359,34 @@ class NewtonMaterialPropertiesCfg(RigidBodyMaterialBaseCfg): """ +@configclass +class MujocoFixedTendonCfg(FixedTendonFragment): + """``mjc:*`` fixed-tendon attributes for a ``MjcTendon`` prim. + + The Mujoco fixed-tendon fragment. Newton has no tendon solver; this models only the ``mjc:*`` + tune path the Newton/Mujoco importer reads from a ``MjcTendon`` prim, carrying only the fields + that path maps. Overrides :attr:`func` with a custom applier + (:func:`~isaaclab_newton.sim.schemas.apply_mujoco_fixed_tendon`) that gates on the ``MjcTendon`` + prim type. Can be combined with :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` in the same + fragment list passed to :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`, which + dispatches each fragment to its own applier independently. + """ + + # Not namespace-driven: the custom applier gates on the ``MjcTendon`` prim type and writes the + # ``mjc:*`` attributes itself, so ``_usd_namespace`` stays ``None`` -- this also guards against + # accidentally routing the fragment through the generic ``apply_namespaced``. + _usd_namespace: ClassVar[str | None] = None + _usd_applied_schema: ClassVar[str | None] = None + + func: Callable | str = "isaaclab_newton.sim.schemas:apply_mujoco_fixed_tendon" + + stiffness: float | None = None + """Spring stiffness term acting on the tendon's length [N/m].""" + + damping: float | None = None + """Damping term acting on the tendon length [N·s/m].""" + + @configclass class NewtonArticulationRootPropertiesCfg(ArticulationRootBaseCfg): """Newton-specific articulation root properties. diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst new file mode 100644 index 00000000000..78e06806579 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst @@ -0,0 +1,22 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonCfg`, the PhysX tendon schema + fragments. They override ``func`` with + :func:`~isaaclab_physx.sim.schemas.apply_fixed_tendon` / + :func:`~isaaclab_physx.sim.schemas.apply_spatial_tendon`, which delegate to the existing + multi-instance tendon writers to tune every applied ``PhysxTendonAxisRootAPI`` / + ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` instance. + +Changed +^^^^^^^ + +* Reworked :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` / + :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonCfg` appliers to tune the multi-instance + PhysX tendon schemas directly, removing the dependency on the legacy + ``modify_*_tendon_properties`` writers and the legacy ``Physx*TendonPropertiesCfg`` reconstruction. + Callers relying on :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg` + reconstruction inside the applier should pass a + :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` fragment directly to + :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` instead. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 767ba36f06d..9541067c83a 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "apply_fixed_tendon", + "apply_spatial_tendon", "define_deformable_body_properties", "modify_deformable_body_properties", "ArticulationRootPropertiesCfg", @@ -22,11 +24,13 @@ __all__ = [ "PhysxConvexHullPropertiesCfg", "PhysxDeformableBodyPropertiesCfg", "PhysxDeformableCollisionPropertiesCfg", + "PhysxFixedTendonCfg", "PhysxFixedTendonPropertiesCfg", "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyCfg", "PhysxRigidBodyPropertiesCfg", "PhysxSDFMeshPropertiesCfg", + "PhysxSpatialTendonCfg", "PhysxSpatialTendonPropertiesCfg", "PhysxTriangleMeshPropertiesCfg", "PhysxTriangleMeshSimplificationPropertiesCfg", @@ -38,6 +42,8 @@ __all__ = [ ] from .schemas import ( + apply_fixed_tendon, + apply_spatial_tendon, define_deformable_body_properties, modify_deformable_body_properties, ) @@ -58,11 +64,13 @@ from .schemas_cfg import ( PhysxConvexHullPropertiesCfg, PhysxDeformableBodyPropertiesCfg, PhysxDeformableCollisionPropertiesCfg, + PhysxFixedTendonCfg, PhysxFixedTendonPropertiesCfg, PhysxJointDrivePropertiesCfg, PhysxRigidBodyCfg, PhysxRigidBodyPropertiesCfg, PhysxSDFMeshPropertiesCfg, + PhysxSpatialTendonCfg, PhysxSpatialTendonPropertiesCfg, PhysxTriangleMeshPropertiesCfg, PhysxTriangleMeshSimplificationPropertiesCfg, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index 713c1cc3264..7678e9ca9d3 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -3,12 +3,130 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Compatibility wrappers for deformable schema writers. +"""Compatibility wrappers for deformable schema writers and PhysX fragment appliers. The deformable schema writers are backend-aware but remain unified in -:mod:`isaaclab.sim.schemas`. +:mod:`isaaclab.sim.schemas`. This module additionally hosts the PhysX-specific fragment +applier funcs that override :attr:`~isaaclab.sim.schemas.SchemaFragment.func` for the +multi-instance tendon schemas, keeping the backend func out of the core package. """ -from isaaclab.sim.schemas.schemas import define_deformable_body_properties, modify_deformable_body_properties +from __future__ import annotations -__all__ = ["define_deformable_body_properties", "modify_deformable_body_properties"] +import dataclasses + +from pxr import Usd + +from isaaclab.sim.schemas.schemas import ( + define_deformable_body_properties, + modify_deformable_body_properties, +) +from isaaclab.sim.utils import safe_set_attribute_on_usd_prim +from isaaclab.sim.utils.stage import get_current_stage +from isaaclab.utils.string import to_camel_case + +from .schemas_cfg import PhysxFixedTendonCfg, PhysxSpatialTendonCfg + +__all__ = [ + "apply_fixed_tendon", + "apply_spatial_tendon", + "define_deformable_body_properties", + "modify_deformable_body_properties", +] + + +def _strip_fragment_fields(cfg) -> dict: + """Collect a fragment's non-``None`` data fields, excluding the ``func`` plumbing field. + + Args: + cfg: The fragment instance to read fields from. + + Returns: + A mapping of set field names to their values, ready to author as namespaced USD attributes. + """ + return { + f.name: getattr(cfg, f.name) + for f in dataclasses.fields(cfg) + if f.name != "func" and getattr(cfg, f.name) is not None + } + + +def _tune_multi_instance_tendon(cfg, prim_path: str, stage: Usd.Stage | None, markers: tuple[str, ...]) -> bool: + """Tune every multi-instance tendon schema (matching one of ``markers``) under ``prim_path``. + + Shared backend for :func:`apply_fixed_tendon` / :func:`apply_spatial_tendon`. These schemas are + *tune-not-apply* (instances are authored in the source asset) and are typically applied on the + descendant joint prims rather than the ``prim_path`` the spawner targets, so this descends the + whole subtree (matching the legacy ``apply_nested`` traversal) and writes each set fragment field + as ``:`` on every prim carrying a matching instance. Applies no + schema. The fragment's ``_usd_namespace`` is unused (these are not flat-namespace fragments); the + schema marker is matched explicitly via ``markers``. + + Args: + cfg: The tendon fragment whose set fields are written. + prim_path: The prim path (or articulation root) whose subtree carries the schemas. + stage: The stage to resolve the prim on. Defaults to the current stage. + markers: Substrings identifying the applied schema(s) to tune (e.g. ``("PhysxTendonAxisRootAPI",)``). + + Returns: + True if at least one matching instance was tuned, False if none is applied. + """ + if stage is None: + stage = get_current_stage() + root = stage.GetPrimAtPath(prim_path) + if not root.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + values = _strip_fragment_fields(cfg) + found = False + for prim in Usd.PrimRange(root): + applied_schemas = prim.GetAppliedSchemas() + if not any(m in s for s in applied_schemas for m in markers): + continue + found = True + for schema_name in applied_schemas: + if not any(m in schema_name for m in markers): + continue + for attr_name, value in values.items(): + safe_set_attribute_on_usd_prim( + prim, f"{schema_name}:{to_camel_case(attr_name, 'cC')}", value, camel_case=False + ) + return found + + +def apply_fixed_tendon(cfg: PhysxFixedTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Tune the multi-instance ``PhysxTendonAxisRootAPI`` schemas on a prim. + + Custom ``func`` override for :class:`PhysxFixedTendonCfg`. The fixed-tendon schema is + multi-instance and *tune-not-apply* (instances are authored in the source asset), so this + writes each set fragment field as ``:`` across every applied + ``PhysxTendonAxisRootAPI`` instance and applies no schema. Writes nothing for the ``mjc:`` + Mujoco path — a separate ``MjcTendon``-aware Newton fragment handles that path. + + Args: + cfg: The :class:`PhysxFixedTendonCfg` fragment to apply. + prim_path: The prim path carrying the fixed-tendon schemas. + stage: The stage where to find the prim. Defaults to the current stage. + + Returns: + True if at least one ``PhysxTendonAxisRootAPI`` instance was tuned, False if none is applied. + """ + return _tune_multi_instance_tendon(cfg, prim_path, stage, ("PhysxTendonAxisRootAPI",)) + + +def apply_spatial_tendon(cfg: PhysxSpatialTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Tune the multi-instance ``PhysxTendonAttachment{Root,Leaf}API`` schemas on a prim. + + Custom ``func`` override for :class:`PhysxSpatialTendonCfg`. Writes each set fragment field + across every applied attachment-root and attachment-leaf instance and applies no schema. + + Args: + cfg: The :class:`PhysxSpatialTendonCfg` fragment to apply. + prim_path: The prim path carrying the spatial-tendon schemas. + stage: The stage where to find the prim. Defaults to the current stage. + + Returns: + True if at least one attachment instance was tuned, False if none is applied. + """ + return _tune_multi_instance_tendon( + cfg, prim_path, stage, ("PhysxTendonAttachmentRootAPI", "PhysxTendonAttachmentLeafAPI") + ) 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 c81ea84f710..45e0fda37d0 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -6,6 +6,7 @@ from __future__ import annotations import warnings +from collections.abc import Callable from typing import ClassVar from isaaclab.sim.schemas.schemas_cfg import ( @@ -13,10 +14,12 @@ CollisionBaseCfg, CollisionFragment, DeformableBodyPropertiesBaseCfg, + FixedTendonFragment, JointDriveBaseCfg, MeshCollisionBaseCfg, RigidBodyBaseCfg, RigidBodyFragment, + SpatialTendonFragment, ) from isaaclab.utils.configclass import configclass @@ -1023,3 +1026,96 @@ def __post_init__(self): stacklevel=2, ) super().__post_init__() + + +@configclass +class PhysxFixedTendonCfg(FixedTendonFragment): + """PhysX fixed-tendon attributes from `PhysxTendonAxisRootAPI`_. + + A fixed-tendon fragment (see :class:`~isaaclab.sim.schemas.FixedTendonFragment`) for the + PhysX fixed-tendon schema. Unlike single-namespace fragments, this is a *tune-not-apply* + fragment: the multi-instance ``PhysxTendonAxisRootAPI:`` schemas already exist on the + prim (authored in the source asset), so the fragment overrides + :attr:`~isaaclab.sim.schemas.SchemaFragment.func` with :func:`apply_fixed_tendon`, which + descends the prim subtree and tunes every existing ``PhysxTendonAxisRootAPI:`` instance + directly. + + Dispatched via :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties`. + + .. _PhysxTendonAxisRootAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_tendon_axis_root_a_p_i.html + """ + + # Not namespace-driven: the custom applier matches the multi-instance schema explicitly, so + # ``_usd_namespace`` stays ``None`` -- this also guards against accidentally routing the fragment + # through the generic ``apply_namespaced`` (which would raise on a missing namespace). + _usd_namespace: ClassVar[str | None] = None + # override ``func``: writer iterates multi-instance ``PhysxTendonAxisRootAPI`` schemas; ``apply_namespaced`` cannot. + func: Callable | str = "isaaclab_physx.sim.schemas:apply_fixed_tendon" + + 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 [N/m].""" + + damping: float | None = None + """The damping term acting on both the tendon length and the tendon-length limits [N·s/m].""" + + limit_stiffness: float | None = None + """Limit stiffness term acting on the tendon's length limits [N/m].""" + + offset: float | None = None + """Length offset term for the tendon [m]. + + 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 [m].""" + + +@configclass +class PhysxSpatialTendonCfg(SpatialTendonFragment): + """PhysX spatial-tendon attributes from `PhysxTendonAttachmentRootAPI`_. + + A spatial-tendon fragment (see :class:`~isaaclab.sim.schemas.SpatialTendonFragment`) for the + PhysX spatial-tendon schema. Unlike single-namespace fragments, this is a *tune-not-apply* + fragment: the multi-instance ``PhysxTendonAttachmentRootAPI:`` / + ``PhysxTendonAttachmentLeafAPI:`` schemas already exist on the prim (authored in the + source asset), so the fragment overrides + :attr:`~isaaclab.sim.schemas.SchemaFragment.func` with :func:`apply_spatial_tendon`, which + descends the prim subtree and tunes every existing ``PhysxTendonAttachmentRootAPI:`` / + ``PhysxTendonAttachmentLeafAPI:`` instance directly. + + Dispatched via :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties`. + + .. _PhysxTendonAttachmentRootAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_tendon_attachment_root_a_p_i.html + """ + + # Not namespace-driven: the custom applier matches the multi-instance schemas explicitly, so + # ``_usd_namespace`` stays ``None`` -- this also guards against accidentally routing the fragment + # through the generic ``apply_namespaced`` (which would raise on a missing namespace). + _usd_namespace: ClassVar[str | None] = None + # override ``func``: writer iterates multi-instance ``PhysxTendonAttachment{Root,Leaf}API`` + # schemas, which the generic ``apply_namespaced`` cannot. + func: Callable | str = "isaaclab_physx.sim.schemas:apply_spatial_tendon" + + 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 [N/m].""" + + damping: float | None = None + """The damping term acting on both the tendon length and the tendon-length limits [N·s/m].""" + + limit_stiffness: float | None = None + """Limit stiffness term acting on the tendon's length limits [N/m].""" + + offset: float | None = None + """Length offset term for the tendon [m]. + + 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. + """ From 12852bc521a622e44e8c5933355916f1bb5b5d6b Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Fri, 26 Jun 2026 12:39:38 -0700 Subject: [PATCH 026/139] Move PhysX fixed-root-joint creation out of core into the PhysX backend The fix_root_link create path in apply_articulation_root_properties used omni.physx.scripts and a PhysxArticulationAPI root-relocation workaround (a PhysX-parser limitation) directly in core. Replace it with an inversion-of-control hook: core exposes register_fixed_root_joint_creator and delegates to the registered creator; the PhysX package registers _create_fixed_root_joint on import. Core's articulation-root writer now carries no PhysX-specific logic; the enable-existing-joint path stays in core (it is generic UsdPhysics). Legacy modify_articulation_root_properties is left untouched. --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 77 ++++++++----------- .../isaaclab_physx/sim/schemas/__init__.py | 71 +++++++++++++++++ 2 files changed, 101 insertions(+), 47 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 5be1e9879ed..e59ab078daa 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -268,6 +268,27 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ +# Backend-registered creator that fixes an articulation base by authoring a world<->root fixed joint. +# Creating it is backend-specific (the PhysX parser requires relocating the articulation root to the +# parent prim), so the backend registers its implementation via :func:`register_fixed_root_joint_creator` +# rather than core carrying the backend logic. +_FIXED_ROOT_JOINT_CREATOR = None + + +def register_fixed_root_joint_creator(creator) -> None: + """Register the backend handler that fixes an articulation base via a world<->root fixed joint. + + Called by :func:`apply_articulation_root_properties` when ``fix_root_link=True`` and no fixed joint + yet exists. Keeps the backend-specific joint-creation logic (e.g. PhysX's parser workaround) out of + core; a physics backend extension (e.g. ``isaaclab_physx``) registers its creator on import. + + Args: + creator: A callable ``creator(articulation_prim, stage)`` that authors the fixed joint. + """ + global _FIXED_ROOT_JOINT_CREATOR + _FIXED_ROOT_JOINT_CREATOR = creator + + def apply_articulation_root_properties( prim_path: str, fragments, @@ -344,54 +365,16 @@ def apply_articulation_root_properties( existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) elif fix_root_link: logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") - - # note: we have to assume that the root prim is a rigid body, - # i.e. we don't handle the case where the root prim is not a rigid body but has articulation api on it - # Currently, there is no obvious way to get first rigid body link identified by the PhysX parser - if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): - raise NotImplementedError( - f"The articulation prim '{root_path}' does not have the RigidBodyAPI applied." - " To create a fixed joint, we need to determine the first rigid body link in" - " the articulation tree. However, this is not implemented yet." + # Creating a world<->root fixed joint to fix the base is backend-specific (the PhysX parser + # needs the articulation root relocated to the parent prim). Delegate to the backend-registered + # creator so core carries no backend logic; see register_fixed_root_joint_creator. + if _FIXED_ROOT_JOINT_CREATOR is None: + raise RuntimeError( + f"Cannot fix the articulation root '{root_path}': no fixed-root-joint creator is" + " registered. Import a physics backend extension (e.g. isaaclab_physx) so it can" + " register one." ) - - # create a fixed joint between the root link and the world frame - from omni.physx.scripts import utils as physx_utils - - physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim) - - # Having a fixed joint on a rigid body is not treated as "fixed base articulation". - # instead, it is treated as a part of the maximal coordinate tree. - # Moving the articulation root to the parent solves this issue. This is a limitation of the PhysX parser. - # get parent prim - parent_prim = articulation_prim.GetParent() - # apply api to parent - UsdPhysics.ArticulationRootAPI.Apply(parent_prim) - parent_applied = parent_prim.GetAppliedSchemas() - if "PhysxArticulationAPI" not in parent_applied: - parent_prim.AddAppliedSchema("PhysxArticulationAPI") - - # copy the attributes - # -- usd attributes - usd_articulation_api = UsdPhysics.ArticulationRootAPI(articulation_prim) - for attr_name in usd_articulation_api.GetSchemaAttributeNames(): - attr = articulation_prim.GetAttribute(attr_name) - parent_attr = parent_prim.GetAttribute(attr_name) - if not parent_attr: - parent_attr = parent_prim.CreateAttribute(attr_name, attr.GetTypeName()) - parent_attr.Set(attr.Get()) - # -- physx attributes (copy by name prefix) - for attr in articulation_prim.GetAttributes(): - aname = attr.GetName() - if aname.startswith("physxArticulation:"): - parent_attr = parent_prim.GetAttribute(aname) - if not parent_attr: - parent_attr = parent_prim.CreateAttribute(aname, attr.GetTypeName()) - parent_attr.Set(attr.Get()) - - # remove api from root - articulation_prim.RemoveAppliedSchema("PhysxArticulationAPI") - articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) + _FIXED_ROOT_JOINT_CREATOR(articulation_prim, stage) return True diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index 47f0b1e8ae9..a30dc37af46 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -5,6 +5,77 @@ """Sub-module containing PhysX schema configuration exports.""" +from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export lazy_export() + + +def _create_fixed_root_joint(articulation_prim, stage) -> None: + """Fix an articulation base by authoring a world<->root fixed joint (PhysX parser semantics). + + Backend creator registered with the core articulation-root writer. Creating the joint relies on + the PhysX parser, which does not treat a fixed joint on a rigid body as a fixed-base articulation; + relocating the ``UsdPhysics.ArticulationRootAPI`` (and its ``physxArticulation:*`` attributes) to + the parent prim works around that. This PhysX-specific logic lives in the PhysX package so core + carries none of it. + + Args: + articulation_prim: The resolved articulation-root prim to fix to the world frame. + stage: The stage the prim lives on. + + Raises: + NotImplementedError: When the root prim is not a rigid body (the first rigid-body link cannot + be determined to attach the fixed joint). + """ + from pxr import UsdPhysics + + from omni.physx.scripts import utils as physx_utils + + # note: we assume the root prim is a rigid body; there is no obvious way to get the first rigid + # body link identified by the PhysX parser when it is not. + if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): + raise NotImplementedError( + f"The articulation prim '{articulation_prim.GetPath().pathString}' does not have the" + " RigidBodyAPI applied. To create a fixed joint, we need to determine the first rigid body" + " link in the articulation tree. However, this is not implemented yet." + ) + + # create a fixed joint between the root link and the world frame + physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim) + + # Having a fixed joint on a rigid body is not treated as "fixed base articulation"; it is treated as + # part of the maximal coordinate tree. Moving the articulation root to the parent solves this (a + # limitation of the PhysX parser). + parent_prim = articulation_prim.GetParent() + UsdPhysics.ArticulationRootAPI.Apply(parent_prim) + if "PhysxArticulationAPI" not in parent_prim.GetAppliedSchemas(): + parent_prim.AddAppliedSchema("PhysxArticulationAPI") + + # copy the attributes to the parent + # -- usd attributes + usd_articulation_api = UsdPhysics.ArticulationRootAPI(articulation_prim) + for attr_name in usd_articulation_api.GetSchemaAttributeNames(): + attr = articulation_prim.GetAttribute(attr_name) + parent_attr = parent_prim.GetAttribute(attr_name) + if not parent_attr: + parent_attr = parent_prim.CreateAttribute(attr_name, attr.GetTypeName()) + parent_attr.Set(attr.Get()) + # -- physx attributes (copy by name prefix) + for attr in articulation_prim.GetAttributes(): + aname = attr.GetName() + if aname.startswith("physxArticulation:"): + parent_attr = parent_prim.GetAttribute(aname) + if not parent_attr: + parent_attr = parent_prim.CreateAttribute(aname, attr.GetTypeName()) + parent_attr.Set(attr.Get()) + + # remove the api from the (former) root + articulation_prim.RemoveAppliedSchema("PhysxArticulationAPI") + articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) + + +# Keep PhysX articulation-root logic out of core: register the creator with the core articulation-root +# writer. Registered on import of this package (which a caller does to construct PhysX schema +# fragments), inverting the dependency so core carries no PhysX-specific articulation logic. +register_fixed_root_joint_creator(_create_fixed_root_joint) From f1892ef3e726f6a4ce831726a39e80e835d4edee Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Fri, 26 Jun 2026 12:56:38 -0700 Subject: [PATCH 027/139] Add core-level tests for the fixed-root-joint creator registry Cover the registry independent of PhysX: the writer delegates to a registered creator, and raises a clear error when none is registered. Uses monkeypatch to isolate the module-global creator. --- .../test/sim/test_articulation_fragments.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 8f16c6ab057..15949b7c8aa 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -247,6 +247,52 @@ def test_apply_articulation_root_properties_fix_root_link_requires_rigid_body(): ) +def test_apply_articulation_root_properties_fix_root_link_without_backend_raises(monkeypatch): + """Creating a fixed root joint with no backend creator registered raises a clear error: the + PhysX-specific creation logic lives in the backend, so core cannot fix the base on its own. + + Uses monkeypatch to clear the module-global creator (other tests register the PhysX creator + session-wide); monkeypatch restores it afterwards so this stays isolated. + """ + from isaaclab.sim.schemas import apply_articulation_root_properties + from isaaclab.sim.schemas import schemas as core_schemas + + monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATOR", None) + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/Robot3") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + with pytest.raises(RuntimeError): + apply_articulation_root_properties("/World/Robot3", [], stage, fix_root_link=True) + + +def test_register_fixed_root_joint_creator_is_invoked(monkeypatch): + """The core writer delegates fixed-root-joint creation to whatever creator is registered.""" + from isaaclab.sim.schemas import apply_articulation_root_properties + from isaaclab.sim.schemas import schemas as core_schemas + from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator + + monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATOR", None) + called = {} + + def _creator(articulation_prim, stage): + called["path"] = articulation_prim.GetPath().pathString + + register_fixed_root_joint_creator(_creator) + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/Robot4") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + apply_articulation_root_properties("/World/Robot4", [], stage, fix_root_link=True) + assert called["path"] == "/World/Robot4" + + # ------------------------------------------------------------------------------------- # public imports # ------------------------------------------------------------------------------------- From 45165ab9cedd6975cf60bef6a6e5ffdf8fc2bbc3 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Fri, 26 Jun 2026 13:12:16 -0700 Subject: [PATCH 028/139] Document RuntimeError in apply_articulation_root_properties Address greptile: the Raises section omitted the RuntimeError raised when fix_root_link=True and no fixed-root-joint creator is registered. Also clarify that NotImplementedError is propagated from the registered backend creator rather than raised by core directly. --- source/isaaclab/isaaclab/sim/schemas/schemas.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index e59ab078daa..712232769f7 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -327,7 +327,11 @@ def apply_articulation_root_properties( Raises: ValueError: When the prim path is not valid. - NotImplementedError: When the root prim is not a rigid body and a fixed joint is to be created. + RuntimeError: When ``fix_root_link`` is True and a fixed joint must be created, but no backend + creator has been registered (see :func:`register_fixed_root_joint_creator`) -- e.g. no + physics backend extension such as ``isaaclab_physx`` was imported. + NotImplementedError: Propagated from the registered creator when it cannot create the fixed + joint (e.g. the resolved root prim is not a rigid body). """ if stage is None: stage = get_current_stage() From 6a0775e39aadf8d22d287dbbe3e662aa19755446 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Fri, 26 Jun 2026 14:09:50 -0700 Subject: [PATCH 029/139] Make fixed-root-joint creation backend-aware so Newton runs on its own Previously only isaaclab_physx registered a fixed-root-joint creator, so a Newton-only import hit a RuntimeError on fix_root_link=True even though Newton needs the joint (it defaults a jointless root to floating). Make the registry backend-aware: each backend registers a creator plus an is_active predicate, and the writer selects the creator matching the running backend. - isaaclab_physx: createJoint + the PhysX-parser root relocation (active when the sim uses PhysxCfg). - isaaclab_newton: authors the world<->root UsdPhysics.FixedJoint via the Kit omni.physx helper (no relocation; Newton reads the joint directly), active when the sim uses NewtonCfg. Nothing is hand-rolled (omni.physx.createJoint is a Kit utility present in any Isaac Sim app). Core stays backend-free; both backends work standalone, and a both-imported run picks the active backend's creator. --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 52 ++++++++++++------- .../test/sim/test_articulation_fragments.py | 22 ++++---- .../isaaclab_newton/sim/schemas/__init__.py | 47 +++++++++++++++++ .../isaaclab_physx/sim/schemas/__init__.py | 18 +++++-- 4 files changed, 106 insertions(+), 33 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 712232769f7..92a1e0de65c 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -268,25 +268,40 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ -# Backend-registered creator that fixes an articulation base by authoring a world<->root fixed joint. -# Creating it is backend-specific (the PhysX parser requires relocating the articulation root to the -# parent prim), so the backend registers its implementation via :func:`register_fixed_root_joint_creator` -# rather than core carrying the backend logic. -_FIXED_ROOT_JOINT_CREATOR = None +# Backend-registered creators that fix an articulation base by authoring a world<->root fixed joint. +# Creating it is backend-specific (PhysX requires relocating the articulation root to the parent prim +# to work around its parser; Newton reads the fixed joint directly), so each backend registers its own +# creator plus a predicate that reports when that backend is the active simulation. This keeps core +# free of backend knowledge while letting each backend run on its own. +_FIXED_ROOT_JOINT_CREATORS: list = [] -def register_fixed_root_joint_creator(creator) -> None: - """Register the backend handler that fixes an articulation base via a world<->root fixed joint. +def register_fixed_root_joint_creator(creator, is_active) -> None: + """Register a backend creator that fixes an articulation base via a world<->root fixed joint. Called by :func:`apply_articulation_root_properties` when ``fix_root_link=True`` and no fixed joint - yet exists. Keeps the backend-specific joint-creation logic (e.g. PhysX's parser workaround) out of - core; a physics backend extension (e.g. ``isaaclab_physx``) registers its creator on import. + yet exists. Multiple backends may register; the creator whose ``is_active()`` predicate returns True + for the running simulation is selected, so each physics backend (e.g. ``isaaclab_physx``, + ``isaaclab_newton``) provides and is matched to its own implementation without core knowing any of + them. Args: creator: A callable ``creator(articulation_prim, stage)`` that authors the fixed joint. + is_active: A callable ``is_active() -> bool`` returning True when ``creator``'s backend is the + active simulation backend. """ - global _FIXED_ROOT_JOINT_CREATOR - _FIXED_ROOT_JOINT_CREATOR = creator + _FIXED_ROOT_JOINT_CREATORS.append((is_active, creator)) + + +def _resolve_fixed_root_joint_creator(): + """Return the creator whose backend is active, or None if no registered backend matches.""" + for is_active, creator in _FIXED_ROOT_JOINT_CREATORS: + try: + if is_active(): + return creator + except Exception: # noqa: BLE001 -- a backend's probe must never break creator resolution + continue + return None def apply_articulation_root_properties( @@ -369,16 +384,17 @@ def apply_articulation_root_properties( existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) elif fix_root_link: logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") - # Creating a world<->root fixed joint to fix the base is backend-specific (the PhysX parser - # needs the articulation root relocated to the parent prim). Delegate to the backend-registered - # creator so core carries no backend logic; see register_fixed_root_joint_creator. - if _FIXED_ROOT_JOINT_CREATOR is None: + # Creating a world<->root fixed joint to fix the base is backend-specific. Delegate to the + # creator for the active backend so core carries no backend logic; see + # register_fixed_root_joint_creator. + creator = _resolve_fixed_root_joint_creator() + if creator is None: raise RuntimeError( f"Cannot fix the articulation root '{root_path}': no fixed-root-joint creator is" - " registered. Import a physics backend extension (e.g. isaaclab_physx) so it can" - " register one." + " registered for the active physics backend. Import the matching backend extension" + " (e.g. isaaclab_physx or isaaclab_newton) so it can register one." ) - _FIXED_ROOT_JOINT_CREATOR(articulation_prim, stage) + creator(articulation_prim, stage) return True diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 15949b7c8aa..cd145277390 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -248,16 +248,16 @@ def test_apply_articulation_root_properties_fix_root_link_requires_rigid_body(): def test_apply_articulation_root_properties_fix_root_link_without_backend_raises(monkeypatch): - """Creating a fixed root joint with no backend creator registered raises a clear error: the - PhysX-specific creation logic lives in the backend, so core cannot fix the base on its own. + """Creating a fixed root joint with no registered backend creator raises a clear error: the + backend-specific creation logic lives in the backends, so core cannot fix the base on its own. - Uses monkeypatch to clear the module-global creator (other tests register the PhysX creator + Uses monkeypatch to clear the module-global creator registry (other tests/backends register session-wide); monkeypatch restores it afterwards so this stays isolated. """ from isaaclab.sim.schemas import apply_articulation_root_properties from isaaclab.sim.schemas import schemas as core_schemas - monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATOR", None) + monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATORS", []) sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -269,19 +269,19 @@ def test_apply_articulation_root_properties_fix_root_link_without_backend_raises apply_articulation_root_properties("/World/Robot3", [], stage, fix_root_link=True) -def test_register_fixed_root_joint_creator_is_invoked(monkeypatch): - """The core writer delegates fixed-root-joint creation to whatever creator is registered.""" +def test_register_fixed_root_joint_creator_selects_active_backend(monkeypatch): + """The writer selects the registered creator whose backend predicate reports active, and ignores + creators whose backend is inactive.""" from isaaclab.sim.schemas import apply_articulation_root_properties from isaaclab.sim.schemas import schemas as core_schemas from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator - monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATOR", None) + monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATORS", []) called = {} - def _creator(articulation_prim, stage): - called["path"] = articulation_prim.GetPath().pathString - - register_fixed_root_joint_creator(_creator) + # an inactive backend's creator must be skipped, the active one's used + register_fixed_root_joint_creator(lambda p, s: called.__setitem__("path", "INACTIVE"), lambda: False) + register_fixed_root_joint_creator(lambda p, s: called.__setitem__("path", p.GetPath().pathString), lambda: True) sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index e79e72e10df..f3b406919c2 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -5,6 +5,53 @@ """Sub-module containing Newton schema configuration exports.""" +from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export lazy_export() + + +def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: + """Fix an articulation base by authoring a world<->root ``UsdPhysics.FixedJoint``. + + Backend creator registered with the core articulation-root writer for the Newton backend. Newton's + importer reads a ``UsdPhysics.FixedJoint`` directly as a fixed root joint (a jointless root would + otherwise default to floating), so -- unlike PhysX -- no articulation-root relocation is needed. The + joint is authored with the Kit ``omni.physx`` utility (a USD-authoring helper present in any Isaac + Sim app regardless of the active solver), so nothing is hand-rolled. + + Args: + articulation_prim: The resolved articulation-root prim to fix to the world frame. + stage: The stage the prim lives on. + + Raises: + NotImplementedError: When the root prim is not a rigid body (the first rigid-body link cannot be + determined to anchor the fixed joint). + """ + from pxr import UsdPhysics + + from omni.physx.scripts import utils as physx_utils + + if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): + raise NotImplementedError( + f"The articulation prim '{articulation_prim.GetPath().pathString}' does not have the" + " RigidBodyAPI applied. To create a fixed joint, we need to determine the first rigid body" + " link in the articulation tree. However, this is not implemented yet." + ) + physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim) + + +def _newton_is_active() -> bool: + """Return whether the running simulation uses the Newton backend.""" + from isaaclab.sim import SimulationContext + + from isaaclab_newton.physics import NewtonCfg + + sim = SimulationContext.instance() + return sim is not None and isinstance(sim.cfg.physics, NewtonCfg) + + +# Register the Newton fixed-root-joint creator (matched to the active Newton backend) so fixing an +# articulation base works in a Newton-only run, without core carrying any backend logic. Registered on +# import of this package (which a caller does to construct Newton schema fragments). +register_fixed_root_joint_creator(_create_fixed_root_joint_newton, _newton_is_active) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index a30dc37af46..0cea9e04b7c 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -75,7 +75,17 @@ def _create_fixed_root_joint(articulation_prim, stage) -> None: articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) -# Keep PhysX articulation-root logic out of core: register the creator with the core articulation-root -# writer. Registered on import of this package (which a caller does to construct PhysX schema -# fragments), inverting the dependency so core carries no PhysX-specific articulation logic. -register_fixed_root_joint_creator(_create_fixed_root_joint) +def _physx_is_active() -> bool: + """Return whether the running simulation uses the PhysX backend.""" + from isaaclab.sim import SimulationContext + + from isaaclab_physx.physics import PhysxCfg + + sim = SimulationContext.instance() + return sim is not None and isinstance(sim.cfg.physics, PhysxCfg) + + +# Keep PhysX articulation-root logic out of core: register the creator (matched to the active PhysX +# backend) with the core articulation-root writer. Registered on import of this package (which a caller +# does to construct PhysX schema fragments), inverting the dependency so core carries no PhysX logic. +register_fixed_root_joint_creator(_create_fixed_root_joint, _physx_is_active) From 6105255a45c05971ee913744883d8072a4dfdfea Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Fri, 26 Jun 2026 14:52:45 -0700 Subject: [PATCH 030/139] Updates docker setups and versions and fixes from release branch (#6262) # Description Cherry picks commits from the release branch that fixed some docker setup and version updates: - #6169 - #6192 - #6195 - #6209 - #6211 ## 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 --------- Co-authored-by: Sheikh Dawood <7774242+sheikh-nv@users.noreply.github.com> --- docker/.env.base | 5 +- docker/Dockerfile.base | 31 +++-- docker/Dockerfile.curobo | 29 +++-- docs/source/deployment/docker.rst | 6 +- docs/source/setup/installation/index.rst | 2 + .../isaaclab_pip_installation.rst | 11 +- .../fix-arm-baremetal-nlopt-swig.rst | 7 + .../changelog.d/fix-arm-docker-install.rst | 6 + source/isaaclab/docs/CHANGELOG.rst | 2 - .../isaaclab/isaaclab/cli/commands/install.py | 123 +++++++++++++----- .../test/install_ci/Dockerfile.installci | 8 +- tools/wheel_builder/res/python_packages.toml | 9 -- 12 files changed, 151 insertions(+), 88 deletions(-) create mode 100644 source/isaaclab/changelog.d/fix-arm-baremetal-nlopt-swig.rst create mode 100644 source/isaaclab/changelog.d/fix-arm-docker-install.rst diff --git a/docker/.env.base b/docker/.env.base index 0cc9ff09c01..555a91fd5ae 100644 --- a/docker/.env.base +++ b/docker/.env.base @@ -6,9 +6,8 @@ ACCEPT_EULA=Y # NVIDIA Isaac Sim base image ISAACSIM_BASE_IMAGE=nvcr.io/nvidia/isaac-sim -# NVIDIA Isaac Sim version to use (e.g. 6.0.0-dev2) -# TODO(AntoineRichard): Revert to stable 6.0.0 tag once it ships on NGC -ISAACSIM_VERSION=6.0.0-dev2 +# NVIDIA Isaac Sim version to use (e.g. 6.0.0) +ISAACSIM_VERSION=6.0.0 # Derived from the default path in the NVIDIA provided Isaac Sim container DOCKER_ISAACSIM_ROOT_PATH=/isaac-sim # The Isaac Lab path in the container diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index 646c9af9ab4..f46bc3f89b2 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -88,15 +88,11 @@ RUN install_carb_env_shim() { \ # arm64-only build deps: # - imgui-bundle has no prebuilt arm64 wheel; needs GL/X11 dev headers. -# - swig is required for the nlopt source build (see arm64 nlopt step below). -# - libgmp-dev is required by pytetwild's fTetWild source build (no aarch64 wheel for 0.2.3). RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ apt-get update && \ apt-get install -y --no-install-recommends \ libgl1-mesa-dev libopengl-dev libglx-dev \ - libx11-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev \ - libgmp-dev \ - swig; \ + libx11-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev; \ fi # copy files necessary for installing Isaac Lab and its dependencies @@ -143,23 +139,26 @@ RUN touch /bin/nvidia-smi && \ mkdir -p /var/run/nvidia-persistenced && \ touch /var/run/nvidia-persistenced/socket -# On arm64, pre-install nlopt 2.6.2 (transitively pinned by isaacteleop[retargeters]). -# There is no aarch64 wheel for this version, and pip's isolated build env hides the -# system numpy from nlopt's cmake-based build, so we install it manually with -# --no-build-isolation. setuptools+wheel+numpy must be importable in the host Python -# for that mode (PEP 517 backend lookup). The subsequent isaaclab.sh --install then -# sees nlopt as already satisfied and skips the from-source rebuild that would fail. +# On arm64, temporarily install swig for the nlopt source build, keep it +# through the Isaac Lab dependency install, then purge it before the layer ends. +# This lets ./isaaclab.sh --install see nlopt as already satisfied without +# leaving swig in the final container filesystem. RUN --mount=type=cache,target=${DOCKER_USER_HOME}/.cache/pip \ if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ + apt-get update && \ + apt-get install -y --no-install-recommends swig && \ ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install setuptools wheel numpy && \ ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install --no-build-isolation nlopt==2.6.2; \ + fi && \ + ${ISAACLAB_PATH}/isaaclab.sh --install && \ + if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ + ${ISAACLAB_PATH}/isaaclab.sh -p -c "import nlopt" && \ + apt-get purge -y --auto-remove swig && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + if command -v swig >/dev/null 2>&1; then exit 1; fi; \ fi -# installing Isaac Lab dependencies -# use pip caching to avoid reinstalling large packages -RUN --mount=type=cache,target=${DOCKER_USER_HOME}/.cache/pip \ - ${ISAACLAB_PATH}/isaaclab.sh --install - # HACK: Remove install of quadprog dependency RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip uninstall -y quadprog diff --git a/docker/Dockerfile.curobo b/docker/Dockerfile.curobo index b63fdacdf98..598b7ea9a42 100644 --- a/docker/Dockerfile.curobo +++ b/docker/Dockerfile.curobo @@ -51,13 +51,11 @@ RUN apt-get update && \ # arm64-only build deps: # - imgui-bundle has no prebuilt arm64 wheel; needs GL/X11 dev headers. -# - swig is required for the nlopt source build (see arm64 nlopt step below). RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ apt-get update && \ apt-get install -y --no-install-recommends \ libgl1-mesa-dev libopengl-dev libglx-dev \ - libx11-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev \ - swig; \ + libx11-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev; \ fi # Detect Ubuntu version and install CUDA 12.8 via NVIDIA network repo (cuda-keyring) @@ -152,23 +150,26 @@ RUN rm -rf ${ISAACSIM_ROOT_PATH}/kit/python/lib/python3.12/site-packages/pip* && ${ISAACLAB_PATH}/_isaac_sim/kit/python/bin/python3 get-pip.py && \ rm get-pip.py -# On arm64, pre-install nlopt 2.6.2 (transitively pinned by isaacteleop[retargeters]). -# There is no aarch64 wheel for this version, and pip's isolated build env hides the -# system numpy from nlopt's cmake-based build, so we install it manually with -# --no-build-isolation. setuptools+wheel+numpy must be importable in the host Python -# for that mode (PEP 517 backend lookup). The subsequent isaaclab.sh --install then -# sees nlopt as already satisfied and skips the from-source rebuild that would fail. +# On arm64, temporarily install swig for the nlopt source build, keep it +# through the Isaac Lab dependency install, then purge it before the layer ends. +# This lets ./isaaclab.sh --install see nlopt as already satisfied without +# leaving swig in the final container filesystem. RUN --mount=type=cache,target=${DOCKER_USER_HOME}/.cache/pip \ if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ + apt-get update && \ + apt-get install -y --no-install-recommends swig && \ ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install setuptools wheel numpy && \ ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install --no-build-isolation nlopt==2.6.2; \ + fi && \ + ${ISAACLAB_PATH}/isaaclab.sh --install && \ + if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ + ${ISAACLAB_PATH}/isaaclab.sh -p -c "import nlopt" && \ + apt-get purge -y --auto-remove swig && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + if command -v swig >/dev/null 2>&1; then exit 1; fi; \ fi -# installing Isaac Lab dependencies -# use pip caching to avoid reinstalling large packages -RUN --mount=type=cache,target=${DOCKER_USER_HOME}/.cache/pip \ - ${ISAACLAB_PATH}/isaaclab.sh --install - # HACK: Uninstall quadprog as it causes issues with some reinforcement learning frameworks RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip uninstall -y quadprog diff --git a/docs/source/deployment/docker.rst b/docs/source/deployment/docker.rst index c5a540de66a..c5eadb3c541 100644 --- a/docs/source/deployment/docker.rst +++ b/docs/source/deployment/docker.rst @@ -322,7 +322,7 @@ To pull the minimal Isaac Lab container, run: .. code:: bash - docker pull nvcr.io/nvidia/isaac-lab:3.0.0-beta1 + docker pull nvcr.io/nvidia/isaac-lab:3.0.0-beta2 .. attention:: @@ -353,7 +353,7 @@ To run the Isaac Lab container with an interactive bash session, run: -v ~/docker/isaac-sim/logs:/root/.nvidia-omniverse/logs:rw \ -v ~/docker/isaac-sim/data:/root/.local/share/ov/data:rw \ -v ~/docker/isaac-sim/documents:/root/Documents:rw \ - nvcr.io/nvidia/isaac-lab:3.0.0-beta1 + nvcr.io/nvidia/isaac-lab:3.0.0-beta2 To enable rendering through X11 forwarding, run: @@ -372,7 +372,7 @@ To enable rendering through X11 forwarding, run: -v ~/docker/isaac-sim/logs:/root/.nvidia-omniverse/logs:rw \ -v ~/docker/isaac-sim/data:/root/.local/share/ov/data:rw \ -v ~/docker/isaac-sim/documents:/root/Documents:rw \ - nvcr.io/nvidia/isaac-lab:3.0.0-beta1 + nvcr.io/nvidia/isaac-lab:3.0.0-beta2 To run an example within the container, run: diff --git a/docs/source/setup/installation/index.rst b/docs/source/setup/installation/index.rst index 18c83f3b698..fef6b41726b 100644 --- a/docs/source/setup/installation/index.rst +++ b/docs/source/setup/installation/index.rst @@ -103,6 +103,8 @@ Other notable limitations with respect to Isaac Lab include... ``pytetwild`` wheel is available for ARM (aarch64). ``pytetwild`` is required for automatic tetrahedral mesh generation of volume deformables. +#. RLinf support has not been verified on DGX Spark. + .. note:: **Build prerequisites on aarch64:** Some Python packages (notably ``imgui-bundle`` and ``quadprog``) do not ship diff --git a/docs/source/setup/installation/isaaclab_pip_installation.rst b/docs/source/setup/installation/isaaclab_pip_installation.rst index 4da9ee305ee..1fa49c5532c 100644 --- a/docs/source/setup/installation/isaaclab_pip_installation.rst +++ b/docs/source/setup/installation/isaaclab_pip_installation.rst @@ -32,7 +32,7 @@ pip extras include: * - ``isaacsim`` - Isaac Sim (``isaacsim[all,extscache]==6.0.0.1``) from `pypi.nvidia.com `_ * - ``all`` - - RL frameworks (SB3, SKRL, RL-Games, RSL-RL). Combine with ``isaacsim`` for a full install. + - RL frameworks (SB3, SKRL, RSL-RL). Combine with ``isaacsim`` for a full install. Install with ``isaaclab[isaacsim,all]`` for the full workflow. @@ -50,6 +50,15 @@ Install with ``isaaclab[isaacsim,all]`` for the full workflow. pip install "isaaclab[isaacsim,all]" --extra-index-url https://pypi.nvidia.com --pre +.. note:: + + ``rl_games`` is not included in the Isaac Lab pip wheel extras. If your workflow requires + ``rl_games``, install it manually from the Isaac Lab-compatible branch: + + .. code-block:: bash + + pip install "rl-games @ git+https://github.com/isaac-sim/rl_games.git@python3.11" gym standard-distutils + Installing dependencies ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/changelog.d/fix-arm-baremetal-nlopt-swig.rst b/source/isaaclab/changelog.d/fix-arm-baremetal-nlopt-swig.rst new file mode 100644 index 00000000000..1f41e192f04 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-arm-baremetal-nlopt-swig.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed native (non-Docker) ARM installation failing when ``swig`` is missing. + The ``nlopt==2.6.2`` source build now installs ``swig`` via apt only for the + duration of the build and purges it afterwards, so the GPL-licensed ``swig`` + is never left behind — in particular it is never shipped in the Docker image. diff --git a/source/isaaclab/changelog.d/fix-arm-docker-install.rst b/source/isaaclab/changelog.d/fix-arm-docker-install.rst new file mode 100644 index 00000000000..b33d98ab088 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-arm-docker-install.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed Linux ARM installation by pre-installing ``nlopt==2.6.2`` before + Isaac Lab dependencies are resolved, and by keeping ``swig`` available in + Docker only until dependency installation completes. diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 62f27539be6..5b95d80fb26 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -563,8 +563,6 @@ Added :func:`~isaaclab.sim.utils.resolve_matching_prims_from_source` to allow callers to opt out of the default no-match error. * Added Kit version, kernel version, and git hash logging to :class:`~isaaclab.app.AppLauncher` startup. -* Added the ``rl-games`` optional dependency to the ``isaaclab`` wheel and - included it in the wheel's ``all`` extra. * Added :attr:`~isaaclab.managers.EventTermCfg.resample_interval_on_reset` to allow ``"interval"`` event terms to keep their per-environment timer across resets while still firing asynchronously per environment. Defaults to ``True`` to preserve the existing behavior. diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index be7c3aa5b0e..6154d19b0ea 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -79,22 +79,6 @@ def _install_system_deps() -> None: ] run_command(["sudo"] + cmd if os.geteuid() != 0 else cmd) - # nlopt has no aarch64 manylinux wheel for the version pinned by - # isaacteleop[retargeters], so pip falls back to a CMake source build - # that needs SWIG. Mirrors the apt step in docker/Dockerfile.base. - if not shutil.which("swig"): - if os.geteuid() != 0 and not shutil.which("sudo"): - print_info( - "swig is missing and sudo is unavailable; skipping swig install. " - "Pre-install swig in your image if you need to build nlopt from source." - ) - else: - print_info("Installing swig (required for building nlopt on ARM)...") - cmd = ["apt-get", "update"] - run_command(["sudo"] + cmd if os.geteuid() != 0 else cmd) - cmd = ["apt-get", "install", "-y", "--no-install-recommends", "swig"] - run_command(["sudo"] + cmd if os.geteuid() != 0 else cmd) - # imgui-bundle has no aarch64 manylinux wheel, so pip falls back to a # CMake source build that needs GL/X11 dev headers (via glfw). # Mirrors the apt step in docker/Dockerfile.base. @@ -154,25 +138,6 @@ def _torch_first_on_sys_path_is_prebundle(python_exe: str, *, env: dict[str, str return result.returncode == 1 -def _maybe_preinstall_arm_nlopt(pip_cmd: list[str]) -> None: - """Pre-install ``nlopt==2.6.2`` on ARM Linux to skip the source-build fallback. - - There is no aarch64 manylinux wheel for the ``nlopt 2.6.2`` version pinned - by ``isaacteleop[retargeters]``, so pip falls back to a CMake source build - that hides the host-Python ``numpy`` from its isolated build env. Mirror - the docker/Dockerfile.base arm64 step: install ``setuptools wheel numpy`` - in the host Python first, then ``--no-build-isolation`` install nlopt so - later submodule installs see it as already satisfied. - """ - if is_windows() or not is_arm(): - return - print_info("Pre-installing nlopt==2.6.2 on ARM (no-build-isolation)...") - print_info(" step 1/2: ensure setuptools/wheel/numpy are importable for the no-build-isolation backend") - run_command(pip_cmd + ["install", "setuptools", "wheel", "numpy"]) - print_info(" step 2/2: install nlopt==2.6.2 with --no-build-isolation") - run_command(pip_cmd + ["install", "--no-build-isolation", "nlopt==2.6.2"]) - - def _maybe_uninstall_prebundled_torch( python_exe: str, pip_cmd: list[str], @@ -195,6 +160,92 @@ def _maybe_uninstall_prebundled_torch( ) +def _ensure_swig_installed() -> bool: + """Install ``swig`` via apt when missing so the nlopt source build can run. + + Returns: + ``True`` when this call installed ``swig`` (so the caller is responsible + for purging it afterwards), ``False`` when ``swig`` was already present or + could not be installed. + """ + if shutil.which("swig"): + return False + if os.geteuid() != 0 and not shutil.which("sudo"): + print_warning( + "swig is required to build nlopt==2.6.2 from source on ARM but is missing and sudo is " + "unavailable. Pre-install swig (or nlopt==2.6.2) manually; the build below will fail otherwise." + ) + return False + print_info("Temporarily installing swig to build nlopt==2.6.2 from source on ARM...") + update = ["apt-get", "update"] + run_command(["sudo"] + update if os.geteuid() != 0 else update) + install = ["apt-get", "install", "-y", "--no-install-recommends", "swig"] + run_command(["sudo"] + install if os.geteuid() != 0 else install) + return shutil.which("swig") is not None + + +def _purge_swig() -> None: + """Remove the ``swig`` package that was installed for the nlopt build. + + ``swig`` is GPL-licensed and must not be shipped (e.g. in the Docker image), + so it is purged immediately after nlopt is built. ``nlopt`` is already a + compiled wheel at this point and does not need ``swig`` at runtime. + Best-effort: failures are logged but do not abort the install. + """ + print_info("Removing swig now that nlopt is built (it must not remain installed)...") + purge = ["apt-get", "purge", "-y", "--auto-remove", "swig"] + run_command(["sudo"] + purge if os.geteuid() != 0 else purge, check=False) + + +def _maybe_preinstall_arm_nlopt(python_exe: str, pip_cmd: list[str]) -> None: + """Pre-install ``nlopt==2.6.2`` on ARM Linux to skip the source-build fallback. + + There is no aarch64 manylinux wheel for the ``nlopt 2.6.2`` version pinned + by ``isaacteleop[retargeters]``, so pip falls back to a CMake source build + that hides the host-Python ``numpy`` from its isolated build env. Mirror + the docker/Dockerfile.base arm64 step: install ``setuptools wheel numpy`` + in the host Python first, then ``--no-build-isolation`` install nlopt so + later submodule installs see it as already satisfied. + + The source build requires ``swig``. When it is missing it is installed via + apt only for the duration of the build and purged afterwards, so the + GPL-licensed ``swig`` package is never left behind — in particular it is + never shipped in the Docker image. In the Docker build nlopt is pre-installed, + so this function returns early and never touches ``swig`` (the Dockerfile + manages its own temporary swig install and purge). + """ + if is_windows() or not is_arm(): + return + + probe_result = run_command( + [ + python_exe, + "-c", + "import importlib.metadata as metadata; import nlopt; " + "raise SystemExit(0 if metadata.version('nlopt') == '2.6.2' else 1)", + ], + check=False, + capture_output=True, + text=True, + ) + if probe_result.returncode == 0: + print_info("nlopt==2.6.2 is already installed on ARM.") + return + + # The from-source build needs swig; install it only if missing and purge it + # afterwards so swig is never left behind (it is GPL and must not ship). + swig_installed_by_us = _ensure_swig_installed() + try: + print_info("Pre-installing nlopt==2.6.2 on ARM (no-build-isolation)...") + print_info(" step 1/2: ensure setuptools/wheel/numpy are importable for the no-build-isolation backend") + run_command(pip_cmd + ["install", "setuptools", "wheel", "numpy"]) + print_info(" step 2/2: install nlopt==2.6.2 with --no-build-isolation") + run_command(pip_cmd + ["install", "--no-build-isolation", "nlopt==2.6.2"]) + finally: + if swig_installed_by_us: + _purge_swig() + + # 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 @@ -953,7 +1004,7 @@ def append_submodules_once(package_dirs: tuple[str, ...]) -> None: run_command(pip_cmd + ["install", "setuptools<82.0.0"]) # On ARM Linux pre-install nlopt to dodge its from-source build fallback. - _maybe_preinstall_arm_nlopt(pip_cmd) + _maybe_preinstall_arm_nlopt(python_exe, pip_cmd) # Drop pip-installed torch if Isaac Sim's deprecated ML prebundle would shadow it. _maybe_uninstall_prebundled_torch(python_exe, pip_cmd, using_uv, probe_env=probe_env) diff --git a/source/isaaclab/test/install_ci/Dockerfile.installci b/source/isaaclab/test/install_ci/Dockerfile.installci index 662af204ce3..68b127df7b3 100644 --- a/source/isaaclab/test/install_ci/Dockerfile.installci +++ b/source/isaaclab/test/install_ci/Dockerfile.installci @@ -42,9 +42,9 @@ RUN apt-get update && \ sudo \ && rm -rf /var/lib/apt/lists/* -# ARM-specific build deps (swig, GL/X11 dev headers) are intentionally NOT -# pre-installed here. ``./isaaclab.sh -i`` auto-installs them via apt when -# missing, so install_ci exercises the real user-visible install path on ARM. +# ARM-specific GL/X11 dev headers are intentionally NOT pre-installed here. +# ``./isaaclab.sh -i`` auto-installs them via apt when missing, so install_ci +# exercises the real user-visible install path on ARM. # Make python3 the default python RUN ln -sf /usr/bin/python3 /usr/bin/python @@ -100,7 +100,7 @@ RUN if getent passwd ubuntu >/dev/null; then userdel -r ubuntu; fi \ && chown -R isaaclab:isaaclab ${ISAACLAB_PATH} # Passwordless sudo for the runtime user so ``./isaaclab.sh -i`` can apt-get -# install missing build deps (swig, GL/X11) without prompting at test time. +# install missing GL/X11 build deps without prompting at test time. RUN echo "isaaclab ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/isaaclab \ && chmod 0440 /etc/sudoers.d/isaaclab diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index 72926c0a93d..31ed6175059 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -97,12 +97,6 @@ pyproject.optional-dependencies.all = [ # RL libraries { "sb3" = ["stable-baselines3>=2.6", "tqdm", "rich"] }, { "skrl" = ["skrl>=2.1.0"] }, - # Do not pin aiohttp here: isaacsim-kernel==6.0.0.0 pins aiohttp==3.12.14. - { "rl-games" = [ - "rl-games @ git+https://github.com/isaac-sim/rl_games.git@python3.11", - "gym", - "standard-distutils", - ] }, { "rsl-rl" = ["rsl-rl-lib==5.0.1", "onnxscript>=0.5"] }, { "rsl_rl" = ["rsl-rl-lib==5.0.1", "onnxscript>=0.5"] }, # ================================================================================ @@ -118,9 +112,6 @@ pyproject.optional-dependencies.all = [ "tqdm", "rich", "skrl>=2.1.0", - "rl-games @ git+https://github.com/isaac-sim/rl_games.git@python3.11", - "gym", - "standard-distutils", "rsl-rl-lib==5.0.1", "onnxscript>=0.5", ] }, From 7c885dac0e5eb1a5551d0a46d9f79aaa995bf353 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Sat, 27 Jun 2026 00:54:55 +0200 Subject: [PATCH 031/139] Migrate OVPhysX RigidObject onto OvPhysxView (view series, part 3) (#6226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ### 🔗 Stacked on top of #6224 (Part 1 — `OvPhysxView`) > This PR depends on **#6224** and is branched off it (a *sibling* of the Articulation > migration #6225, not stacked on it). It targets `develop`, but because #6224 has not > merged yet, the diff below includes the Part 1 `OvPhysxView` commits **as well as** the > Part 3 migration. **Please review #6224 first.** Once #6224 merges, this branch will be > rebased onto `develop` and the diff will shrink to the rigid-object migration only. # Description Part 3 of the OVPhysX view-migration series. It migrates the OVPhysX **`RigidObject`** onto the `OvPhysxView` binding manager introduced in **#6224 (Part 1)**, mirroring the Articulation migration in **#6225 (Part 2)**. The series dogfoods `OvPhysxView` on the real asset classes to surface weaknesses and to consolidate the scattered `create_tensor_binding` / `binding.read` / `binding.write` calls behind one managed surface. ### What changed * `RigidObject._initialize_impl` builds a single `OvPhysxView(physx, pattern, device)` and eagerly creates the rigid-body bindings through it. RigidObject keeps its **fail-loud** contract (a missing core type raises a helpful `RuntimeError`) by creating each via `binding_for` rather than the best-effort `try_binding_for` the articulation uses. * `RigidObjectData` is constructed from the view; counts come from the view's bindings, and both `_get_binding` helpers delegate to `OvPhysxView.try_binding_for`. * **Every write goes through `root_view.set_attribute(...)`** — pose/velocity/wrench plus the CPU-only mass/COM/inertia properties (which the asset pre-stages to pinned CPU buffers, so the source is already on the binding's native device and the view's no-implicit-staging policy is satisfied). Writes now get the view's read-only/device/ dtype/contiguity guards. * **Every read goes through `root_view.read_into(...)`.** `RigidObjectData._read_binding_into` routes through the view, which caches the `float32` reinterpret per destination buffer and derives the structured layout from the binding shape — this path previously rebuilt the reinterpret on every read, so it is a strict read-cache improvement. CPU-only reads keep their pinned-host staging. * `RigidObject.root_view` now returns the `OvPhysxView` instead of a raw `dict[TensorType, TensorBinding]` (**breaking** — see migration note). ### Breaking change / migration `RigidObject.root_view` returns an `OvPhysxView`, not a dict: * `root_view[tensor_type]` → `root_view.try_binding_for(tensor_type)` * full-tensor read → `root_view.get_attribute(tensor_type)` ## Type of change - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] This change requires a documentation update ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have added tests that prove my fix is effective or that my feature works (rigid-object tests exercise the migrated read/write paths) - [x] I have updated the changelog (`source/isaaclab_ovphysx/changelog.d/`) - [ ] I have updated the documentation accordingly - [x] I have added my changes to the right `changelog.d` fragment(s) ### Testing Run kitless; cpu and cuda must be separate invocations (process-global device lock): ``` 42 passed, 10 xfailed # test_rigid_object.py -k cpu 41 passed, 2 skipped, 8 xfailed # test_rigid_object.py -k cuda 1 passed # test_rigid_object_helpers.py ``` --- ...er-feat-ovphysx_rigidobject_view.major.rst | 9 ++ .../assets/rigid_object/rigid_object.py | 121 ++++++++++-------- .../assets/rigid_object/rigid_object_data.py | 68 +++++----- .../test/assets/test_rigid_object.py | 4 +- 4 files changed, 114 insertions(+), 88 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject_view.major.rst diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject_view.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject_view.major.rst new file mode 100644 index 00000000000..61ad8f86c27 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject_view.major.rst @@ -0,0 +1,9 @@ +Changed +^^^^^^^ + +* **Breaking:** :attr:`~isaaclab_ovphysx.assets.RigidObject.root_view` now returns an + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw + ``dict`` mapping ``TensorType`` to ``TensorBinding``. The view owns all tensor-binding + creation, caching, reads, and writes for the rigid object. Replace ``root_view[tensor_type]`` + with ``root_view.try_binding_for(tensor_type)`` / + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.get_attribute`. 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 index 0428c9d2f67..f0df0e11255 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/rigid_object.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/rigid_object.py @@ -10,7 +10,6 @@ import re import warnings from collections.abc import Sequence -from typing import Any import numpy as np import torch @@ -29,6 +28,7 @@ from isaaclab_ovphysx.assets.kernels import _body_wrench_to_world from isaaclab_ovphysx.cloner import queue_ovphysx_replication from isaaclab_ovphysx.physics import OvPhysxManager +from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView from .rigid_object_data import RigidObjectData @@ -64,9 +64,9 @@ def __init__(self, cfg: RigidObjectCfg): """ super().__init__(cfg) queue_ovphysx_replication(cfg) - # Bindings are created lazily (on first access) to avoid allocating - # handles for tensor types the user never queries. - self._bindings: dict[int, Any] = {} + # The binding manager is created in ``_initialize_impl``; it owns all + # TensorBinding creation, caching, and the CPU/GPU device policy. + self._root_view: OvPhysxView | None = None """ Properties @@ -94,20 +94,23 @@ def body_names(self) -> list[str]: return self._body_names @property - def root_view(self) -> dict[int, Any]: + def root_view(self) -> OvPhysxView: """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 + On OVPhysX this is an :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`: a + string-keyed binding manager over the per-tensor-type ``TensorBinding`` handles, + rather than the single opaque view object used by the PhysX and Newton backends. + Address attributes by their lowercased ``TensorType`` name (e.g. + ``root_view.get_attribute("rigid_body_pose")``) or by the + :class:`~isaaclab_ovphysx.tensor_types.TensorType` member itself. For high-level + state access (instance counts, prim paths, transforms), prefer 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 + return self._root_view @property def instantaneous_wrench_composer(self) -> WrenchComposer | None: @@ -178,8 +181,7 @@ def write_data_to_sim(self) -> None: outputs=[self._wrench_buf], device=self._device, ) - binding = self._get_binding(TT.RIGID_BODY_WRENCH) - binding.write(self._wrench_buf_flat) + self._root_view.set_attribute(TT.RIGID_BODY_WRENCH, self._wrench_buf_flat) inst.reset() def update(self, dt: float) -> None: @@ -362,8 +364,9 @@ def write_root_link_pose_to_sim_index( if not skip_forward: self.data._reset_pose() # 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) + self._root_view.set_attribute( + TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids + ) def write_root_link_pose_to_sim_mask( self, @@ -401,8 +404,9 @@ def write_root_link_pose_to_sim_mask( ) if not skip_forward: self.data._reset_pose() - binding = self._get_binding(TT.RIGID_BODY_POSE) - binding.write(self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) + self._root_view.set_attribute( + TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp + ) def write_root_com_pose_to_sim_index( self, @@ -441,8 +445,9 @@ def write_root_com_pose_to_sim_index( ) if not skip_forward: self.data._reset_pose(from_link=False) - binding = self._get_binding(TT.RIGID_BODY_POSE) - binding.write(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) + self._root_view.set_attribute( + TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids + ) def write_root_com_pose_to_sim_mask( self, @@ -481,8 +486,9 @@ def write_root_com_pose_to_sim_mask( ) if not skip_forward: self.data._reset_pose(from_link=False) - binding = self._get_binding(TT.RIGID_BODY_POSE) - binding.write(self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) + self._root_view.set_attribute( + TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp + ) def write_root_com_velocity_to_sim_index( self, @@ -524,8 +530,9 @@ def write_root_com_velocity_to_sim_index( # Invalidate dependent timestamps. if not skip_forward: self.data._reset_velocity() - binding = self._get_binding(TT.RIGID_BODY_VELOCITY) - binding.write(self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids) + self._root_view.set_attribute( + TT.RIGID_BODY_VELOCITY, self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids + ) def write_root_com_velocity_to_sim_mask( self, @@ -566,8 +573,9 @@ def write_root_com_velocity_to_sim_mask( ) if not skip_forward: self.data._reset_velocity() - binding = self._get_binding(TT.RIGID_BODY_VELOCITY) - binding.write(self.data._root_com_vel_w.data.view(wp.float32), mask=env_mask_wp) + self._root_view.set_attribute( + TT.RIGID_BODY_VELOCITY, self.data._root_com_vel_w.data.view(wp.float32), mask=env_mask_wp + ) def write_root_link_velocity_to_sim_index( self, @@ -614,8 +622,9 @@ def write_root_link_velocity_to_sim_index( ) if not skip_forward: self.data._reset_velocity(from_com=False) - binding = self._get_binding(TT.RIGID_BODY_VELOCITY) - binding.write(self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids) + self._root_view.set_attribute( + TT.RIGID_BODY_VELOCITY, self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids + ) def write_root_link_velocity_to_sim_mask( self, @@ -656,8 +665,9 @@ def write_root_link_velocity_to_sim_mask( ) if not skip_forward: self.data._reset_velocity(from_com=False) - binding = self._get_binding(TT.RIGID_BODY_VELOCITY) - binding.write(self.data._root_com_vel_w.data.view(wp.float32), mask=env_mask_wp) + self._root_view.set_attribute( + TT.RIGID_BODY_VELOCITY, self.data._root_com_vel_w.data.view(wp.float32), mask=env_mask_wp + ) """ Operations - Setters. @@ -698,8 +708,7 @@ def set_masses_index( # 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) + self._root_view.set_attribute(TT.RIGID_BODY_MASS, self._cpu_body_mass.flatten(), indices=cpu_env_ids) def set_masses_mask( self, @@ -733,8 +742,9 @@ def set_masses_mask( 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)) + self._root_view.set_attribute( + TT.RIGID_BODY_MASS, self._cpu_body_mass.flatten(), mask=self._get_cpu_env_mask(env_mask_wp) + ) def set_coms_index( self, @@ -773,9 +783,10 @@ def set_coms_index( # 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) + self._root_view.set_attribute( + TT.RIGID_BODY_COM_POSE, self._cpu_body_coms.reshape((self._num_instances, 7)), indices=cpu_env_ids + ) def set_coms_mask( self, @@ -810,8 +821,11 @@ def set_coms_mask( ) 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)) + self._root_view.set_attribute( + TT.RIGID_BODY_COM_POSE, + self._cpu_body_coms.reshape((self._num_instances, 7)), + mask=self._get_cpu_env_mask(env_mask_wp), + ) def set_inertias_index( self, @@ -847,9 +861,10 @@ def set_inertias_index( # 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) + self._root_view.set_attribute( + TT.RIGID_BODY_INERTIA, self._cpu_body_inertia.reshape((self._num_instances, 9)), indices=cpu_env_ids + ) def set_inertias_mask( self, @@ -883,9 +898,10 @@ def set_inertias_mask( 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) + self._root_view.set_attribute( + TT.RIGID_BODY_INERTIA, + self._cpu_body_inertia.reshape((self._num_instances, 9)), + mask=self._get_cpu_env_mask(env_mask_wp), ) """ @@ -920,6 +936,7 @@ def has_rigid_body_api(prim) -> bool: # 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. + self._root_view = OvPhysxView(self._ovphysx, pattern=pattern, device=self._device) for tt in ( TT.RIGID_BODY_POSE, TT.RIGID_BODY_VELOCITY, @@ -929,7 +946,7 @@ def has_rigid_body_api(prim) -> bool: TT.RIGID_BODY_INERTIA, ): try: - self._get_binding(tt) + self._root_view.binding_for(tt) except Exception as e: raise RuntimeError( f"OVPhysX could not create rigid-body binding {tt!r}. " @@ -941,7 +958,7 @@ def has_rigid_body_api(prim) -> bool: ) from e # read counts and body names from the root-pose binding - root_pose = self._bindings[TT.RIGID_BODY_POSE] + root_pose = self._root_view.binding_for(TT.RIGID_BODY_POSE) self._num_instances = root_pose.count self._num_bodies = 1 try: @@ -957,7 +974,7 @@ def has_rigid_body_api(prim) -> bool: self._body_names = ["base_link"] # container for data access - self._data = RigidObjectData(self._bindings, self._device, check_shapes=self._check_shapes) + self._data = RigidObjectData(self._root_view, self._device, check_shapes=self._check_shapes) # create buffers self._create_buffers() @@ -977,7 +994,7 @@ def _create_buffers(self) -> None: # 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=...)`` + # All-true masks for default mask paths. These let ``set_attribute(..., 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) @@ -1093,8 +1110,8 @@ def _resolve_body_mask(self, body_mask: wp.array | None) -> wp.array: 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 + ``set_attribute(mask=...)`` requires the mask on the binding's native 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) @@ -1126,12 +1143,7 @@ def _get_binding(self, tensor_type: int): 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 + return self._root_view.try_binding_for(tensor_type) """ Internal simulation callbacks. @@ -1140,6 +1152,9 @@ def _get_binding(self, tensor_type: int): def _invalidate_initialize_callback(self, event) -> None: """Invalidates the scene elements.""" super()._invalidate_initialize_callback(event) + # Drop the view (and the bindings it caches) on stop so a destroyed/stale binding is + # not held across the reset; ``_initialize_impl`` rebuilds a fresh view on the next play. + self._root_view = None def write_root_state_to_sim( self, 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 index e2ef03564d2..6d3659338d5 100644 --- 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 @@ -22,6 +22,7 @@ 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 +from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView class RigidObjectData(BaseRigidObjectData): @@ -60,30 +61,31 @@ class RigidObjectData(BaseRigidObjectData): def __init__( self, - bindings: dict, + view: OvPhysxView, 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 + view: The :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager + for this rigid object. ``num_instances`` is read from the + ``rigid_body_pose`` binding's ``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 + super().__init__(view, device) + # The view owns the per-tensor-type bindings and the CPU/GPU device policy. + self._view = view 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] + root_pose = self._view.binding_for(TT.RIGID_BODY_POSE) self._num_instances = root_pose.count self._num_bodies = 1 @@ -873,8 +875,8 @@ def _create_buffers(self) -> None: # 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] + mass_binding = self._view.binding_for(TT.RIGID_BODY_MASS) + inertia_binding = self._view.binding_for(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) @@ -957,20 +959,24 @@ def _pin_proxy_arrays(self) -> None: """ def _get_binding(self, tensor_type: int): - """Return the binding for the given tensor type, or None.""" - return self._bindings.get(tensor_type) + """Return the binding for the given tensor type, or None. + + Delegates to :attr:`root_view`'s + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.try_binding_for`. + """ + return self._view.try_binding_for(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. + Routes through :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.read_into`, which + reinterprets a structured *dst* off the binding shape and reuses that reinterpret + across calls (keeping the wheel's read cache warm). CPU-only bindings on a non-CPU + sim are read into a pinned-host staging buffer first (the view does not stage across + devices), then copied to *dst* on the simulation device. """ - binding = self._bindings[tensor_type] if self._check_shapes: + binding = self._view.binding_for(tensor_type) dst_bytes = dst.size * wp.types.type_size_in_bytes(dst.dtype) binding_bytes = 4 * math.prod(binding.shape) assert dst_bytes >= binding_bytes, ( @@ -978,26 +984,22 @@ def _read_binding_into(self, tensor_type: int, dst: wp.array) -> None: 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": + if tensor_type in TT._CPU_ONLY_TYPES and self.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) + staging = wp.zeros( + self._view.binding_for(tensor_type).shape, dtype=wp.float32, device="cpu", pinned=True + ) self._cpu_staging_buffers[tensor_type] = staging - binding.read(staging) + self._view.read_into(tensor_type, staging) + # Copy the flat float32 staging into dst (possibly structured) on the sim device. + if dst.dtype == wp.float32: + view = dst + else: + view = wp.array(ptr=dst.ptr, shape=staging.shape, dtype=wp.float32, device=str(dst.device), copy=False) wp.copy(view, staging) else: - binding.read(view) + self._view.read_into(tensor_type, dst) def _get_pos_from_transform(self, transform: wp.array) -> wp.array: """Generates a position array from a transform array.""" diff --git a/source/isaaclab_ovphysx/test/assets/test_rigid_object.py b/source/isaaclab_ovphysx/test/assets/test_rigid_object.py index 87d4f2e9e88..b3012dfb397 100644 --- a/source/isaaclab_ovphysx/test/assets/test_rigid_object.py +++ b/source/isaaclab_ovphysx/test/assets/test_rigid_object.py @@ -168,8 +168,8 @@ def generate_cubes_scene( _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() " + "wheel side. RigidObject.root_view is an OvPhysxView over the per-tensor-type " + "bindings 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." ) From 5f88cd15a32a215eb1d05a63b49272775d34ea2a Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Sat, 27 Jun 2026 00:57:12 +0200 Subject: [PATCH 032/139] Migrate OVPhysX IMU sensor onto OvPhysxView (view series, part 5) (#6228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ### 🔗 Stacked on top of #6224 (Part 1 — `OvPhysxView`) > Branched off #6224 as a sibling of the asset migrations (#6225/#6226/#6227). Targets `develop`; until #6224 merges, the diff below includes the Part 1 `OvPhysxView` commits. **Please review #6224 first.** # Description Part 5 of the OVPhysX view-migration series — route the **IMU** sensor's tensor-binding management through `OvPhysxView`. Internal refactor; **no public API change** (sensors do not expose a `root_view`). * `_initialize_impl` builds one `OvPhysxView` instead of separate `create_tensor_binding` calls; binding handles come from `binding_for`. * Pose/state reads route through `read_into` (cached `float32` reinterpret, typed off the binding shape), replacing the manual `wp.array(ptr=...)` reinterpret views. * The CPU-only `RIGID_BODY_COM_POSE` keeps its pinned-host staging + `wp.copy` to the sim device. * Numeric behavior unchanged. ## Type of change - [x] This change requires no public API change (internal refactor) ## Checklist - [x] I have run the `pre-commit` checks with `./isaaclab.sh --format` - [x] My changes generate no new warnings - [x] I have added a changelog fragment (`.skip` — internal refactor, no user-facing change / no bump) ## Testing `test_imu.py` — cpu: **12 passed**, cuda: **12 passed**. --- .../antoiner-feat-ovphysx_imu_view.skip | 0 .../isaaclab_ovphysx/sensors/imu/imu.py | 51 ++++++++++--------- 2 files changed, 27 insertions(+), 24 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu_view.skip diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu_view.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu_view.skip new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py index 0ce9539bf8e..9292cfcd6c6 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/imu/imu.py @@ -16,6 +16,7 @@ import isaaclab_ovphysx.tensor_types as TT from isaaclab_ovphysx.physics import OvPhysxManager as SimulationManager +from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView from .imu_data import ImuData from .kernels import imu_reset_kernel, imu_update_kernel @@ -134,9 +135,10 @@ def _initialize_impl(self): # Translate the regex-style path expression to an ovphysx fnmatch glob. pattern = self._rigid_parent_expr.replace(".*", "*") - self._pose_binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_POSE) - self._vel_binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_VELOCITY) - self._com_binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_COM_POSE) + self._root_view = OvPhysxView(physx_instance, pattern=pattern, device=self._device) + self._pose_binding = self._root_view.binding_for(TT.RIGID_BODY_POSE) + self._vel_binding = self._root_view.binding_for(TT.RIGID_BODY_VELOCITY) + self._com_binding = self._root_view.binding_for(TT.RIGID_BODY_COM_POSE) self._num_bodies = self._pose_binding.count if self._num_bodies != self._num_envs: @@ -167,17 +169,27 @@ def _initialize_impl(self): self._offset_pos_b = wp.from_torch(composed_p.contiguous(), dtype=wp.vec3f) self._offset_quat_b = wp.from_torch(composed_q.contiguous(), dtype=wp.quatf) + def _invalidate_initialize_callback(self, event) -> None: + """Drop the OVPhysX view and bindings when physics stops.""" + super()._invalidate_initialize_callback(event) + # Drop the view (and the bindings it caches) so a stale/destroyed handle is not held + # across the reset; ``_initialize_impl`` rebuilds a fresh view on the next play. + self._root_view = None + self._pose_binding = None + self._vel_binding = None + self._com_binding = None + def _update_buffers_impl(self, env_mask: wp.array | None = None): """Fills the buffers of the sensor data.""" env_mask = self._resolve_indices_and_mask(None, env_mask) - # ovphysx ``binding.read(dst)`` writes into the pre-allocated dst buffer; - # ``_*_view`` are float32 aliases of the structured-dtype buffers below. - self._pose_binding.read(self._transforms_view) - self._vel_binding.read(self._velocities_view) + # ``read_into`` fills the structured-dtype destination in place through a cached + # float32 reinterpret of the binding's flat shape (no extra copy). + self._root_view.read_into(TT.RIGID_BODY_POSE, self._transforms) + self._root_view.read_into(TT.RIGID_BODY_VELOCITY, self._velocities) # RIGID_BODY_COM_POSE is a CPU tensor type in the OVPhysX wheel. - # For GPU simulations, stage on CPU then copy into the kernel buffer. - self._com_binding.read(self._coms_read_view) + # For GPU simulations, stage on a pinned CPU buffer then copy into the kernel buffer. + self._root_view.read_into(TT.RIGID_BODY_COM_POSE, self._coms_read_view) if self._coms_read_view is not self._coms_gpu_view: wp.copy(self._coms_gpu_view, self._coms_read_view) @@ -212,25 +224,16 @@ def _initialize_buffers_impl(self): self._offset_pos_b = wp.from_torch(offset_pos_torch.contiguous(), dtype=wp.vec3f) self._offset_quat_b = wp.from_torch(offset_quat_torch.contiguous(), dtype=wp.quatf) - # Structured-dtype buffers consumed by the kernel. + # Structured-dtype buffers consumed by the kernel. ``read_into`` fills the GPU-resident + # pose/velocity buffers directly, building and caching the float32 reinterpret itself. self._transforms = wp.zeros(self._num_bodies, dtype=wp.transformf, device=self._device) self._velocities = wp.zeros(self._num_bodies, dtype=wp.spatial_vectorf, device=self._device) self._coms_buffer = wp.zeros(self._num_bodies, dtype=wp.transformf, device=self._device) - self._transforms_view = wp.array( - ptr=self._transforms.ptr, - shape=self._pose_binding.shape, - dtype=wp.float32, - device=self._device, - copy=False, - ) - self._velocities_view = wp.array( - ptr=self._velocities.ptr, - shape=self._vel_binding.shape, - dtype=wp.float32, - device=self._device, - copy=False, - ) + # RIGID_BODY_COM_POSE is CPU-only in the OVPhysX wheel. ``read_into`` requires the + # destination on the binding's native device (cpu), so on a GPU sim we read into a pinned + # CPU buffer and copy into the GPU kernel buffer; on a CPU sim the two alias and the copy + # is skipped. ``_coms_gpu_view`` stays a flat float32 view so the copy dtype matches. self._coms_gpu_view = wp.array( ptr=self._coms_buffer.ptr, shape=self._com_binding.shape, From 26274468bdfedde69e118bc8066fa7c35d8b6ba4 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Sat, 27 Jun 2026 00:57:41 +0200 Subject: [PATCH 033/139] Migrate OVPhysX PVA sensor onto OvPhysxView (view series, part 5) (#6229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ### 🔗 Stacked on top of #6224 (Part 1 — `OvPhysxView`) > Branched off #6224 as a sibling of the asset migrations (#6225/#6226/#6227). Targets `develop`; until #6224 merges, the diff below includes the Part 1 `OvPhysxView` commits. **Please review #6224 first.** # Description Part 5 of the OVPhysX view-migration series — route the **PVA** sensor's tensor-binding management through `OvPhysxView`. Internal refactor; **no public API change** (sensors do not expose a `root_view`). * `_initialize_impl` builds one `OvPhysxView` instead of separate `create_tensor_binding` calls; binding handles come from `binding_for`. * Pose/state reads route through `read_into` (cached `float32` reinterpret, typed off the binding shape), replacing the manual `wp.array(ptr=...)` reinterpret views. * The CPU-only `RIGID_BODY_COM_POSE` keeps its pinned-host staging + `wp.copy` to the sim device. * Numeric behavior unchanged. ## Type of change - [x] This change requires no public API change (internal refactor) ## Checklist - [x] I have run the `pre-commit` checks with `./isaaclab.sh --format` - [x] My changes generate no new warnings - [x] I have added a changelog fragment (`.skip` — internal refactor, no user-facing change / no bump) ## Testing `test_pva.py` — cpu: **13 passed**, cuda: **13 passed**. --- .../antoiner-feat-ovphysx_pva_view.skip | 0 .../isaaclab_ovphysx/sensors/pva/pva.py | 62 ++++++++----------- 2 files changed, 25 insertions(+), 37 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_pva_view.skip diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_pva_view.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_pva_view.skip new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/pva/pva.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/pva/pva.py index 7fb30e71e62..569cd5f333d 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/pva/pva.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/pva/pva.py @@ -19,6 +19,7 @@ import isaaclab_ovphysx.tensor_types as TT from isaaclab_ovphysx.physics import OvPhysxManager as SimulationManager +from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView from .kernels import pva_reset_kernel, pva_update_kernel from .pva_data import PvaData @@ -67,7 +68,7 @@ def __init__(self, cfg: PvaCfg): self._rigid_parent_expr: str | None = None # Sentinel — set in :meth:`_initialize_impl`; ``None`` means the sensor has not been bound yet # (used by :meth:`_debug_vis_callback` to safely no-op before init). - self._pose_binding = None + self._root_view: OvPhysxView | None = None def __str__(self) -> str: """Returns: A string containing information about the instance.""" @@ -143,10 +144,8 @@ def _initialize_impl(self): # Translate the regex-style path expression to an ovphysx fnmatch glob. pattern = self._rigid_parent_expr.replace(".*", "*") - self._pose_binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_POSE) - self._vel_binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_VELOCITY) - self._com_binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_COM_POSE) - self._num_bodies = self._pose_binding.count + self._root_view = OvPhysxView(physx_instance, pattern=pattern, device=self._device) + self._num_bodies = self._root_view.binding_for(TT.RIGID_BODY_POSE).count if self._num_bodies != self._num_envs: raise ValueError( @@ -178,19 +177,26 @@ def _initialize_impl(self): self._offset_pos_b = wp.from_torch(composed_p.contiguous(), dtype=wp.vec3f) self._offset_quat_b = wp.from_torch(composed_q.contiguous(), dtype=wp.quatf) + def _invalidate_initialize_callback(self, event) -> None: + """Drop the OVPhysX view when physics stops.""" + super()._invalidate_initialize_callback(event) + # Drop the view (and the bindings it caches) so a stale/destroyed handle is not held + # across the reset; ``_initialize_impl`` rebuilds a fresh view on the next play. + self._root_view = None + def _update_buffers_impl(self, env_mask: wp.array | None = None): """Fills the buffers of the sensor data.""" env_mask = self._resolve_indices_and_mask(None, env_mask) - # ovphysx ``binding.read(dst)`` writes into the pre-allocated dst buffer; - # ``_*_view`` are float32 aliases of the structured-dtype buffers below. - self._pose_binding.read(self._transforms_view) - self._vel_binding.read(self._velocities_view) + # ``OvPhysxView.read_into`` fills the structured-dtype buffer in place via a + # cached float32 reinterpret; no manual float32 alias is needed. + self._root_view.read_into(TT.RIGID_BODY_POSE, self._transforms) + self._root_view.read_into(TT.RIGID_BODY_VELOCITY, self._velocities) # RIGID_BODY_COM_POSE is a CPU tensor type in the OVPhysX wheel. # For GPU simulations, stage on CPU then copy into the kernel buffer. - self._com_binding.read(self._coms_read_view) - if self._coms_read_view is not self._coms_gpu_view: - wp.copy(self._coms_gpu_view, self._coms_read_view) + self._root_view.read_into(TT.RIGID_BODY_COM_POSE, self._coms_read_view) + if self._coms_read_view is not self._coms_buffer: + wp.copy(self._coms_buffer, self._coms_read_view) wp.launch( pva_update_kernel, @@ -231,36 +237,18 @@ def _initialize_buffers_impl(self): self._offset_pos_b = wp.from_torch(offset_pos_torch.contiguous(), dtype=wp.vec3f) self._offset_quat_b = wp.from_torch(offset_quat_torch.contiguous(), dtype=wp.quatf) - # Structured-dtype buffers consumed by the kernel. + # Structured-dtype buffers filled in place by :meth:`OvPhysxView.read_into`. self._transforms = wp.zeros(self._num_bodies, dtype=wp.transformf, device=self._device) self._velocities = wp.zeros(self._num_bodies, dtype=wp.spatial_vectorf, device=self._device) self._coms_buffer = wp.zeros(self._num_bodies, dtype=wp.transformf, device=self._device) - self._transforms_view = wp.array( - ptr=self._transforms.ptr, - shape=self._pose_binding.shape, - dtype=wp.float32, - device=self._device, - copy=False, - ) - self._velocities_view = wp.array( - ptr=self._velocities.ptr, - shape=self._vel_binding.shape, - dtype=wp.float32, - device=self._device, - copy=False, - ) - self._coms_gpu_view = wp.array( - ptr=self._coms_buffer.ptr, - shape=self._com_binding.shape, - dtype=wp.float32, - device=self._device, - copy=False, - ) + # RIGID_BODY_COM_POSE is CPU-resident even on a GPU sim, so its binding requires a + # CPU destination. On a GPU sim, stage the read into a pinned CPU buffer and copy into + # the kernel buffer; on a CPU sim, read straight into the kernel buffer. if self._device == "cpu": - self._coms_read_view = self._coms_gpu_view + self._coms_read_view = self._coms_buffer else: - self._coms_read_view = wp.zeros(self._com_binding.shape, dtype=wp.float32, device="cpu", pinned=True) + self._coms_read_view = wp.zeros(self._num_bodies, dtype=wp.transformf, device="cpu", pinned=True) def _set_debug_vis_impl(self, debug_vis: bool): if debug_vis: @@ -273,7 +261,7 @@ def _set_debug_vis_impl(self, debug_vis: bool): def _debug_vis_callback(self, event): # safely return if the sensor has not been bound yet (matches the PhysX `_view is None` idiom) - if self._pose_binding is None: + if self._root_view is None: return # get marker location # -- base state (convert warp -> torch for visualization) From cb1fa8fadafd27d1125eea2f325cb0e5ea7f2890 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Sat, 27 Jun 2026 00:59:36 +0200 Subject: [PATCH 034/139] Migrate OVPhysX joint-wrench sensor onto OvPhysxView (view series, part 5) (#6230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ### 🔗 Stacked on top of #6224 (Part 1 — `OvPhysxView`) > Branched off #6224 as a sibling of the asset migrations (#6225/#6226/#6227). Targets `develop`; until #6224 merges, the diff below includes the Part 1 `OvPhysxView` commits. **Please review #6224 first.** # Description Part 5 of the OVPhysX view-migration series — route the **joint-wrench** sensor's tensor-binding management through `OvPhysxView`. Internal refactor; **no public API change** (sensors do not expose a `root_view`). * `_initialize_impl` builds one `OvPhysxView` instead of separate `create_tensor_binding` calls; binding handles come from `binding_for`. * Pose/state reads route through `read_into` (cached `float32` reinterpret, typed off the binding shape), replacing the manual `wp.array(ptr=...)` reinterpret views. * All bindings are GPU-resident state (`ARTICULATION_LINK_INCOMING_JOINT_FORCE`); no CPU staging needed. * Numeric behavior unchanged. ## Type of change - [x] This change requires no public API change (internal refactor) ## Checklist - [x] I have run the `pre-commit` checks with `./isaaclab.sh --format` - [x] My changes generate no new warnings - [x] I have added a changelog fragment (`.skip` — internal refactor, no user-facing change / no bump) ## Testing `test_joint_wrench_sensor.py` — cpu: **11 passed**, cuda: **11 passed**. --- ...toiner-feat-ovphysx_joint_wrench_view.skip | 0 .../joint_wrench/joint_wrench_sensor.py | 33 ++++++++----------- 2 files changed, 13 insertions(+), 20 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_joint_wrench_view.skip diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_joint_wrench_view.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_joint_wrench_view.skip new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/joint_wrench/joint_wrench_sensor.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/joint_wrench/joint_wrench_sensor.py index 29150513e28..41106e0f3b0 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/joint_wrench/joint_wrench_sensor.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/joint_wrench/joint_wrench_sensor.py @@ -22,6 +22,7 @@ import isaaclab_ovphysx.tensor_types as TT from isaaclab_ovphysx.physics import OvPhysxManager +from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView from .joint_wrench_sensor_data import JointWrenchSensorData from .kernels import joint_wrench_reset_kernel, joint_wrench_split_kernel @@ -63,9 +64,9 @@ def __init__(self, cfg: JointWrenchSensorCfg): self._data = JointWrenchSensorData() self._physx_instance: Any = None + self._root_view: OvPhysxView | None = None self._wrench_binding: Any = None self._wrench_buf: wp.array | None = None - self._wrench_read_view: wp.array | None = None self._joint_pos_b: wp.array | None = None self._joint_quat_b: wp.array | None = None self._num_bodies: int = 0 @@ -135,9 +136,8 @@ def _initialize_impl(self) -> None: pattern = re.sub(r"\{ENV_REGEX_NS\}", "*", root_prim_path_expr) pattern = re.sub(r"\.\*", "*", pattern) - self._wrench_binding = physx_instance.create_tensor_binding( - pattern=pattern, tensor_type=TT.LINK_INCOMING_JOINT_FORCE - ) + self._root_view = OvPhysxView(physx_instance, pattern=pattern, device=self._device) + self._wrench_binding = self._root_view.binding_for(TT.LINK_INCOMING_JOINT_FORCE) if self._wrench_binding.body_count == 0 or self._wrench_binding.count == 0: raise RuntimeError(f"Joint wrench sensor matched zero bodies at '{self.cfg.prim_path}'.") @@ -160,17 +160,10 @@ def _initialize_impl(self) -> None: self._create_joint_frame_buffers() - # Wrench storage as (N, L) spatial_vectorf; a float32 alias view targets the - # same memory and is the buffer passed to TensorBinding.read(...). Mirrors the - # OVPhysX Articulation pattern (see articulation_data.py:_get_read_view). + # Wrench storage as (N, L) spatial_vectorf, read each step via the view. The view + # reinterprets this structured buffer off the binding's flat float32 shape and caches + # that reinterpret per destination buffer, so no manual float32 alias is needed here. self._wrench_buf = wp.zeros((self._num_envs, self._num_bodies), dtype=wp.spatial_vectorf, device=self._device) - self._wrench_read_view = wp.array( - ptr=self._wrench_buf.ptr, - shape=self._wrench_binding.shape, - dtype=wp.float32, - device=str(self._wrench_buf.device), - copy=False, - ) self._data.create_buffers(num_envs=self._num_envs, num_bodies=self._num_bodies, device=self._device) @@ -250,7 +243,7 @@ def _update_buffers_impl(self, env_mask: wp.array) -> None: Args: env_mask: A mask containing which environments need to be updated. Shape is ``(num_envs,)``. """ - if self._wrench_binding is None or self._wrench_buf is None or self._wrench_read_view is None: + if self._root_view is None or self._wrench_buf is None: raise RuntimeError( f"Joint wrench sensor '{self.cfg.prim_path}': not initialized." " Access sensor data only after sim.reset() has been called." @@ -258,7 +251,7 @@ def _update_buffers_impl(self, env_mask: wp.array) -> None: if self._joint_pos_b is None or self._joint_quat_b is None: raise RuntimeError(f"Joint wrench sensor '{self.cfg.prim_path}': joint frame buffers are not initialized.") - self._wrench_binding.read(self._wrench_read_view) + self._root_view.read_into(TT.LINK_INCOMING_JOINT_FORCE, self._wrench_buf) wp.launch( joint_wrench_split_kernel, dim=(self._num_envs, self._num_bodies), @@ -281,10 +274,10 @@ def _invalidate_initialize_callback(self, event) -> None: event: An invalidate event. """ super()._invalidate_initialize_callback(event) - # Drop the float32 alias view before the wrench buffer it aliases via raw - # ptr: if the buffer is freed while the alias still references its memory, - # Warp's array deallocator aborts. - self._wrench_read_view = None + # Drop the view (and the binding it owns) before the wrench buffer it reads into: + # the view caches a float32 reinterpret of that buffer, so releasing it first keeps + # Warp's array deallocator from aborting on a freed-but-still-referenced allocation. + self._root_view = None self._wrench_binding = None self._physx_instance = None self._wrench_buf = None From ab01ba03bb853100d1e60deb8ed44afd5c963802 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Sat, 27 Jun 2026 01:01:14 +0200 Subject: [PATCH 035/139] Migrate OVPhysX contact-sensor pose binding onto OvPhysxView (view series, part 5) (#6232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ### 🔗 Stacked on top of #6224 (Part 1 — `OvPhysxView`) > Branched off #6224 as a sibling of the other migrations. Targets `develop`; until #6224 merges the diff includes the Part 1 `OvPhysxView` commits. **Please review #6224 first.** # Description Part 5 of the OVPhysX view-migration series — the **contact sensor**. This is the *partial* one: only the `RIGID_BODY_POSE` pose binding (the `track_pose` path) is a `TensorBinding` the view can manage, so only it is migrated. The **`ContactBinding`** (net forces via `read_net_forces`, force matrix via `read_force_matrix`) is a separate ovphysx wheel API that `OvPhysxView` does not wrap — it is left exactly as-is. * `_initialize_impl` builds an `OvPhysxView` for the pose binding (when `track_pose`) and obtains it via `binding_for(TT.RIGID_BODY_POSE)`; the count-mismatch check and the `pose_binding` accessor are unchanged. * The per-step pose read goes through `read_into(TT.RIGID_BODY_POSE, ...)` (cached reinterpret). * The `ContactBinding` create/read/destroy paths are untouched. * Internal refactor; no public API change. ## Type of change - [x] Internal refactor (non-breaking) ## Checklist - [x] I have run the `pre-commit` checks with `./isaaclab.sh --format` - [x] My changes generate no new warnings - [x] Changelog fragment (`.skip` — internal refactor, no bump) ## Testing `test_contact_sensor.py` — cpu: **9 passed, 4 skipped**; cuda: **9 passed, 4 skipped** (exercises both the ContactBinding force path and the migrated pose-tracking path). --- ...antoiner-feat-ovphysx_contact_sensor_view.skip | 0 .../sensors/contact_sensor/contact_sensor.py | 15 ++++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contact_sensor_view.skip diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contact_sensor_view.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contact_sensor_view.skip new file mode 100644 index 00000000000..e69de29bb2d 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 index fba5d8b3da4..1bfd5077bf9 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor.py @@ -22,6 +22,7 @@ import isaaclab_ovphysx.tensor_types as TT from isaaclab_ovphysx.physics import OvPhysxManager +from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView from .contact_sensor_data import ContactSensorData from .kernels import ( @@ -92,6 +93,9 @@ def __init__(self, cfg: ContactSensorCfg): self._physx_instance: Any = None self._contact_binding: Any = None self._pose_binding: Any = None + # The pose binding (track_pose only) is managed by an OvPhysxView; the ContactBinding + # is a separate wheel API the view does not wrap. + self._root_view: OvPhysxView | None = 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 @@ -281,10 +285,8 @@ def has_contact_report(prim) -> bool: "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, - ) + self._root_view = OvPhysxView(physx_instance, pattern=single_pose_pattern, device=self._device) + self._pose_binding = self._root_view.binding_for(TT.RIGID_BODY_POSE) if self._pose_binding.count != self._contact_binding.sensor_count: raise RuntimeError( "RIGID_BODY_POSE binding count mismatch." @@ -378,7 +380,7 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None) -> None: 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) + self._root_view.read_into(TT.RIGID_BODY_POSE, self._poses_flat_buf) poses_flat = self._poses_flat_buf.view(wp.transformf) wp.launch( split_flat_pose_to_pos_quat, @@ -518,4 +520,7 @@ def _invalidate_initialize_callback(self, event) -> None: with contextlib.suppress(Exception): self._pose_binding.destroy() self._pose_binding = None + # Drop the view too: it caches the same (now-destroyed) pose binding, so leaving it set + # would keep a destroyed handle reachable. _initialize_impl rebuilds a fresh view on play. + self._root_view = None self._physx_instance = None From 4b94eef0a99e46b44cba4bcb1cd36247acbf58f8 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Sat, 27 Jun 2026 01:01:48 +0200 Subject: [PATCH 036/139] Migrate OVPhysX FrameView + SceneDataBackend pose bindings onto OvPhysxView (view series) (#6233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ### 🔗 Stacked on top of #6224 (Part 1 — `OvPhysxView`) > Branched off #6224 as a sibling of the asset/sensor migrations. Targets `develop`; until #6224 merges the diff includes the Part 1 `OvPhysxView` commits. **Please review #6224 first.** # Description Two more `RIGID_BODY_POSE` binding consumers the asset (#6225-6227) and sensor (#6228-6232) passes didn't cover, migrated together in one PR: * **`OvPhysxFrameView`** (`sim/views/ovphysx_frame_view.py`) — the frame view that cameras / `XformPrim` use. Builds one `OvPhysxView` + `try_binding_for(RIGID_BODY_POSE)` (preserving its explicit "matched zero bodies" error, since the view rejects a 0-count binding), and reads via `read_into`. * **`OvPhysxSceneDataBackend`** (`physics/ovphysx_manager.py`) — the scene-data provider. Builds one `OvPhysxView` per distinct rigid-body pattern, stores it on the per-pattern entry, and reads via `read_into`; the per-entry merge into the transform buffer is unchanged. Both are internal refactors — **no public API change** (neither exposes a `root_view`). The `ContactBinding` is unrelated (separate wheel API; see #6232). The scene-data backend's bypass-init unit test hand-seeds the internal entry dict, so it now seeds a matching `view` stub with `read_into`. ## Type of change - [x] Internal refactor (non-breaking) ## Checklist - [x] I have run the `pre-commit` checks with `./isaaclab.sh --format` - [x] Changelog fragment (`.skip` — internal refactor, no bump) ## Testing * `test_views_xform_prim_ovphysx.py` — cpu: **18 passed**, cuda: **18 passed**. * `test_ovphysx_scene_data_backend.py` — **11 passed**. --- .../antoiner-feat-ovphysx_frameview_scenedata.skip | 0 .../isaaclab_ovphysx/physics/ovphysx_manager.py | 7 +++++-- .../isaaclab_ovphysx/sim/views/ovphysx_frame_view.py | 11 ++++++++--- .../test/physics/test_ovphysx_scene_data_backend.py | 4 ++++ 4 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_frameview_scenedata.skip diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_frameview_scenedata.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_frameview_scenedata.skip new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 01803d51a76..f21eeb09436 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -98,6 +98,7 @@ def setup(self, physx, stage, device: str) -> None: 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 + from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView self._physx = physx self._rigid_bindings = [] @@ -119,7 +120,8 @@ def setup(self, physx, stage, device: str) -> None: total_count = 0 for pattern in sorted(patterns): try: - pose_binding = physx.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_POSE) + view = OvPhysxView(physx, pattern=pattern, device=device) + pose_binding = view.binding_for(TT.RIGID_BODY_POSE) except Exception as exc: logger.warning("Failed to create RIGID_BODY_POSE binding for %s: %s", pattern, exc) continue @@ -141,6 +143,7 @@ def setup(self, physx, stage, device: str) -> None: self._rigid_bindings.append( { "pattern": pattern, + "view": view, "pose": pose_binding, "pose_buf": pose_buf, "pose_buf_transformf": pose_buf_transformf, @@ -175,7 +178,7 @@ def transforms(self) -> SceneDataFormat.Transform: for entry in self._rigid_bindings: try: - entry["pose"].read(entry["pose_buf"]) + entry["view"].read_into("rigid_body_pose", entry["pose_buf"]) except Exception as exc: logger.warning("RIGID_BODY_POSE read failed for %s: %s", entry["pattern"], exc) continue 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 index 879ecddf385..5965662d420 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/ovphysx_frame_view.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/ovphysx_frame_view.py @@ -357,6 +357,7 @@ def _initialize_impl(self, physx: Any) -> None: ``env_0`` replaced by the row's env_id. """ from isaaclab_ovphysx import tensor_types as TT # noqa: PLC0415 + from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView # 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] @@ -406,8 +407,11 @@ def _initialize_impl(self, physx: Any) -> None: # 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: + self._root_view = OvPhysxView(physx, pattern=pattern, device=self._device) + # ``try_binding_for`` returns None for a zero-match binding (the view rejects a + # 0-count binding); surface that as the explicit zero-bodies error below. + self._pose_binding = self._root_view.try_binding_for(TT.RIGID_BODY_POSE) + if self._pose_binding is None: raise RuntimeError( f"OvPhysxFrameView: RIGID_BODY_POSE binding for pattern {pattern!r} matched zero bodies." ) @@ -415,6 +419,7 @@ def _initialize_impl(self, physx: Any) -> None: 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._root_view = None self._pose_binding = None self._pose_buf = wp.zeros((1, 7), dtype=wp.float32, device=self._device) binding_paths = [] @@ -613,7 +618,7 @@ def _current_body_q(self) -> wp.array: buffer ``[num_bodies]``. """ if self._pose_binding is not None: - self._pose_binding.read(self._pose_buf) + self._root_view.read_into("rigid_body_pose", self._pose_buf) return self._pose_buf.view(wp.transformf) # ------------------------------------------------------------------ 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 index 37487b5dd85..d57efdddfdd 100644 --- a/source/isaaclab_ovphysx/test/physics/test_ovphysx_scene_data_backend.py +++ b/source/isaaclab_ovphysx/test/physics/test_ovphysx_scene_data_backend.py @@ -186,6 +186,7 @@ def fake_read_b(dst): { "pattern": "/World/envs/env_*/Cube", "pose": SimpleNamespace(read=fake_read_a, prim_paths=["/Cube0", "/Cube1"]), + "view": SimpleNamespace(read_into=lambda name, dst, _r=fake_read_a: _r(dst)), "pose_buf": buf_a, "pose_buf_transformf": buf_a_tf, "row_offset": 0, @@ -194,6 +195,7 @@ def fake_read_b(dst): { "pattern": "/World/envs/env_*/Pole", "pose": SimpleNamespace(read=fake_read_b, prim_paths=["/Pole"]), + "view": SimpleNamespace(read_into=lambda name, dst, _r=fake_read_b: _r(dst)), "pose_buf": buf_b, "pose_buf_transformf": buf_b_tf, "row_offset": 2, @@ -324,6 +326,7 @@ def bad_read(dst): { "pattern": "/World/envs/env_*/Good", "pose": SimpleNamespace(read=good_read, prim_paths=["/Good"]), + "view": SimpleNamespace(read_into=lambda name, dst, _r=good_read: _r(dst)), "pose_buf": buf_good, "pose_buf_transformf": buf_good_tf, "row_offset": 0, @@ -332,6 +335,7 @@ def bad_read(dst): { "pattern": "/World/envs/env_*/Bad", "pose": SimpleNamespace(read=bad_read, prim_paths=["/Bad"]), + "view": SimpleNamespace(read_into=lambda name, dst, _r=bad_read: _r(dst)), "pose_buf": buf_bad, "pose_buf_transformf": buf_bad_tf, "row_offset": 1, From 0316aea144ff1d159159db182a28319bf56be186 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Fri, 26 Jun 2026 21:39:35 -0400 Subject: [PATCH 037/139] [IsaacLab CI] upload omni-github test-result artifacts from CI jobs (#6234) # Description - Add `upload-omni-github-test-results` composite action to convert JUnit XML into the omni-github test-result artifact contract. - Wire the upload step into install-ci, package test, and Docker test composite actions. - Use IsaacLab upstream repository ID `567038244` in artifact identities for omni-github registration. - [x] Installation Tests workflow uploads `pytest-results-*--v1-567038244-*` artifacts - [x] omni-github registration PR can reference real sample artifact names from this run link to slack discussion: https://nvidia.slack.com/archives/C0B16FFDDBP/p1782216333267729 link to artifact schema: https://github.com/NVIDIA-Omniverse/omni-github/blob/main/docs/test-results/clients/artifact-upload/artifact-schema.md reference to junit format: https://github.com/testmoapp/junitxml#structure link to test results dashboard: https://omni-github-stg.horde-aks.nvidia.com/test-results/repository-test-summary?repository=isaac-sim%2FIsaacLab&days=30&prMode=include&groupBy=job_name&groupValue=isaaclab+%28core%29+%5B3%2F3%5D example artifact from most recent pipeline run: - rendering correctness test: [test_results.json](https://github.com/user-attachments/files/29359914/test_results.json) - asset test: https://github.com/isaac-sim/IsaacLab/actions/runs/28196987278/artifacts/7890469555 corresponding golden images are uploaded with Fixes # ([OMPE-98494](https://jirasw.nvidia.com/browse/OMPE-98494)) ## 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 --- .github/actions/install-ci-run/action.yml | 10 + .github/actions/run-package-tests/action.yml | 5 + .github/actions/run-tests/action.yml | 16 + .../action.yml | 119 ++++++++ .../junit_to_omni_github_results.py | 215 ++++++++++++++ .../result-json.schema.json | 275 ++++++++++++++++++ .../test_junit_to_omni_github_results.py | 169 +++++++++++ .github/workflows/build.yaml | 3 + .github/workflows/install-ci.yml | 1 + 9 files changed, 813 insertions(+) create mode 100644 .github/actions/upload-omni-github-test-results/action.yml create mode 100644 .github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py create mode 100644 .github/actions/upload-omni-github-test-results/result-json.schema.json create mode 100644 .github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py diff --git a/.github/actions/install-ci-run/action.yml b/.github/actions/install-ci-run/action.yml index e099be41e56..f86ef7105a3 100644 --- a/.github/actions/install-ci-run/action.yml +++ b/.github/actions/install-ci-run/action.yml @@ -48,9 +48,19 @@ runs: tools/run_install_ci.py "${args[@]}" - name: Upload JUnit XML report if: always() + id: upload-junit-report uses: actions/upload-artifact@v7 with: name: install-ci-junit-${{ runner.arch }} path: ${{ github.workspace }}/results/results.xml if-no-files-found: ignore retention-days: 7 + + - name: Upload omni-github test results + if: always() + uses: ./.github/actions/upload-omni-github-test-results + with: + junit-file: ${{ github.workspace }}/results/results.xml + junit-log-url: ${{ steps.upload-junit-report.outputs.artifact-url }} + artifact-prefix: pytest-results-${{ github.job }}-${{ runner.arch }} + test-type: installation-e2e diff --git a/.github/actions/run-package-tests/action.yml b/.github/actions/run-package-tests/action.yml index ee3b40b1d33..211707e48c1 100644 --- a/.github/actions/run-package-tests/action.yml +++ b/.github/actions/run-package-tests/action.yml @@ -77,6 +77,10 @@ inputs: description: 'Space-separated pip packages to install inside the Docker container before pytest starts' default: '' required: false + omni-github-test-type: + description: 'Test type stored on each uploaded omni-github test row' + default: 'pytest' + required: false container-name: description: 'Docker container name prefix (run-id is appended automatically)' required: true @@ -160,6 +164,7 @@ runs: test-node-ids-key: ${{ inputs.test-node-ids-key }} volume-mount-source: ${{ github.workspace }} extra-pip-packages: ${{ inputs.extra-pip-packages }} + omni-github-test-type: ${{ inputs.omni-github-test-type }} - name: Check Test Results if: always() diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index 1a9b5308336..9a335aeb9ee 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -77,6 +77,10 @@ inputs: description: 'Space-separated pip packages to install inside the Docker container before pytest starts' default: '' required: false + omni-github-test-type: + description: 'Test type stored on each uploaded omni-github test row' + default: 'pytest' + required: false runs: using: composite @@ -456,6 +460,7 @@ runs: - name: Upload comparison images if: always() + id : upload-comparison-images uses: actions/upload-artifact@v7 with: name: comparison-images-${{ inputs.container-name }} @@ -465,6 +470,7 @@ runs: - name: Upload JUnit XML report if: always() + id: upload-junit-report uses: actions/upload-artifact@v7 with: name: junit-${{ inputs.container-name }} @@ -472,6 +478,16 @@ runs: if-no-files-found: ignore retention-days: 7 + - name: Upload omni-github test results + if: always() + uses: ./.github/actions/upload-omni-github-test-results + with: + junit-file: ${{ inputs.reports-dir }}/${{ inputs.result-file }} + junit-log-url: ${{ steps.upload-junit-report.outputs.artifact-url }} + comparison-images-url: ${{ steps.upload-comparison-images.outputs.artifact-url }} + artifact-prefix: pytest-results-${{ github.job }}-${{ inputs.container-name }} + test-type: ${{ inputs.omni-github-test-type }} + - name: Clean up Docker container if: always() && !cancelled() shell: bash diff --git a/.github/actions/upload-omni-github-test-results/action.yml b/.github/actions/upload-omni-github-test-results/action.yml new file mode 100644 index 00000000000..80c820a7464 --- /dev/null +++ b/.github/actions/upload-omni-github-test-results/action.yml @@ -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 + +name: 'Upload omni-github test results' +description: > + Converts a JUnit XML report into the omni-github test-result artifact + contract and uploads it with the current GitHub repository identity. + +inputs: + junit-file: + description: 'Path to the JUnit XML report to convert' + required: true + junit-log-url: + description: 'URL of the uploaded JUnit XML artifact' + default: '' + required: false + comparison-images-url: + description: 'URL of the uploaded comparison images artifact' + default: '' + required: false + artifact-prefix: + description: 'Artifact name prefix' + required: true + test-tool-id: + description: 'Identifier for the test runner that produced the JUnit report' + default: 'pytest' + required: false + test-type: + description: >- + Suite category stored on each uploaded omni-github test row, such as + training-e2e, rendering-correctness, pytest, etc. + default: 'pytest' + required: false + retention-days: + description: 'GitHub artifact retention in days' + default: '7' + required: false + +runs: + using: composite + steps: + - name: Convert JUnit XML to omni-github results + id: convert + shell: bash + env: + ARTIFACT_PREFIX: ${{ inputs.artifact-prefix }} + JUNIT_FILE: ${{ inputs.junit-file }} + TEST_TOOL_ID: ${{ inputs.test-tool-id }} + TEST_TYPE: ${{ inputs.test-type }} + run: | + set -euo pipefail + + if [ ! -f "$JUNIT_FILE" ]; then + echo "::warning::Skipping omni-github upload because JUnit report was not found: $JUNIT_FILE" + echo "upload=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + case "${RUNNER_OS:-unknown}-${RUNNER_ARCH:-unknown}" in + Linux-X64) app_platform="linux-x86_64" ;; + Linux-ARM64) app_platform="linux-aarch64" ;; + Windows-X64) app_platform="windows-x86_64" ;; + Windows-ARM64) app_platform="windows-aarch64" ;; + macOS-X64) app_platform="macos-x86_64" ;; + macOS-ARM64) app_platform="macos-aarch64" ;; + *) app_platform="$(printf '%s-%s' "${RUNNER_OS:-unknown}" "${RUNNER_ARCH:-unknown}" | tr '[:upper:]' '[:lower:]')" ;; + esac + + run_attempt="${GITHUB_RUN_ATTEMPT:-1}" + retries=0 + + # Run attempt > 1 means this job is a re-run, so adjust the retries count accordingly + [[ "$run_attempt" =~ ^[1-9][0-9]*$ ]] && retries=$((run_attempt - 1)) + + # Base directory for the artifact, which will be uploaded to GitHub + artifact_dir="${RUNNER_TEMP}/omni-github-test-results/${ARTIFACT_PREFIX}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_JOB}" + rm -rf "$artifact_dir" + mkdir -p "$artifact_dir" + + python3 "$GITHUB_ACTION_PATH/junit_to_omni_github_results.py" \ + --junit-file "$JUNIT_FILE" \ + --output-dir "$artifact_dir" \ + --test-tool-id "$TEST_TOOL_ID" \ + --test-type "$TEST_TYPE" \ + --app-platform "$app_platform" \ + --app-config "${GITHUB_JOB:-github-job}" \ + --group-name "${GITHUB_WORKFLOW:-github-workflow} / ${GITHUB_JOB:-github-job}" \ + --junit-log-url "${{ inputs.junit-log-url }}" \ + --comparison-images-url "${{ inputs.comparison-images-url }}" \ + --retries "$retries" + + result_json="$artifact_dir/_testoutput/test_results.json" + + # Validating with a local pinned copy of the schemas is recommended by the omni-github team. + schema_venv="${RUNNER_TEMP}/omni-github-jsonschema-venv" + python3 -m venv "$schema_venv" + "$schema_venv/bin/python" -m pip install -q jsonschema + if ! "$schema_venv/bin/python" -m jsonschema \ + "$GITHUB_ACTION_PATH/result-json.schema.json" \ + --instance "$result_json"; then + echo "::warning::Skipping omni-github upload because converted results failed schema validation: $JUNIT_FILE" + echo "upload=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "artifact_dir=$artifact_dir" >> "$GITHUB_OUTPUT" + echo "upload=true" >> "$GITHUB_OUTPUT" + + - name: Upload omni-github test results + if: always() && steps.convert.outputs.upload == 'true' + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact-prefix }}--v1-${{ github.repository_id }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ job.check_run_id }} + path: ${{ steps.convert.outputs.artifact_dir }} + if-no-files-found: error + retention-days: ${{ inputs.retention-days }} + compression-level: 9 diff --git a/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py b/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py new file mode 100644 index 00000000000..03a81733bfa --- /dev/null +++ b/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py @@ -0,0 +1,215 @@ +# 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 + +"""Convert a JUnit XML report into the omni-github test-result artifact format.""" + +from __future__ import annotations + +import argparse +import json +import xml.etree.ElementTree as ET +from pathlib import Path + +_MANIFEST_NAME = "omni-github-test-results-upload.json" +_RESULT_PATH = "_testoutput/test_results.json" +_MAX_MESSAGE_CHARS = 500 +_FLAG_PATTERNS = { + "crash": ("abort", "core dumped", "crash", "process killed", "segmentation fault", "signal "), + "timeout": ("deadline exceeded", "timed out", "timeout"), +} + + +def _local_name(tag: str) -> str: + """Return an XML tag name without its namespace.""" + return tag.rsplit("}", maxsplit=1)[-1] + + +def _iter_testcases(root: ET.Element) -> list[ET.Element]: + """Return all JUnit testcase elements from the parsed XML tree.""" + return [element for element in root.iter() if _local_name(element.tag) == "testcase"] + + +def _first_child(testcase: ET.Element, names: set[str]) -> ET.Element | None: + """Return the first direct testcase child whose local tag name matches.""" + for child in testcase: + if _local_name(child.tag) in names: + return child + return None + + +def _duration_seconds(element: ET.Element) -> float: + """Return the element duration in seconds.""" + try: + duration = float(element.attrib.get("time", "0")) + except ValueError: + duration = 0.0 + return max(duration, 0.0) + + +def _test_id(testcase: ET.Element) -> str: + """Return a stable test identifier from JUnit classname and name fields.""" + parts = [testcase.attrib.get(key, "").strip() for key in ("classname", "name")] + return "::".join(part for part in parts if part) or "unknown-testcase" + + +def _short_message(element: ET.Element | None) -> str | None: + """Return a compact message from a failure, error, or skip element.""" + if element is None: + return None + raw_message = element.attrib.get("message") or element.attrib.get("type") or element.text or "" + message = " ".join(raw_message.split()) + if not message: + return None + if len(message) > _MAX_MESSAGE_CHARS: + return message[: _MAX_MESSAGE_CHARS - 3].rstrip() + "..." + return message + + +def _convert_testcase( + testcase: ET.Element, + test_type: str, + group_id: str, + retries: int, + log_paths: list[str], +) -> dict[str, object]: + """Convert one JUnit testcase element into an omni-github test row.""" + failure_or_error = _first_child(testcase, {"error", "failure"}) + skipped = _first_child(testcase, {"skipped"}) + message = _short_message(failure_or_error) + skip_reason = _short_message(skipped) + row: dict[str, object] = { + "test_id": _test_id(testcase), + "passed": failure_or_error is None and skipped is None, + "duration": _duration_seconds(testcase), + "test_type": test_type, + "group_id": group_id, + "retries": retries, + "log_paths": log_paths, + } + if testcase.attrib.get("name"): + row["test_name"] = testcase.attrib["name"] + if skipped is not None: + row["skipped"] = True + if skip_reason is not None: + row["skip_reason"] = skip_reason + row.setdefault("message", skip_reason) + if message is not None: + row["message"] = message + + if failure_or_error is not None: + detail_text = " ".join( + filter(None, (message, failure_or_error.attrib.get("type"), failure_or_error.text)) + ).lower() + for flag, patterns in _FLAG_PATTERNS.items(): + if any(pattern in detail_text for pattern in patterns): + row[flag] = True + + return row + + +def convert_junit( + junit_file: Path, + output_dir: Path, + test_tool_id: str, + test_type: str, + app_platform: str, + app_config: str, + group_name: str, + junit_log_url: str, + comparison_images_url: str, + retries: int = 0, +) -> None: + """Convert a JUnit XML report and write the omni-github artifact directory. + + Args: + junit_file: Path to the source JUnit XML report. + output_dir: Directory where the artifact root should be written. + test_tool_id: Identifier for the test tool that produced the report. + test_type: Suite category to store on each converted test row. + app_platform: Platform label for the result app metadata. + app_config: Configuration label for the result app metadata. + group_name: Human-readable test group label to store on each test row. + retries: Within-job retry count to store on each test row. + junit_log_url: URL of the uploaded source JUnit XML artifact. + comparison_images_url: URL of the uploaded comparison images artifact. + """ + root = ET.parse(junit_file).getroot() + log_paths: list[str] = [] + if junit_log_url: + log_paths.append(junit_log_url) + + if comparison_images_url: + log_paths.append(comparison_images_url) + + tests = [] + for testcase in _iter_testcases(root): + tests.append( + _convert_testcase( + testcase, + test_type, + group_name, + retries, + log_paths=log_paths, + ) + ) + result: dict[str, object] = { + "result_schema_version": 1, + "test_tool_id": test_tool_id, + "app": { + "platform": app_platform, + "config": app_config, + }, + "tests": tests, + } + manifest = { + "schema_version": 1, + "result_paths": [_RESULT_PATH], + } + + result_path = output_dir / _RESULT_PATH + result_path.parent.mkdir(parents=True, exist_ok=True) + result_path.write_text(json.dumps(result, separators=(",", ":")), encoding="utf-8") + (output_dir / _MANIFEST_NAME).write_text(json.dumps(manifest, separators=(",", ":")), encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--junit-file", type=Path, required=True, help="Path to the JUnit XML report.") + parser.add_argument("--output-dir", type=Path, required=True, help="Artifact root directory to write.") + parser.add_argument("--test-tool-id", required=True, help="Identifier for the test tool.") + parser.add_argument("--test-type", required=True, help="Suite category for each test row.") + parser.add_argument("--app-platform", required=True, help="App platform label.") + parser.add_argument("--app-config", required=True, help="App configuration label.") + parser.add_argument("--group-name", required=True, help="Human-readable group label for each test row.") + parser.add_argument( + "--junit-log-url", required=True, type=str, help="URL of the uploaded source JUnit XML artifact." + ) + parser.add_argument( + "--comparison-images-url", required=True, type=str, help="URL of the uploaded comparison images artifact." + ) + parser.add_argument("--retries", type=int, default=0, help="Within-job retry count for each test row.") + return parser.parse_args() + + +def main() -> None: + """Run the converter.""" + args = parse_args() + convert_junit( + junit_file=args.junit_file, + output_dir=args.output_dir, + test_tool_id=args.test_tool_id, + test_type=args.test_type, + app_platform=args.app_platform, + app_config=args.app_config, + group_name=args.group_name, + junit_log_url=args.junit_log_url, + comparison_images_url=args.comparison_images_url, + retries=max(args.retries, 0), + ) + + +if __name__ == "__main__": + main() diff --git a/.github/actions/upload-omni-github-test-results/result-json.schema.json b/.github/actions/upload-omni-github-test-results/result-json.schema.json new file mode 100644 index 00000000000..f94d55573e2 --- /dev/null +++ b/.github/actions/upload-omni-github-test-results/result-json.schema.json @@ -0,0 +1,275 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://omni-github.local/schemas/artifact-upload/result-json.schema.json", + "title": "Test result JSON", + "type": "object", + "additionalProperties": false, + "required": ["test_tool_id", "app", "tests"], + "properties": { + "result_schema_version": { + "type": ["integer", "null"], + "default": 1, + "enum": [1, null] + }, + "test_tool_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "app": { + "$ref": "#/$defs/appContext" + }, + "tests": { + "type": "array", + "minItems": 1, + "maxItems": 20000, + "items": { + "$ref": "#/$defs/testRow" + } + } + }, + "$defs": { + "shortString": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "nullableShortString": { + "type": ["string", "null"], + "minLength": 1, + "maxLength": 256 + }, + "messageString": { + "type": "string", + "maxLength": 4096 + }, + "nullableMessageString": { + "type": ["string", "null"], + "maxLength": 4096 + }, + "artifactRelativePath": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "not": { + "pattern": "(^/|(^|/)\\.\\.(/|$)|\\\\)" + } + }, + "appContext": { + "type": "object", + "additionalProperties": false, + "required": ["platform"], + "properties": { + "platform": { + "$ref": "#/$defs/shortString" + }, + "config": { + "type": ["string", "null"], + "minLength": 1, + "maxLength": 128, + "default": "release" + }, + "python_version": { + "$ref": "#/$defs/nullableShortString" + }, + "app_version": { + "$ref": "#/$defs/nullableShortString" + }, + "kit_version_full": { + "$ref": "#/$defs/nullableShortString" + }, + "kit_build_number": { + "type": ["integer", "null"], + "minimum": 0 + }, + "app_name_full": { + "$ref": "#/$defs/nullableShortString" + }, + "kit_version": { + "$ref": "#/$defs/nullableShortString" + }, + "app_name": { + "$ref": "#/$defs/nullableShortString" + }, + "package": { + "$ref": "#/$defs/nullableShortString" + }, + "kit_git_hash": { + "$ref": "#/$defs/nullableShortString" + }, + "kit_branch": { + "$ref": "#/$defs/nullableShortString" + } + } + }, + "testRow": { + "type": "object", + "additionalProperties": false, + "required": ["test_id", "passed", "duration"], + "properties": { + "test_id": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "passed": { + "type": "boolean" + }, + "duration": { + "type": "number", + "minimum": 0, + "maximum": 86400 + }, + "test_name": { + "type": ["string", "null"], + "minLength": 1, + "maxLength": 512 + }, + "group_id": { + "$ref": "#/$defs/nullableShortString" + }, + "test_type": { + "$ref": "#/$defs/nullableShortString" + }, + "skipped": { + "type": ["boolean", "null"], + "default": false + }, + "unreliable": { + "type": ["boolean", "null"], + "default": false + }, + "crash": { + "type": ["boolean", "null"], + "default": false + }, + "timeout": { + "type": ["boolean", "null"], + "default": false + }, + "retries": { + "type": ["integer", "null"], + "minimum": 0, + "maximum": 100, + "default": 0 + }, + "owner": { + "$ref": "#/$defs/nullableShortString" + }, + "skip_reason": { + "$ref": "#/$defs/nullableMessageString" + }, + "message": { + "$ref": "#/$defs/nullableMessageString" + }, + "log_paths": { + "anyOf": [ + { + "type": "array", + "maxItems": 20, + "items": { + "$ref": "#/$defs/artifactRelativePath" + } + }, + { + "type": "null" + } + ] + }, + "app": { + "anyOf": [ + { + "$ref": "#/$defs/appContext" + }, + { + "type": "null" + } + ] + }, + "custom": { + "anyOf": [ + { + "$ref": "#/$defs/customObject" + }, + { + "type": "null" + } + ] + } + } + }, + "customObject": { + "type": "object", + "minProperties": 1, + "maxProperties": 20, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/customNamespace" + } + }, + "customNamespace": { + "type": "object", + "minProperties": 1, + "maxProperties": 50, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/customValue" + } + }, + "customValue": { + "oneOf": [ + { + "type": "string", + "maxLength": 1024 + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "maxItems": 100, + "items": { + "oneOf": [ + { + "type": "string", + "maxLength": 1024 + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + { + "type": "object", + "maxProperties": 20, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/customValue" + } + } + ] + } + } +} diff --git a/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py b/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py new file mode 100644 index 00000000000..e25ce26d3a1 --- /dev/null +++ b/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.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 the omni-github JUnit result converter.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +_MODULE_PATH = Path(__file__).with_name("junit_to_omni_github_results.py") +_RESULT_PATH = "_testoutput/test_results.json" + + +def _load_converter_module() -> ModuleType: + """Load the converter module from the local GitHub action directory.""" + spec = importlib.util.spec_from_file_location("junit_to_omni_github_results", _MODULE_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _load_rows(output_dir: Path) -> list[dict[str, object]]: + """Load converted rows from the omni-github result artifact.""" + result = json.loads((output_dir / _RESULT_PATH).read_text(encoding="utf-8")) + return result["tests"] + + +def test_convert_junit_populates_github_metadata_and_failure_details(tmp_path: Path) -> None: + """Converted JUnit rows should carry grouping, retry, and message metadata.""" + converter = _load_converter_module() + junit_file = tmp_path / "report.xml" + output_dir = tmp_path / "out" + junit_file.write_text( + """ + + + Traceback details + + + + + + +""", + encoding="utf-8", + ) + + converter.convert_junit( + junit_file=junit_file, + output_dir=output_dir, + test_tool_id="pytest", + test_type="pytest", + app_platform="linux-x86_64", + app_config="test-job", + group_name="Docker + Tests / isaaclab_tasks [1/3]", + junit_log_url="", + comparison_images_url="", + retries=2, + ) + + rows = _load_rows(output_dir) + failed, skipped, passed = rows + assert failed["test_id"] == "source.isaaclab.test.foo.test_sample::test_fails[param]" + assert failed["test_name"] == "test_fails[param]" + assert failed["passed"] is False + assert failed["duration"] == 1.25 + assert failed["group_id"] == "Docker + Tests / isaaclab_tasks [1/3]" + assert failed["retries"] == 2 + assert failed["message"] == "AssertionError: expected 1, got 2" + + assert skipped["passed"] is False + assert skipped["skipped"] is True + assert skipped["skip_reason"] == "requires GPU" + assert skipped["message"] == "requires GPU" + + assert passed["passed"] is True + assert "message" not in passed + + +def test_convert_junit_marks_crashes_and_timeouts(tmp_path: Path) -> None: + """Converted rows should surface crash and timeout messages.""" + converter = _load_converter_module() + junit_file = tmp_path / "report.xml" + output_dir = tmp_path / "out" + junit_file.write_text( + """ + + + diagnostics + + +""", + encoding="utf-8", + ) + + converter.convert_junit( + junit_file=junit_file, + output_dir=output_dir, + test_tool_id="pytest", + test_type="rendering-correctness", + app_platform="linux-x86_64", + app_config="test-job", + group_name="Docker + Tests / environments", + junit_log_url="", + comparison_images_url="", + retries=0, + ) + + rows = _load_rows(output_dir) + assert rows == [ + { + "crash": True, + "duration": 0.0, + "group_id": "Docker + Tests / environments", + "log_paths": [], + "message": "Process killed by signal 15 after timeout", + "passed": False, + "retries": 0, + "test_id": "test_rendering_cartpole::test_execution", + "test_name": "test_execution", + "test_type": "rendering-correctness", + "timeout": True, + } + ] + + +def test_convert_junit_adds_log_paths_for_junit_and_comparison_artifacts(tmp_path: Path) -> None: + """Converted rows should point at the uploaded JUnit and comparison image artifacts.""" + converter = _load_converter_module() + reports_dir = tmp_path + output_dir = tmp_path / "out" + junit_file = reports_dir / "report.xml" + junit_log_url = "https://github.com/isaac-sim/IsaacLab/actions/runs/123/artifacts/456" + comparison_images_url = "https://github.com/isaac-sim/IsaacLab/actions/runs/123/artifacts/789" + + junit_file.write_text( + """ + + + +""", + encoding="utf-8", + ) + + converter.convert_junit( + junit_file=junit_file, + output_dir=output_dir, + test_tool_id="pytest", + test_type="rendering-correctness", + app_platform="linux-x86_64", + app_config="test-job", + group_name="Docker + Tests / rendering", + junit_log_url=junit_log_url, + comparison_images_url=comparison_images_url, + retries=0, + ) + + rows = _load_rows(output_dir) + assert rows[0]["log_paths"] == [ + junit_log_url, + comparison_images_url, + ] diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index bfcc691d9cf..9bf03560cdd 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -731,6 +731,7 @@ jobs: filter-pattern: "isaaclab_tasks" include-files: "test_environments_training.py" container-name: isaac-lab-environments-training-test + omni-github-test-type: training-e2e test-rendering-correctness: name: "rendering-correctness" @@ -759,6 +760,7 @@ jobs: test-node-ids-file: ${{ github.event_name == 'push' && '.github/test-subsets/postmerge-rendering.toml' || '' }} test-node-ids-key: ${{ github.event_name == 'push' && 'rendering-correctness' || '' }} container-name: isaac-lab-rendering-correctness-test + omni-github-test-type: rendering-correctness test-rendering-correctness-kitless: name: "rendering-correctness-kitless" @@ -787,6 +789,7 @@ jobs: test-node-ids-file: ${{ github.event_name == 'push' && '.github/test-subsets/postmerge-rendering.toml' || '' }} test-node-ids-key: ${{ github.event_name == 'push' && 'rendering-correctness-kitless' || '' }} container-name: isaac-lab-rendering-correctness-kitless-test + omni-github-test-type: rendering-correctness-kitless #endregion #region disabled quarantined tests diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index 8d86eec15fc..226222d425b 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -30,6 +30,7 @@ concurrency: permissions: contents: read pull-requests: read + jobs: changes: name: Detect Changes From 57c396e6f95027e7ad8e5ffbe037e2ab56897e1a Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 06:15:04 +0000 Subject: [PATCH 038/139] [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: 7.3.0 → 7.4.0 - isaaclab_assets: 0.3.4 → 0.4.0 - isaaclab_newton: 1.3.0 → 1.4.0 - isaaclab_ovphysx: 4.0.0 → 5.0.0 - isaaclab_physx: 2.3.0 → 2.4.0 - isaaclab_tasks: 8.0.7 → 8.1.0 --- .../fix-arm-baremetal-nlopt-swig.rst | 7 --- .../changelog.d/fix-arm-docker-install.rst | 6 -- .../changelog.d/jiwenc-so-101.minor.rst | 25 -------- .../isaaclab/changelog.d/physx-com-cache.skip | 1 - .../vidurv-schema-frag-tendons.minor.rst | 29 --------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 63 +++++++++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../changelog.d/jiwenc-so-101.minor.rst | 6 -- source/isaaclab_assets/config/extension.toml | 2 +- source/isaaclab_assets/docs/CHANGELOG.rst | 11 ++++ source/isaaclab_assets/pyproject.toml | 2 +- .../vidurv-schema-frag-tendons.minor.rst | 7 --- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 12 ++++ source/isaaclab_newton/pyproject.toml | 2 +- ...r-feat-ovphysx_articulation_view.major.rst | 13 ---- ...iner-feat-ovphysx_contact_sensor_view.skip | 0 ...iner-feat-ovphysx_frameview_scenedata.skip | 0 .../antoiner-feat-ovphysx_imu_view.skip | 0 ...toiner-feat-ovphysx_joint_wrench_view.skip | 0 .../antoiner-feat-ovphysx_pva_view.skip | 0 ...er-feat-ovphysx_rigidobject_view.major.rst | 9 --- .../changelog.d/physx-com-cache.rst | 5 -- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 30 +++++++++ source/isaaclab_ovphysx/pyproject.toml | 2 +- .../changelog.d/physx-com-cache.rst | 5 -- .../vidurv-schema-frag-tendons.minor.rst | 22 ------- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 33 ++++++++++ source/isaaclab_physx/pyproject.toml | 2 +- .../changelog.d/jiwenc-so-101.minor.rst | 53 ---------------- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 58 +++++++++++++++++ source/isaaclab_tasks/pyproject.toml | 2 +- 36 files changed, 219 insertions(+), 200 deletions(-) delete mode 100644 source/isaaclab/changelog.d/fix-arm-baremetal-nlopt-swig.rst delete mode 100644 source/isaaclab/changelog.d/fix-arm-docker-install.rst delete mode 100644 source/isaaclab/changelog.d/jiwenc-so-101.minor.rst delete mode 100644 source/isaaclab/changelog.d/physx-com-cache.skip delete mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst delete mode 100644 source/isaaclab_assets/changelog.d/jiwenc-so-101.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation_view.major.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contact_sensor_view.skip delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_frameview_scenedata.skip delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu_view.skip delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_joint_wrench_view.skip delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_pva_view.skip delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject_view.major.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/physx-com-cache.rst delete mode 100644 source/isaaclab_physx/changelog.d/physx-com-cache.rst delete mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/jiwenc-so-101.minor.rst diff --git a/source/isaaclab/changelog.d/fix-arm-baremetal-nlopt-swig.rst b/source/isaaclab/changelog.d/fix-arm-baremetal-nlopt-swig.rst deleted file mode 100644 index 1f41e192f04..00000000000 --- a/source/isaaclab/changelog.d/fix-arm-baremetal-nlopt-swig.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed native (non-Docker) ARM installation failing when ``swig`` is missing. - The ``nlopt==2.6.2`` source build now installs ``swig`` via apt only for the - duration of the build and purges it afterwards, so the GPL-licensed ``swig`` - is never left behind — in particular it is never shipped in the Docker image. diff --git a/source/isaaclab/changelog.d/fix-arm-docker-install.rst b/source/isaaclab/changelog.d/fix-arm-docker-install.rst deleted file mode 100644 index b33d98ab088..00000000000 --- a/source/isaaclab/changelog.d/fix-arm-docker-install.rst +++ /dev/null @@ -1,6 +0,0 @@ -Fixed -^^^^^ - -* Fixed Linux ARM installation by pre-installing ``nlopt==2.6.2`` before - Isaac Lab dependencies are resolved, and by keeping ``swig`` available in - Docker only until dependency installation completes. diff --git a/source/isaaclab/changelog.d/jiwenc-so-101.minor.rst b/source/isaaclab/changelog.d/jiwenc-so-101.minor.rst deleted file mode 100644 index 8578898f432..00000000000 --- a/source/isaaclab/changelog.d/jiwenc-so-101.minor.rst +++ /dev/null @@ -1,25 +0,0 @@ -Added -^^^^^ - -* Added an ``"adaptive_dls"`` ``ik_method`` to :class:`~isaaclab.controllers.DifferentialIKController`: - a manipulability-aware damped least squares whose damping ramps from ``lambda_min`` toward - ``lambda_max`` as the smallest task-Jacobian singular value drops below ``sigma_thresh`` - (Maciejewski-Klein style), keeping low-DOF / near-singular arms well-conditioned. -* Added an optional per-axis :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.orientation_weight` - (scalar or ``(wx, wy, wz)``) that soft-weights the orientation rows of a ``"pose"`` task, so an - arm that cannot serve a full 6-DOF pose degrades gracefully instead of leaking orientation error - into position. -* Added null-space joint-limit avoidance to :class:`~isaaclab.controllers.DifferentialIKController` - via :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.joint_limit_avoidance_gain` / - :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.joint_limit_avoidance_margin` and - :meth:`~isaaclab.controllers.DifferentialIKController.set_joint_pos_limits`. When enabled, a - center-seeking bias is projected into the null space of the position rows so it never perturbs - the commanded end-effector position; :class:`~isaaclab.envs.mdp.actions.task_space_actions.DifferentialInverseKinematicsAction` - injects the joint limits automatically. - -Changed -^^^^^^^ - -* Changed :meth:`~isaaclab.controllers.DifferentialIKController.set_command` to renormalize the - commanded quaternion for absolute ``"pose"`` commands, hardening the controller against slightly - non-unit quaternion inputs. Existing unit-quaternion callers are unaffected. diff --git a/source/isaaclab/changelog.d/physx-com-cache.skip b/source/isaaclab/changelog.d/physx-com-cache.skip deleted file mode 100644 index 66ddcc4fedd..00000000000 --- a/source/isaaclab/changelog.d/physx-com-cache.skip +++ /dev/null @@ -1 +0,0 @@ -Tests only; user-facing fragments live in the backend packages. diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst deleted file mode 100644 index c32fab0d668..00000000000 --- a/source/isaaclab/changelog.d/vidurv-schema-frag-tendons.minor.rst +++ /dev/null @@ -1,29 +0,0 @@ -Added -^^^^^ - -* Added the tendon schema-fragment markers - :class:`~isaaclab.sim.schemas.FixedTendonFragment` and - :class:`~isaaclab.sim.schemas.SpatialTendonFragment`, which type the spawner - ``fixed_tendons_props`` / ``spatial_tendons_props`` slots. -* Added :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` and - :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties`, the family writers that - dispatch a list of tendon fragments via each fragment's ``func``. Tendons are a - *tune-not-apply* family, so the writers tune the existing multi-instance tendon schemas - without applying a new anchor schema. - -Changed -^^^^^^^ - -* Changed the spawner ``fixed_tendons_props`` / ``spatial_tendons_props`` slots - (:attr:`~isaaclab.sim.spawners.from_files.FileCfg.fixed_tendons_props`, - :attr:`~isaaclab.sim.spawners.from_files.FileCfg.spatial_tendons_props`) to also accept one - or more tendon fragments. Legacy single cfgs continue to work through a transition bridge in - the spawn writer. -* :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` and - :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties` now raise - ``ValueError`` when the prim at ``prim_path`` does not exist in the stage. - Callers that previously relied on an implicit no-op for invalid paths must - either validate the path beforehand or catch ``ValueError``. - The aggregated return value is now ``False`` whenever any fragment applier - reports failure; callers must not assume the return is always ``True`` even - when the prim is valid. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 7570819f8ad..bad36771b56 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 = "7.3.0" +version = "7.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 5b95d80fb26..a229b694a3a 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,69 @@ Changelog --------- +7.4.0 (2026-06-27) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added an ``"adaptive_dls"`` ``ik_method`` to :class:`~isaaclab.controllers.DifferentialIKController`: + a manipulability-aware damped least squares whose damping ramps from ``lambda_min`` toward + ``lambda_max`` as the smallest task-Jacobian singular value drops below ``sigma_thresh`` + (Maciejewski-Klein style), keeping low-DOF / near-singular arms well-conditioned. +* Added an optional per-axis :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.orientation_weight` + (scalar or ``(wx, wy, wz)``) that soft-weights the orientation rows of a ``"pose"`` task, so an + arm that cannot serve a full 6-DOF pose degrades gracefully instead of leaking orientation error + into position. +* Added null-space joint-limit avoidance to :class:`~isaaclab.controllers.DifferentialIKController` + via :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.joint_limit_avoidance_gain` / + :attr:`~isaaclab.controllers.differential_ik_cfg.DifferentialIKControllerCfg.joint_limit_avoidance_margin` and + :meth:`~isaaclab.controllers.DifferentialIKController.set_joint_pos_limits`. When enabled, a + center-seeking bias is projected into the null space of the position rows so it never perturbs + the commanded end-effector position; :class:`~isaaclab.envs.mdp.actions.task_space_actions.DifferentialInverseKinematicsAction` + injects the joint limits automatically. +* Added the tendon schema-fragment markers + :class:`~isaaclab.sim.schemas.FixedTendonFragment` and + :class:`~isaaclab.sim.schemas.SpatialTendonFragment`, which type the spawner + ``fixed_tendons_props`` / ``spatial_tendons_props`` slots. +* Added :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` and + :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties`, the family writers that + dispatch a list of tendon fragments via each fragment's ``func``. Tendons are a + *tune-not-apply* family, so the writers tune the existing multi-instance tendon schemas + without applying a new anchor schema. + +Changed +^^^^^^^ + +* Changed :meth:`~isaaclab.controllers.DifferentialIKController.set_command` to renormalize the + commanded quaternion for absolute ``"pose"`` commands, hardening the controller against slightly + non-unit quaternion inputs. Existing unit-quaternion callers are unaffected. +* Changed the spawner ``fixed_tendons_props`` / ``spatial_tendons_props`` slots + (:attr:`~isaaclab.sim.spawners.from_files.FileCfg.fixed_tendons_props`, + :attr:`~isaaclab.sim.spawners.from_files.FileCfg.spatial_tendons_props`) to also accept one + or more tendon fragments. Legacy single cfgs continue to work through a transition bridge in + the spawn writer. +* :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` and + :func:`~isaaclab.sim.schemas.apply_spatial_tendon_properties` now raise + ``ValueError`` when the prim at ``prim_path`` does not exist in the stage. + Callers that previously relied on an implicit no-op for invalid paths must + either validate the path beforehand or catch ``ValueError``. + The aggregated return value is now ``False`` whenever any fragment applier + reports failure; callers must not assume the return is always ``True`` even + when the prim is valid. + +Fixed +^^^^^ + +* Fixed native (non-Docker) ARM installation failing when ``swig`` is missing. + The ``nlopt==2.6.2`` source build now installs ``swig`` via apt only for the + duration of the build and purges it afterwards, so the GPL-licensed ``swig`` + is never left behind — in particular it is never shipped in the Docker image. +* Fixed Linux ARM installation by pre-installing ``nlopt==2.6.2`` before + Isaac Lab dependencies are resolved, and by keeping ``swig`` available in + Docker only until dependency installation completes. + + 7.3.0 (2026-06-26) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 95af22c8f52..f78b9933241 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "7.3.0" +version = "7.4.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_assets/changelog.d/jiwenc-so-101.minor.rst b/source/isaaclab_assets/changelog.d/jiwenc-so-101.minor.rst deleted file mode 100644 index 692dcf5f187..00000000000 --- a/source/isaaclab_assets/changelog.d/jiwenc-so-101.minor.rst +++ /dev/null @@ -1,6 +0,0 @@ -Added -^^^^^ - -* Added :obj:`~isaaclab_assets.robots.so101.SO101_CFG` and - :obj:`~isaaclab_assets.robots.so101.SO101_HIGH_PD_CFG` configurations for the - TheRobotStudio SO-101 5-DOF follower arm. diff --git a/source/isaaclab_assets/config/extension.toml b/source/isaaclab_assets/config/extension.toml index 055e3a5ff2f..38e4707a809 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.4" +version = "0.4.0" # Description title = "Isaac Lab Assets" diff --git a/source/isaaclab_assets/docs/CHANGELOG.rst b/source/isaaclab_assets/docs/CHANGELOG.rst index e9eda582221..a11a177c97f 100644 --- a/source/isaaclab_assets/docs/CHANGELOG.rst +++ b/source/isaaclab_assets/docs/CHANGELOG.rst @@ -1,6 +1,17 @@ Changelog --------- +0.4.0 (2026-06-27) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :obj:`~isaaclab_assets.robots.so101.SO101_CFG` and + :obj:`~isaaclab_assets.robots.so101.SO101_HIGH_PD_CFG` configurations for the + TheRobotStudio SO-101 5-DOF follower arm. + + 0.3.4 (2026-05-12) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_assets/pyproject.toml b/source/isaaclab_assets/pyproject.toml index cce714d643d..cbd4b3a156e 100644 --- a/source/isaaclab_assets/pyproject.toml +++ b/source/isaaclab_assets/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_assets" -version = "0.3.4" +version = "0.4.0" description = "Extension containing configuration instances of different assets and sensors." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst deleted file mode 100644 index 75ebce232ad..00000000000 --- a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-tendons.minor.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg` and its applier - :func:`~isaaclab_newton.sim.schemas.apply_mujoco_fixed_tendon` for tuning ``mjc:*`` - fixed-tendon attributes on ``MjcTendon`` prims, - splitting the Mujoco tune path out of the PhysX fixed-tendon applier. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 76c89b9afd9..9c7f58d42c9 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 = "1.3.0" +version = "1.4.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 1f6e2a807fd..1a752a3a217 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +1.4.0 (2026-06-27) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg` and its applier + :func:`~isaaclab_newton.sim.schemas.apply_mujoco_fixed_tendon` for tuning ``mjc:*`` + fixed-tendon attributes on ``MjcTendon`` prims, + splitting the Mujoco tune path out of the PhysX fixed-tendon applier. + + 1.3.0 (2026-06-26) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index 38cbf741d7d..579b86480c4 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_newton" -version = "1.3.0" +version = "1.4.0" description = "Extension providing IsaacLab with Newton specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation_view.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation_view.major.rst deleted file mode 100644 index 789881fe48f..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_articulation_view.major.rst +++ /dev/null @@ -1,13 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** :attr:`~isaaclab_ovphysx.assets.Articulation.root_view` now returns an - :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw - ``dict`` mapping ``TensorType`` to ``TensorBinding``. The view owns all tensor-binding - creation, caching, reads, and writes for the articulation. Address bindings by attribute - name or ``TensorType`` member through - :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.try_binding_for` / - :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.get_attribute` rather than indexing the - dict, e.g. replace ``root_view[tensor_type]`` with - ``root_view.try_binding_for(tensor_type)`` and ``tensor_type in root_view`` with - ``root_view.try_binding_for(tensor_type) is not None``. diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contact_sensor_view.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_contact_sensor_view.skip deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_frameview_scenedata.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_frameview_scenedata.skip deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu_view.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_imu_view.skip deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_joint_wrench_view.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_joint_wrench_view.skip deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_pva_view.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_pva_view.skip deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject_view.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject_view.major.rst deleted file mode 100644 index 61ad8f86c27..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject_view.major.rst +++ /dev/null @@ -1,9 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** :attr:`~isaaclab_ovphysx.assets.RigidObject.root_view` now returns an - :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw - ``dict`` mapping ``TensorType`` to ``TensorBinding``. The view owns all tensor-binding - creation, caching, reads, and writes for the rigid object. Replace ``root_view[tensor_type]`` - with ``root_view.try_binding_for(tensor_type)`` / - :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.get_attribute`. diff --git a/source/isaaclab_ovphysx/changelog.d/physx-com-cache.rst b/source/isaaclab_ovphysx/changelog.d/physx-com-cache.rst deleted file mode 100644 index e5b79b26c5c..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/physx-com-cache.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed repeated OVPhysX articulation body-frame center-of-mass pose reads by caching them as model - properties and invalidating dependent buffers when center-of-mass offsets are updated. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index 5d64576ae09..b820371d388 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 = "4.0.0" +version = "5.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 a4829a64709..a2e2148ebd2 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,36 @@ Changelog --------- +5.0.0 (2026-06-27) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* **Breaking:** :attr:`~isaaclab_ovphysx.assets.Articulation.root_view` now returns an + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw + ``dict`` mapping ``TensorType`` to ``TensorBinding``. The view owns all tensor-binding + creation, caching, reads, and writes for the articulation. Address bindings by attribute + name or ``TensorType`` member through + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.try_binding_for` / + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.get_attribute` rather than indexing the + dict, e.g. replace ``root_view[tensor_type]`` with + ``root_view.try_binding_for(tensor_type)`` and ``tensor_type in root_view`` with + ``root_view.try_binding_for(tensor_type) is not None``. +* **Breaking:** :attr:`~isaaclab_ovphysx.assets.RigidObject.root_view` now returns an + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw + ``dict`` mapping ``TensorType`` to ``TensorBinding``. The view owns all tensor-binding + creation, caching, reads, and writes for the rigid object. Replace ``root_view[tensor_type]`` + with ``root_view.try_binding_for(tensor_type)`` / + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxView.get_attribute`. + +Fixed +^^^^^ + +* Fixed repeated OVPhysX articulation body-frame center-of-mass pose reads by caching them as model + properties and invalidating dependent buffers when center-of-mass offsets are updated. + + 4.0.0 (2026-06-26) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index 4619ddb6c17..46ba8f83b3a 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ovphysx" -version = "4.0.0" +version = "5.0.0" description = "Extension providing IsaacLab with ovphysx/TensorBindingsAPI specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/physx-com-cache.rst b/source/isaaclab_physx/changelog.d/physx-com-cache.rst deleted file mode 100644 index bda2f58ef8e..00000000000 --- a/source/isaaclab_physx/changelog.d/physx-com-cache.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed repeated PhysX articulation body-frame center-of-mass pose reads by caching them as model - properties and invalidating dependent buffers when center-of-mass offsets are updated. diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst deleted file mode 100644 index 78e06806579..00000000000 --- a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-tendons.minor.rst +++ /dev/null @@ -1,22 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` and - :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonCfg`, the PhysX tendon schema - fragments. They override ``func`` with - :func:`~isaaclab_physx.sim.schemas.apply_fixed_tendon` / - :func:`~isaaclab_physx.sim.schemas.apply_spatial_tendon`, which delegate to the existing - multi-instance tendon writers to tune every applied ``PhysxTendonAxisRootAPI`` / - ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` instance. - -Changed -^^^^^^^ - -* Reworked :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` / - :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonCfg` appliers to tune the multi-instance - PhysX tendon schemas directly, removing the dependency on the legacy - ``modify_*_tendon_properties`` writers and the legacy ``Physx*TendonPropertiesCfg`` reconstruction. - Callers relying on :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg` - reconstruction inside the applier should pass a - :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` fragment directly to - :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` instead. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 44a7f2fdf03..b9a934f8fe3 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 = "2.3.0" +version = "2.4.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 dbe245bc3bc..2a6e262c9d8 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,39 @@ Changelog --------- +2.4.0 (2026-06-27) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` and + :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonCfg`, the PhysX tendon schema + fragments. They override ``func`` with + :func:`~isaaclab_physx.sim.schemas.apply_fixed_tendon` / + :func:`~isaaclab_physx.sim.schemas.apply_spatial_tendon`, which delegate to the existing + multi-instance tendon writers to tune every applied ``PhysxTendonAxisRootAPI`` / + ``PhysxTendonAttachmentRootAPI`` / ``PhysxTendonAttachmentLeafAPI`` instance. + +Changed +^^^^^^^ + +* Reworked :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` / + :class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonCfg` appliers to tune the multi-instance + PhysX tendon schemas directly, removing the dependency on the legacy + ``modify_*_tendon_properties`` writers and the legacy ``Physx*TendonPropertiesCfg`` reconstruction. + Callers relying on :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg` + reconstruction inside the applier should pass a + :class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonCfg` fragment directly to + :func:`~isaaclab.sim.schemas.apply_fixed_tendon_properties` instead. + +Fixed +^^^^^ + +* Fixed repeated PhysX articulation body-frame center-of-mass pose reads by caching them as model + properties and invalidating dependent buffers when center-of-mass offsets are updated. + + 2.3.0 (2026-06-26) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index d949e646bcd..938bc4079cd 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "2.3.0" +version = "2.4.0" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/jiwenc-so-101.minor.rst b/source/isaaclab_tasks/changelog.d/jiwenc-so-101.minor.rst deleted file mode 100644 index a4b858d955a..00000000000 --- a/source/isaaclab_tasks/changelog.d/jiwenc-so-101.minor.rst +++ /dev/null @@ -1,53 +0,0 @@ -Added -^^^^^ - -* Added ``Isaac-Stack-Cube-SO101-v0`` and ``Isaac-Stack-Cube-SO101-IK-Abs-v0`` cube-stacking - environments for the SO-101 5-DOF arm. The IK-Abs variant uses absolute task-space - differential inverse kinematics and is seated on the table. -* Generalized the cube-stack MDP gripper observations and terminations to support single-jaw - grippers in addition to two-finger parallel grippers. -* Added XR teleoperation for ``Isaac-Stack-Cube-SO101-IK-Abs-v0`` via an IsaacTeleop retargeting - pipeline built from the SO-101 retargeters in ``isaacteleop.retargeters``. Controller poses are - rebased into the robot base frame upstream via - :attr:`~isaaclab_teleop.IsaacTeleopCfg.target_frame_prim_path` (set to the seated, +90 deg-yawed - base prim), so the retargeters work directly in the IK command frame. End-effector motion is - clutch-rebased around the pose captured on engage: the clutch latches on the first running frame - (the headset "Play") and seeds its home from a static reset-origin (the gripper's pose in the - base frame at the seated init pose) so engaging with a steady controller does not move the arm, - then keeps a running home so a mid-task re-clutch resumes from the last commanded pose. The - gripper jaw tracks the controller trigger continuously (analog). - -Changed -^^^^^^^ - -* **Breaking:** Reworked the ``Isaac-Stack-Cube-SO101-IK-Abs-v0`` teleop control to command the - full end-effector SE3 pose. The arm action is now an 8-D - ``[pos_x, pos_y, pos_z, quat_x, quat_y, quat_z, quat_w, gripper]`` (orientation xyzw), - replacing the previous 6-D ``[pos_x, pos_y, pos_z, pitch, roll, gripper]``. A single - full-pose differential IK (``SO101PoseIKController``, ``command_type="pose"``) now solves all - **5** arm joints (``shoulder_pan``, ``shoulder_lift``, ``elbow_flex``, ``wrist_flex``, - ``wrist_roll``) over a 6-row task: 3 linear rows track position exactly (weight 1) and 3 - orientation rows are soft-weighted by the core controller's ``orientation_weight`` (default - ``0.5``). This replaces the reduced 4-row ``[x, y, z, pitch]`` IK over 4 joints plus a separate - ``wrist_roll`` action. The manipulability-aware damped least squares - (``ik_method="adaptive_dls"``) and null-space joint-limit avoidance are now provided by the core - :class:`~isaaclab.controllers.DifferentialIKController`; ``SO101PoseIKController`` only adds the - wrist-only orientation joint mask (restricting orientation to ``wrist_flex`` / ``wrist_roll`` so - ``shoulder_pan`` stays position-only) on top. - - Migration: the controller / action classes were renamed - ``SO101PositionPitchIK{Controller,ControllerCfg,Action,ActionCfg}`` -> - ``SO101PoseIK{...}`` (modules ``position_pitch_ik_{controller,action}.py`` -> - ``pose_ik_{controller,action}.py``), the cfg field ``pitch_task_weight`` -> - ``orientation_weight``, and the ``wrist_roll_action`` term was removed (``wrist_roll`` is - now solved by the IK). The SO-101 ``SO101WristRetargeter`` (and its ``SO101RollRetargeter`` - alias) was removed from ``isaacteleop.retargeters``; the clutch retargeter now drives the full - pose with a fixed orientation calibration offset. -* Refactored the cube-stack scaffolding so the SO-101 config no longer depends on Franka. The - cube setup, table/ground/robot semantics, end-effector frame builder, and reset events were - promoted to the robot-neutral :class:`~isaaclab_tasks.contrib.stack.stack_env_cfg.StackEnvCfg` - base, and the Franka and SO-101 joint-position configs are now override-only. The generic stack - event functions moved to ``isaaclab_tasks.contrib.stack.mdp.stack_events`` (``franka_stack_events`` - re-exports them for backward compatibility), and ``randomize_joint_by_gaussian_offset`` now holds - the gripper joints fixed by resolving them from the env's ``gripper_joint_names`` rather than - assuming the last two joints are the gripper. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 554c6cdbddf..aa7d9ee1374 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 = "8.0.7" +version = "8.1.0" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 18d415207bf..669832006f3 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,64 @@ Changelog --------- +8.1.0 (2026-06-27) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``Isaac-Stack-Cube-SO101-v0`` and ``Isaac-Stack-Cube-SO101-IK-Abs-v0`` cube-stacking + environments for the SO-101 5-DOF arm. The IK-Abs variant uses absolute task-space + differential inverse kinematics and is seated on the table. +* Generalized the cube-stack MDP gripper observations and terminations to support single-jaw + grippers in addition to two-finger parallel grippers. +* Added XR teleoperation for ``Isaac-Stack-Cube-SO101-IK-Abs-v0`` via an IsaacTeleop retargeting + pipeline built from the SO-101 retargeters in ``isaacteleop.retargeters``. Controller poses are + rebased into the robot base frame upstream via + :attr:`~isaaclab_teleop.IsaacTeleopCfg.target_frame_prim_path` (set to the seated, +90 deg-yawed + base prim), so the retargeters work directly in the IK command frame. End-effector motion is + clutch-rebased around the pose captured on engage: the clutch latches on the first running frame + (the headset "Play") and seeds its home from a static reset-origin (the gripper's pose in the + base frame at the seated init pose) so engaging with a steady controller does not move the arm, + then keeps a running home so a mid-task re-clutch resumes from the last commanded pose. The + gripper jaw tracks the controller trigger continuously (analog). + +Changed +^^^^^^^ + +* **Breaking:** Reworked the ``Isaac-Stack-Cube-SO101-IK-Abs-v0`` teleop control to command the + full end-effector SE3 pose. The arm action is now an 8-D + ``[pos_x, pos_y, pos_z, quat_x, quat_y, quat_z, quat_w, gripper]`` (orientation xyzw), + replacing the previous 6-D ``[pos_x, pos_y, pos_z, pitch, roll, gripper]``. A single + full-pose differential IK (``SO101PoseIKController``, ``command_type="pose"``) now solves all + **5** arm joints (``shoulder_pan``, ``shoulder_lift``, ``elbow_flex``, ``wrist_flex``, + ``wrist_roll``) over a 6-row task: 3 linear rows track position exactly (weight 1) and 3 + orientation rows are soft-weighted by the core controller's ``orientation_weight`` (default + ``0.5``). This replaces the reduced 4-row ``[x, y, z, pitch]`` IK over 4 joints plus a separate + ``wrist_roll`` action. The manipulability-aware damped least squares + (``ik_method="adaptive_dls"``) and null-space joint-limit avoidance are now provided by the core + :class:`~isaaclab.controllers.DifferentialIKController`; ``SO101PoseIKController`` only adds the + wrist-only orientation joint mask (restricting orientation to ``wrist_flex`` / ``wrist_roll`` so + ``shoulder_pan`` stays position-only) on top. + + Migration: the controller / action classes were renamed + ``SO101PositionPitchIK{Controller,ControllerCfg,Action,ActionCfg}`` -> + ``SO101PoseIK{...}`` (modules ``position_pitch_ik_{controller,action}.py`` -> + ``pose_ik_{controller,action}.py``), the cfg field ``pitch_task_weight`` -> + ``orientation_weight``, and the ``wrist_roll_action`` term was removed (``wrist_roll`` is + now solved by the IK). The SO-101 ``SO101WristRetargeter`` (and its ``SO101RollRetargeter`` + alias) was removed from ``isaacteleop.retargeters``; the clutch retargeter now drives the full + pose with a fixed orientation calibration offset. +* Refactored the cube-stack scaffolding so the SO-101 config no longer depends on Franka. The + cube setup, table/ground/robot semantics, end-effector frame builder, and reset events were + promoted to the robot-neutral :class:`~isaaclab_tasks.contrib.stack.stack_env_cfg.StackEnvCfg` + base, and the Franka and SO-101 joint-position configs are now override-only. The generic stack + event functions moved to ``isaaclab_tasks.contrib.stack.mdp.stack_events`` (``franka_stack_events`` + re-exports them for backward compatibility), and ``randomize_joint_by_gaussian_offset`` now holds + the gripper joints fixed by resolving them from the env's ``gripper_joint_names`` rather than + assuming the last two joints are the gripper. + + 8.0.7 (2026-06-26) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index 32d6e948075..4e1ebfd167b 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.0.7" +version = "8.1.0" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 44d5e35ffb3e48b2f13ff58968ec9c4b7e281686 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Sat, 27 Jun 2026 08:28:43 -0700 Subject: [PATCH 039/139] Keep backend fixed-root-joint hook import free of USD libraries The PhysX and Newton schema packages registered their fixed-root-joint creators by importing the register function from the core schemas module at package import time. That core module imports pxr at module top, so importing a backend package eagerly pulled USD libraries into otherwise USD-free import paths (e.g. resolving a robot config), failing the omni/pxr-free import guard. Move the registry and registration API into a dedicated USD-free hooks module so a backend can register its hook at import time without dragging in pxr. --- .../isaaclab/sim/schemas/_backend_hooks.py | 52 +++++++++++++++++++ .../isaaclab/isaaclab/sim/schemas/schemas.py | 37 +------------ .../test/sim/test_articulation_fragments.py | 12 ++--- .../isaaclab_newton/sim/schemas/__init__.py | 2 +- .../isaaclab_physx/sim/schemas/__init__.py | 2 +- 5 files changed, 60 insertions(+), 45 deletions(-) create mode 100644 source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py diff --git a/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py new file mode 100644 index 00000000000..17299bd81f7 --- /dev/null +++ b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py @@ -0,0 +1,52 @@ +# 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 registration hooks for schema writers. + +This module holds the inversion-of-control registries that let physics backends (e.g. +``isaaclab_physx``, ``isaaclab_newton``) inject backend-specific behaviour into the core schema +writers without core importing any backend. It is deliberately kept free of ``pxr``/``omni`` imports +so a backend can register its hook at package-import time without eagerly pulling USD libraries into +an otherwise USD-free import path. +""" + +from __future__ import annotations + +from collections.abc import Callable + +# Backend-registered creators that fix an articulation base by authoring a world<->root fixed joint. +# Creating it is backend-specific (PhysX requires relocating the articulation root to the parent prim +# to work around its parser; Newton reads the fixed joint directly), so each backend registers its own +# creator plus a predicate that reports when that backend is the active simulation. This keeps core +# free of backend knowledge while letting each backend run on its own. +_FIXED_ROOT_JOINT_CREATORS: list[tuple[Callable, Callable]] = [] + + +def register_fixed_root_joint_creator(creator: Callable, is_active: Callable) -> None: + """Register a backend creator that fixes an articulation base via a world<->root fixed joint. + + Called by :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` when ``fix_root_link`` + is True and no fixed joint yet exists. Multiple backends may register; the creator whose + ``is_active()`` predicate returns True for the running simulation is selected, so each physics + backend (e.g. ``isaaclab_physx``, ``isaaclab_newton``) provides and is matched to its own + implementation without core knowing any of them. + + Args: + creator: A callable ``creator(articulation_prim, stage)`` that authors the fixed joint. + is_active: A callable ``is_active() -> bool`` returning True when ``creator``'s backend is the + active simulation backend. + """ + _FIXED_ROOT_JOINT_CREATORS.append((is_active, creator)) + + +def _resolve_fixed_root_joint_creator(): + """Return the creator whose backend is active, or None if no registered backend matches.""" + for is_active, creator in _FIXED_ROOT_JOINT_CREATORS: + try: + if is_active(): + return creator + except Exception: # noqa: BLE001 -- a backend's probe must never break creator resolution + continue + return None diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 92a1e0de65c..49a794cf502 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -29,6 +29,7 @@ safe_set_attribute_on_usd_schema, ) from . import schemas_cfg +from ._backend_hooks import _resolve_fixed_root_joint_creator # import logger logger = logging.getLogger(__name__) @@ -268,42 +269,6 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd """ -# Backend-registered creators that fix an articulation base by authoring a world<->root fixed joint. -# Creating it is backend-specific (PhysX requires relocating the articulation root to the parent prim -# to work around its parser; Newton reads the fixed joint directly), so each backend registers its own -# creator plus a predicate that reports when that backend is the active simulation. This keeps core -# free of backend knowledge while letting each backend run on its own. -_FIXED_ROOT_JOINT_CREATORS: list = [] - - -def register_fixed_root_joint_creator(creator, is_active) -> None: - """Register a backend creator that fixes an articulation base via a world<->root fixed joint. - - Called by :func:`apply_articulation_root_properties` when ``fix_root_link=True`` and no fixed joint - yet exists. Multiple backends may register; the creator whose ``is_active()`` predicate returns True - for the running simulation is selected, so each physics backend (e.g. ``isaaclab_physx``, - ``isaaclab_newton``) provides and is matched to its own implementation without core knowing any of - them. - - Args: - creator: A callable ``creator(articulation_prim, stage)`` that authors the fixed joint. - is_active: A callable ``is_active() -> bool`` returning True when ``creator``'s backend is the - active simulation backend. - """ - _FIXED_ROOT_JOINT_CREATORS.append((is_active, creator)) - - -def _resolve_fixed_root_joint_creator(): - """Return the creator whose backend is active, or None if no registered backend matches.""" - for is_active, creator in _FIXED_ROOT_JOINT_CREATORS: - try: - if is_active(): - return creator - except Exception: # noqa: BLE001 -- a backend's probe must never break creator resolution - continue - return None - - def apply_articulation_root_properties( prim_path: str, fragments, diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index cd145277390..8275e69b872 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -254,10 +254,9 @@ def test_apply_articulation_root_properties_fix_root_link_without_backend_raises Uses monkeypatch to clear the module-global creator registry (other tests/backends register session-wide); monkeypatch restores it afterwards so this stays isolated. """ - from isaaclab.sim.schemas import apply_articulation_root_properties - from isaaclab.sim.schemas import schemas as core_schemas + from isaaclab.sim.schemas import _backend_hooks, apply_articulation_root_properties - monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATORS", []) + monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", []) sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -272,11 +271,10 @@ def test_apply_articulation_root_properties_fix_root_link_without_backend_raises def test_register_fixed_root_joint_creator_selects_active_backend(monkeypatch): """The writer selects the registered creator whose backend predicate reports active, and ignores creators whose backend is inactive.""" - from isaaclab.sim.schemas import apply_articulation_root_properties - from isaaclab.sim.schemas import schemas as core_schemas - from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator + from isaaclab.sim.schemas import _backend_hooks, apply_articulation_root_properties + from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator - monkeypatch.setattr(core_schemas, "_FIXED_ROOT_JOINT_CREATORS", []) + monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", []) called = {} # an inactive backend's creator must be skipped, the active one's used diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index f3b406919c2..244b00e3336 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -5,7 +5,7 @@ """Sub-module containing Newton schema configuration exports.""" -from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator +from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export lazy_export() diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index 0cea9e04b7c..2e41f5f7112 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -5,7 +5,7 @@ """Sub-module containing PhysX schema configuration exports.""" -from isaaclab.sim.schemas.schemas import register_fixed_root_joint_creator +from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export lazy_export() From 25b30c1020aa2ced43e3c87e08e805217c7333f6 Mon Sep 17 00:00:00 2001 From: vidurv-nvidia Date: Sat, 27 Jun 2026 15:57:11 -0500 Subject: [PATCH 040/139] Adding Mesh Collision USD data classes and writers (#6264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Adds the **mesh-collision** schema-fragment API (collision-mesh cooking approximations). - `MeshCollisionFragment` marker + `UsdPhysicsMeshCollisionCfg` (`physics:approximation` token; `UsdPhysics.MeshCollisionAPI` anchor) in `isaaclab`. - PhysX cooking fragments in `isaaclab_physx` — `PhysxConvexHullCfg`, `PhysxConvexDecompositionCfg`, `PhysxTriangleMeshCfg`, `PhysxSDFMeshCfg` (each its own `physx*Collision:*` namespace + applied schema). - `NewtonMeshCollisionCfg` / `NewtonSDFCollisionCfg` in `isaaclab_newton`. - `apply_mesh_collision` — the default `func` every `MeshCollisionFragment` carries. It writes the fragment's backend cooking namespace (via `apply_namespaced`) **and** the shared `physics:approximation` token its `mesh_approximation_name` implies (skipping `"none"` so it does not clobber). This keeps the approximation-token coupling with the fragment, matching the tendon fragments where the backend application logic lives in a `func` rather than being special-cased by the caller. - `apply_mesh_collision_properties` family writer — applies the `MeshCollisionAPI` anchor, then dispatches each fragment via its `func` and aggregates the results. It is the same generic shape as the sibling family writers (`apply_collision_properties`, `apply_rigid_body_properties`); the shared-token "last non-`none` wins" behavior emerges from dispatch order rather than a special reduction pass in the writer body. Core imports no backend. - The spawner `mesh_collision_props` slot now also accepts a fragment list. This PR is purely **additive** and self-contained: it builds only on the single-namespace schema-fragment base (`SchemaFragment` + `apply_namespaced`) already in `develop`, existing call sites are untouched (a transition bridge routes legacy single cfgs to the existing `define_`/`modify_` writers), and it does **not** depend on any other open PR. ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots N/A — non-visual API 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 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 --- ...vidurv-schema-frag-meshcollision.minor.rst | 20 + source/isaaclab/isaaclab/sim/__init__.pyi | 8 + .../isaaclab/sim/converters/mesh_converter.py | 28 +- .../sim/converters/mesh_converter_cfg.py | 16 +- .../isaaclab/sim/schemas/__init__.pyi | 8 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 110 ++++++ .../isaaclab/sim/schemas/schemas_cfg.py | 54 ++- .../test/sim/test_mesh_collision_fragments.py | 353 ++++++++++++++++++ ...vidurv-schema-frag-meshcollision.minor.rst | 9 + .../isaaclab_newton/sim/schemas/__init__.pyi | 4 + .../sim/schemas/schemas_cfg.py | 113 ++++++ ...vidurv-schema-frag-meshcollision.minor.rst | 11 + .../isaaclab_physx/sim/schemas/__init__.pyi | 10 + .../isaaclab_physx/sim/schemas/schemas_cfg.py | 166 ++++++++ 14 files changed, 898 insertions(+), 12 deletions(-) create mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-meshcollision.minor.rst create mode 100644 source/isaaclab/test/sim/test_mesh_collision_fragments.py create mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-meshcollision.minor.rst create mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-meshcollision.minor.rst diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-meshcollision.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-meshcollision.minor.rst new file mode 100644 index 00000000000..3532b8f68ff --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-meshcollision.minor.rst @@ -0,0 +1,20 @@ +Added +^^^^^ + +* Added the mesh-collision schema-fragment API: the + :class:`~isaaclab.sim.schemas.MeshCollisionFragment` marker and + :class:`~isaaclab.sim.schemas.UsdPhysicsMeshCollisionCfg` (carrying the standard + ``physics:approximation`` token via ``UsdPhysics.MeshCollisionAPI``). +* Added :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`, which applies + ``UsdPhysics.MeshCollisionAPI`` as the implicit anchor, resolves the + ``physics:approximation`` token from whichever cooking fragment is present (validated against + :const:`~isaaclab.sim.schemas.MESH_APPROXIMATION_TOKENS`), and dispatches each fragment via its + ``func``. + +Changed +^^^^^^^ + +* Changed the mesh-converter ``mesh_collision_props`` slot + (:attr:`~isaaclab.sim.converters.MeshConverterCfg.mesh_collision_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.MeshCollisionFragment` fragments. Legacy single cfgs continue to + work through a transition bridge in the converter. diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 1204269f630..eadfbca6d34 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -60,6 +60,7 @@ __all__ = [ "NewtonMeshCollisionPropertiesCfg", "NewtonRigidBodyPropertiesCfg", "NewtonSDFCollisionPropertiesCfg", + "MeshCollisionFragment", "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyPropertiesCfg", "RigidBodyBaseCfg", @@ -67,10 +68,13 @@ __all__ = [ "SchemaFragment", "SpatialTendonFragment", "UsdPhysicsCollisionCfg", + "UsdPhysicsMeshCollisionCfg", "UsdPhysicsRigidBodyCfg", "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", + "apply_mesh_collision", + "apply_mesh_collision_properties", "apply_namespaced", "apply_rigid_body_properties", "apply_spatial_tendon_properties", @@ -236,6 +240,7 @@ from .schemas import ( MassCfg, MassFragment, MassPropertiesCfg, + MeshCollisionFragment, MeshCollisionPropertiesCfg, PhysxJointDrivePropertiesCfg, PhysxRigidBodyPropertiesCfg, @@ -248,11 +253,14 @@ from .schemas import ( TriangleMeshPropertiesCfg, TriangleMeshSimplificationPropertiesCfg, UsdPhysicsCollisionCfg, + UsdPhysicsMeshCollisionCfg, UsdPhysicsRigidBodyCfg, activate_contact_sensors, apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, + apply_mesh_collision, + apply_mesh_collision_properties, apply_namespaced, apply_rigid_body_properties, apply_spatial_tendon_properties, diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py index f4022507eec..186811143b7 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter.py @@ -12,9 +12,10 @@ from isaacsim.core.experimental.utils.app import enable_extension from pxr import Gf, Tf, Usd, UsdGeom, UsdPhysics, UsdUtils -from isaaclab.sim import schemas from isaaclab.sim.converters.asset_converter_base import AssetConverterBase from isaaclab.sim.converters.mesh_converter_cfg import MeshConverterCfg +from isaaclab.sim.schemas import schemas +from isaaclab.sim.schemas.schemas_cfg import SchemaFragment from isaaclab.sim.utils import delete_prim, export_prim_to_file # import logger @@ -130,7 +131,7 @@ def _convert_asset(self, cfg: MeshConverterCfg): coll_frags = ( cfg.collision_props if isinstance(cfg.collision_props, (list, tuple)) else [cfg.collision_props] ) - if coll_frags and all(isinstance(f, schemas.SchemaFragment) for f in coll_frags): + if coll_frags and all(isinstance(f, SchemaFragment) for f in coll_frags): schemas.apply_collision_properties(str(child_mesh_prim.GetPath()), coll_frags, stage=stage) else: schemas.define_collision_properties( @@ -138,9 +139,22 @@ def _convert_asset(self, cfg: MeshConverterCfg): ) # Add collision mesh if cfg.mesh_collision_props is not None: - schemas.define_mesh_collision_properties( - prim_path=child_mesh_prim.GetPath(), cfg=cfg.mesh_collision_props, stage=stage + # Transition bridge: route a fragment (or list of fragments) through the new + # ``apply_mesh_collision_properties`` family writer; otherwise fall back to the + # legacy single-cfg ``define_mesh_collision_properties`` path. + mesh_collision_frags = ( + cfg.mesh_collision_props + if isinstance(cfg.mesh_collision_props, (list, tuple)) + else [cfg.mesh_collision_props] ) + if all(isinstance(f, SchemaFragment) for f in mesh_collision_frags): + schemas.apply_mesh_collision_properties( + prim_path=child_mesh_prim.GetPath(), fragments=mesh_collision_frags, stage=stage + ) + else: + schemas.define_mesh_collision_properties( + prim_path=child_mesh_prim.GetPath(), cfg=cfg.mesh_collision_props, stage=stage + ) # Delete the old Xform and make the new Xform the default prim stage.SetDefaultPrim(xform_prim) # Apply default Xform rotation to mesh -> enable to set rotation and scale @@ -192,15 +206,15 @@ def _convert_asset(self, cfg: MeshConverterCfg): # apply mass properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.mass_props is not None: # normalize a single fragment to a list so the convenience form routes like a list - mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, schemas.SchemaFragment) else cfg.mass_props - if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, schemas.SchemaFragment) for f in mass_frags): + mass_frags = [cfg.mass_props] if isinstance(cfg.mass_props, SchemaFragment) else cfg.mass_props + if isinstance(mass_frags, (list, tuple)) and all(isinstance(f, SchemaFragment) for f in mass_frags): schemas.apply_mass_properties(str(xform_prim.GetPath()), mass_frags, stage=stage) else: schemas.define_mass_properties(prim_path=xform_prim.GetPath(), cfg=cfg.mass_props, stage=stage) # apply rigid body properties (transition shim, remove later: fragment list -> apply_*; legacy cfg -> define_*) if cfg.rigid_props is not None: rigid_frags = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] - if rigid_frags and all(isinstance(f, schemas.SchemaFragment) for f in rigid_frags): + if rigid_frags and all(isinstance(f, SchemaFragment) for f in rigid_frags): schemas.apply_rigid_body_properties(str(xform_prim.GetPath()), rigid_frags, stage=stage) else: schemas.define_rigid_body_properties(prim_path=xform_prim.GetPath(), cfg=cfg.rigid_props, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py b/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py index 09c887a4899..df25b5cba3f 100644 --- a/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py +++ b/source/isaaclab/isaaclab/sim/converters/mesh_converter_cfg.py @@ -39,8 +39,22 @@ class MeshConverterCfg(AssetConverterBaseCfg): Note: If None, then no collision properties will be added. """ - mesh_collision_props: schemas_cfg.MeshCollisionBaseCfg = None + mesh_collision_props: ( + schemas_cfg.MeshCollisionBaseCfg + | schemas_cfg.MeshCollisionFragment + | list[schemas_cfg.MeshCollisionFragment] + | None + ) = None """Mesh approximation properties to apply to all collision meshes in the USD. + + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.MeshCollisionBaseCfg` or + a ``Physx*PropertiesCfg`` cooking cfg) or a list of + :class:`~isaaclab.sim.schemas.MeshCollisionFragment` fragments (e.g. + ``[UsdPhysicsMeshCollisionCfg(...), PhysxConvexHullCfg(...)]``). When a fragment list is given, + ``UsdPhysics.MeshCollisionAPI`` is applied as the implicit anchor, the ``physics:approximation`` + token is resolved from whichever cooking fragment is present, and each fragment writes its own + namespace. + Note: If None, then no mesh approximation properties will be added. """ diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index ef116b499ce..f4e9f7cff59 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -11,6 +11,8 @@ __all__ = [ "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", + "apply_mesh_collision", + "apply_mesh_collision_properties", "apply_namespaced", "apply_rigid_body_properties", "apply_spatial_tendon_properties", @@ -43,10 +45,12 @@ __all__ = [ "MassFragment", "MassPropertiesCfg", "MeshCollisionBaseCfg", + "MeshCollisionFragment", "RigidBodyFragment", "SchemaFragment", "SpatialTendonFragment", "UsdPhysicsCollisionCfg", + "UsdPhysicsMeshCollisionCfg", "UsdPhysicsRigidBodyCfg", "MujocoJointDrivePropertiesCfg", "MujocoRigidBodyPropertiesCfg", @@ -68,6 +72,8 @@ from .schemas import ( apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, + apply_mesh_collision, + apply_mesh_collision_properties, apply_namespaced, apply_rigid_body_properties, apply_spatial_tendon_properties, @@ -104,11 +110,13 @@ from .schemas_cfg import ( MassFragment, MassPropertiesCfg, MeshCollisionBaseCfg, + MeshCollisionFragment, RigidBodyBaseCfg, RigidBodyFragment, SchemaFragment, SpatialTendonFragment, UsdPhysicsCollisionCfg, + UsdPhysicsMeshCollisionCfg, UsdPhysicsRigidBodyCfg, ) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 1e596973b03..ddae4582c7d 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -255,6 +255,13 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd # ``func`` is the only non-USD field; non-scalar values raise in the setter if f.name == "func": continue + # ``mesh_approximation_name`` is not a namespaced attribute: it is the standard + # ``physics:approximation`` token, written by ``apply_mesh_collision_properties`` (the + # family writer) which validates it against ``MESH_APPROXIMATION_TOKENS``. Skip it here + # so a mesh-collision cooking fragment dispatched through this generic applier does not + # author a spurious ``:meshApproximationName`` attribute. + if f.name == "mesh_approximation_name": + continue value = getattr(cfg, f.name) if value is None: continue @@ -468,6 +475,109 @@ def apply_rigid_body_properties( return success +def apply_mesh_collision( + cfg: schemas_cfg.MeshCollisionFragment, prim_path: str, stage: Usd.Stage | None = None +) -> bool: + """Apply a single mesh-collision fragment: its namespaced cooking attrs plus the shared token. + + This is the default :attr:`~isaaclab.sim.schemas.SchemaFragment.func` for every + :class:`~isaaclab.sim.schemas.MeshCollisionFragment`. Unlike the generic :func:`apply_namespaced`, + a mesh-collision fragment additionally authors the shared ``physics:approximation`` token (via the + standard ``UsdPhysics.MeshCollisionAPI``) on top of its own backend cooking namespace. + + The token is *not* a plain namespaced attribute -- it is shared state on the family anchor implied + by the present cooking fragment. Each fragment carries a :attr:`mesh_approximation_name` whose + default encodes the token its schema implies (e.g. ``"convexHull"`` for :class:`PhysxConvexHullCfg`, + ``"sdf"`` for :class:`PhysxSDFMeshCfg`). A name of ``"none"`` leaves the token unchanged, so when + several fragments are dispatched in order by :func:`apply_mesh_collision_properties` the last one + with a non-``"none"`` name wins -- this is how a core fragment composes with a backend cooking + fragment. The name is validated against :const:`MESH_APPROXIMATION_TOKENS`; an unknown name raises + ``ValueError``. :attr:`mesh_approximation_name` is skipped by :func:`apply_namespaced`, so it is + never authored as a spurious ``:meshApproximationName`` attribute. + + Args: + cfg: The mesh-collision fragment to apply. + prim_path: The prim path to author on. This prim should be a Mesh. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the fragment was applied successfully. + + Raises: + ValueError: If the prim at ``prim_path`` is not valid, or when the fragment's mesh + approximation name is not in :const:`MESH_APPROXIMATION_TOKENS`. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + # ensure the standard MeshCollisionAPI anchor (carrier of ``physics:approximation``) exists + if not UsdPhysics.MeshCollisionAPI(prim): + UsdPhysics.MeshCollisionAPI.Apply(prim) + # write the fragment's backend cooking namespace + applied schema; ``mesh_approximation_name`` is + # skipped by the generic applier as it is the shared token handled below + success = apply_namespaced(cfg, prim_path, stage) + # author the shared ``physics:approximation`` token this fragment implies; ``"none"`` leaves the + # token untouched so a later non-"none" fragment in a list dispatch wins + name = getattr(cfg, "mesh_approximation_name", None) + if name is not None and name != "none": + if name not in MESH_APPROXIMATION_TOKENS: + raise ValueError( + f"Invalid mesh approximation name: '{name}'. " + f"Valid options are: {list(MESH_APPROXIMATION_TOKENS.keys())}" + ) + safe_set_attribute_on_usd_schema( + UsdPhysics.MeshCollisionAPI(prim), "Approximation", MESH_APPROXIMATION_TOKENS[name], camel_case=False + ) + return success + + +def apply_mesh_collision_properties( + prim_path: str, fragments: Iterable[schemas_cfg.MeshCollisionFragment], stage: Usd.Stage | None = None +) -> bool: + """Apply a list of mesh-collision fragments to a prim. + + Applies ``UsdPhysics.MeshCollisionAPI`` as the implicit anchor (the carrier of the + ``physics:approximation`` token), then dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. The default mesh-collision func + (:func:`apply_mesh_collision`) authors both the fragment's backend cooking namespace and the + shared approximation token it implies, so composing a core fragment with a backend cooking + fragment lets the last fragment with a non-``"none"`` :attr:`mesh_approximation_name` set the + token. Backend cooking fragments carry their own funcs, so core never imports a backend. + + Args: + prim_path: The prim path to apply the mesh-collision schemas on. This prim should be a Mesh. + fragments: An iterable of :class:`~isaaclab.sim.schemas.MeshCollisionFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if all fragments applied successfully, False if any fragment reported failure. + + Raises: + ValueError: If the prim at ``prim_path`` is not valid, or when a fragment's mesh + approximation name is not in :const:`MESH_APPROXIMATION_TOKENS`. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + # fail loudly on an invalid path (matches the sibling apply_* writers) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + # apply the standard MeshCollisionAPI anchor (carrier of ``physics:approximation``) + if not UsdPhysics.MeshCollisionAPI(prim): + UsdPhysics.MeshCollisionAPI.Apply(prim) + # dispatch each fragment via its ``func`` (cooking-schema namespace + implied approximation + # token), aggregating per-fragment results so a reported failure is not masked by the anchor + success = True + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + success = bool(func(cfg, prim_path, stage)) and success + return success + + 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. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index 7729f661bba..138b4ebbe69 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -183,6 +183,24 @@ class CollisionFragment(SchemaFragment): pass +@configclass +class MeshCollisionFragment(SchemaFragment): + """Marker base for mesh-collision fragments; types the ``mesh_collision_props`` slot. + + A mesh-collision concept is split across one *core* fragment carrying the standard + ``physics:approximation`` token (:class:`UsdPhysicsMeshCollisionCfg`) and one cooking + fragment per backend cooking schema (PhysX convex hull / decomposition / triangle mesh / + SDF, Newton mesh / SDF). Whichever cooking fragment is present implies the approximation + token written to ``physics:approximation`` -- see + :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. + """ + + # Mesh-collision fragments author the shared ``physics:approximation`` token in addition to their + # own namespaced cooking attrs, so they dispatch through :func:`~isaaclab.sim.schemas.apply_mesh_collision` + # (not the generic :func:`~isaaclab.sim.schemas.apply_namespaced`). See that func for the token coupling. + func: Callable | str = "isaaclab.sim.schemas:apply_mesh_collision" + + @configclass class FixedTendonFragment(SchemaFragment): """Marker base for fixed-tendon fragments; types the ``fixed_tendons_props`` slot. @@ -233,6 +251,37 @@ class UsdPhysicsCollisionCfg(CollisionFragment): """ +@configclass +class UsdPhysicsMeshCollisionCfg(MeshCollisionFragment): + """``physics:approximation`` mesh-collision token from `UsdPhysics.MeshCollisionAPI`_. + + Carries the standard mesh-collision approximation token (:attr:`mesh_approximation_name` + written to ``physics:approximation``). The ``UsdPhysics.MeshCollisionAPI`` schema is applied + as the implicit anchor by the mesh-collision family writer + (:func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`), so this fragment owns no + applied schema of its own. + + .. note:: + The ``physics:approximation`` attribute is a ``TfToken`` validated against + :const:`~isaaclab.sim.schemas.MESH_APPROXIMATION_TOKENS`; the family writer (not the generic + :func:`~isaaclab.sim.schemas.apply_namespaced` applier) handles the token write, so this + fragment overrides nothing but the namespace metadata. When a PhysX/Newton cooking fragment + is present alongside this one, its default :attr:`mesh_approximation_name` sets the token. + + .. _UsdPhysics.MeshCollisionAPI: https://openusd.org/release/api/class_usd_physics_mesh_collision_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None # MeshCollisionAPI applied by the family anchor + + mesh_approximation_name: str = "none" + """Name of mesh collision approximation method. Default: "none". + + Writes the ``physics:approximation`` token via :class:`UsdPhysics.MeshCollisionAPI`. + Refer to :const:`~isaaclab.sim.schemas.MESH_APPROXIMATION_TOKENS` for available options. + """ + + @configclass class ArticulationRootBaseCfg: """Solver-common properties to apply to the root of an articulation. @@ -637,10 +686,7 @@ class MeshCollisionBaseCfg: """ # -- 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. + # Records the standard API name for the writer's standard-vs-PhysX gating; cooking subclasses override. _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 diff --git a/source/isaaclab/test/sim/test_mesh_collision_fragments.py b/source/isaaclab/test/sim/test_mesh_collision_fragments.py new file mode 100644 index 00000000000..c592c6de75e --- /dev/null +++ b/source/isaaclab/test/sim/test_mesh_collision_fragments.py @@ -0,0 +1,353 @@ +# 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.""" + +from pxr import UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + + +def _make_xform(stage, path="/World/Mesh"): + UsdGeom.Xform.Define(stage, path) + return stage.GetPrimAtPath(path) + + +def _has_authored_api_schema(prim, schema_name: str) -> bool: + """Return whether a schema name is applied or authored in ``apiSchemas`` metadata. + + A schema that is authored via ``AddAppliedSchema`` but not registered in the current build + appears in the ``apiSchemas`` listOp yet not in the composed ``GetAppliedSchemas()``. + """ + if schema_name in prim.GetAppliedSchemas(): + return True + api_schemas = prim.GetMetadata("apiSchemas") + if api_schemas is None: + return False + return any( + schema_name in getattr(api_schemas, item_list) + for item_list in ("explicitItems", "prependedItems", "appendedItems", "addedItems") + ) + + +# ------------------------------------------------------------------------------------- +# Fragment metadata + marker hierarchy +# ------------------------------------------------------------------------------------- + + +def test_mesh_collision_fragment_metadata_defaults(): + from isaaclab.sim.schemas import MeshCollisionFragment, SchemaFragment, UsdPhysicsMeshCollisionCfg + + cfg = UsdPhysicsMeshCollisionCfg(mesh_approximation_name="convexHull") + assert isinstance(cfg, MeshCollisionFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physics" + assert type(cfg)._usd_applied_schema is None # anchor applies MeshCollisionAPI, not the fragment + assert cfg.func == "isaaclab.sim.schemas:apply_mesh_collision" + assert cfg.mesh_approximation_name == "convexHull" + + +# ------------------------------------------------------------------------------------- +# Core USD fragment: physics:approximation token via apply_mesh_collision_properties +# ------------------------------------------------------------------------------------- + + +def test_usd_mesh_collision_fragment_writes_approximation_token(): + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/M0") + apply_mesh_collision_properties( + "/World/M0", [UsdPhysicsMeshCollisionCfg(mesh_approximation_name="boundingCube")], stage + ) + prim = stage.GetPrimAtPath("/World/M0") + assert bool(UsdPhysics.MeshCollisionAPI(prim)) + assert prim.GetAttribute("physics:approximation").Get() == "boundingCube" + + +# ------------------------------------------------------------------------------------- +# PhysX cooking fragments (isaaclab_physx): each writes its own physx*Collision namespace +# ------------------------------------------------------------------------------------- + + +def test_physx_convex_hull_fragment_writes_namespace(): + from isaaclab_physx.sim.schemas import PhysxConvexHullCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M1") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(PhysxConvexHullCfg(hull_vertex_limit=32, min_thickness=0.002), "/World/M1", stage) + assert prim.GetAttribute("physxConvexHullCollision:hullVertexLimit").Get() == 32 + assert abs(prim.GetAttribute("physxConvexHullCollision:minThickness").Get() - 0.002) < 1e-6 + # ``mesh_approximation_name`` must NOT be authored as a namespaced attr by the generic applier. + assert not prim.HasAttribute("physxConvexHullCollision:meshApproximationName") + + +def test_physx_convex_decomposition_fragment_writes_namespace(): + from isaaclab_physx.sim.schemas import PhysxConvexDecompositionCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M2") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(PhysxConvexDecompositionCfg(max_convex_hulls=8, shrink_wrap=True), "/World/M2", stage) + assert prim.GetAttribute("physxConvexDecompositionCollision:maxConvexHulls").Get() == 8 + assert prim.GetAttribute("physxConvexDecompositionCollision:shrinkWrap").Get() is True + + +def test_physx_triangle_mesh_fragment_writes_namespace(): + from isaaclab_physx.sim.schemas import PhysxTriangleMeshCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M3") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(PhysxTriangleMeshCfg(weld_tolerance=0.01), "/World/M3", stage) + assert abs(prim.GetAttribute("physxTriangleMeshCollision:weldTolerance").Get() - 0.01) < 1e-6 + + +def test_physx_triangle_mesh_simplification_fragment_writes_namespace(): + from isaaclab_physx.sim.schemas import PhysxTriangleMeshSimplificationCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M4") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(PhysxTriangleMeshSimplificationCfg(simplification_metric=0.7), "/World/M4", stage) + ns = "physxTriangleMeshSimplificationCollision" + assert abs(prim.GetAttribute(f"{ns}:simplificationMetric").Get() - 0.7) < 1e-6 + + +def test_physx_sdf_mesh_fragment_writes_namespace(): + from isaaclab_physx.sim.schemas import PhysxSDFMeshCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M5") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(PhysxSDFMeshCfg(sdf_resolution=128, sdf_margin=0.02), "/World/M5", stage) + assert prim.GetAttribute("physxSDFMeshCollision:sdfResolution").Get() == 128 + assert abs(prim.GetAttribute("physxSDFMeshCollision:sdfMargin").Get() - 0.02) < 1e-6 + + +# ------------------------------------------------------------------------------------- +# Newton cooking fragments (isaaclab_newton): newton namespace + applied schema +# ------------------------------------------------------------------------------------- + + +def test_newton_mesh_collision_fragment_writes_namespace(): + from isaaclab_newton.sim.schemas import NewtonMeshCollisionCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M6") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(NewtonMeshCollisionCfg(max_hull_vertices=24), "/World/M6", stage) + assert prim.GetAttribute("newton:maxHullVertices").Get() == 24 + assert "NewtonMeshCollisionAPI" in prim.GetAppliedSchemas() + + +def test_newton_sdf_collision_fragment_writes_namespace(): + from isaaclab_newton.sim.schemas import NewtonSDFCollisionCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/M7") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_namespaced(NewtonSDFCollisionCfg(sdf_max_resolution=64, hydroelastic_enabled=True), "/World/M7", stage) + assert prim.GetAttribute("newton:sdfMaxResolution").Get() == 64 + assert prim.GetAttribute("newton:hydroelasticEnabled").Get() is True + # ``NewtonSDFCollisionAPI`` is authored into the ``apiSchemas`` listOp (like the legacy cfg) but + # is not a registered schema in this Newton build, so it is absent from the composed + # ``GetAppliedSchemas()``. Assert the authored token, matching the legacy Newton test. + assert _has_authored_api_schema(prim, "NewtonSDFCollisionAPI") + + +# ------------------------------------------------------------------------------------- +# Composition through apply_mesh_collision_properties: token coupling + multi-namespace +# ------------------------------------------------------------------------------------- + + +def test_apply_mesh_collision_properties_composes_namespaces(): + from isaaclab_newton.sim.schemas import NewtonMeshCollisionCfg + from isaaclab_physx.sim.schemas import PhysxConvexHullCfg + + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/M8") + apply_mesh_collision_properties( + "/World/M8", + [ + UsdPhysicsMeshCollisionCfg(), + PhysxConvexHullCfg(hull_vertex_limit=48), + NewtonMeshCollisionCfg(max_hull_vertices=48), + ], + stage, + ) + prim = stage.GetPrimAtPath("/World/M8") + assert bool(UsdPhysics.MeshCollisionAPI(prim)) # implicit anchor applied + # token coupling: the convex-hull cooking fragment sets ``physics:approximation`` + assert prim.GetAttribute("physics:approximation").Get() == "convexHull" + assert prim.GetAttribute("physxConvexHullCollision:hullVertexLimit").Get() == 48 + assert prim.GetAttribute("newton:maxHullVertices").Get() == 48 + + +def test_apply_mesh_collision_properties_rejects_invalid_token(): + import pytest + + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/M9") + with pytest.raises(ValueError): + apply_mesh_collision_properties( + "/World/M9", [UsdPhysicsMeshCollisionCfg(mesh_approximation_name="notAToken")], stage + ) + + +def test_apply_mesh_collision_properties_raises_on_invalid_prim(): + import pytest + + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + with pytest.raises(ValueError): + apply_mesh_collision_properties("/World/DoesNotExist", [UsdPhysicsMeshCollisionCfg()], stage) + + +def test_apply_mesh_collision_properties_aggregates_fragment_results(): + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Magg") + + # a fragment whose applier reports failure must make the aggregate return False + failing = UsdPhysicsMeshCollisionCfg() + failing.func = lambda cfg, prim_path, stage=None: False + assert apply_mesh_collision_properties("/World/Magg", [failing], stage) is False + + # all-succeeding fragments return True + ok = UsdPhysicsMeshCollisionCfg() + ok.func = lambda cfg, prim_path, stage=None: True + assert apply_mesh_collision_properties("/World/Magg", [ok], stage) is True + + +def test_apply_mesh_collision_properties_accepts_generator(): + # the writer dispatches fragments from any iterable; a one-shot generator is consumed once and + # each fragment authors both its namespace and its implied approximation token + from isaaclab_physx.sim.schemas import PhysxConvexHullCfg + + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Mgen") + frags = (f for f in [UsdPhysicsMeshCollisionCfg(), PhysxConvexHullCfg(hull_vertex_limit=48)]) + apply_mesh_collision_properties("/World/Mgen", frags, stage) + prim = stage.GetPrimAtPath("/World/Mgen") + # both passes ran: approximation token resolved AND the per-fragment namespaced attr written + assert prim.GetAttribute("physics:approximation").Get() == "convexHull" + assert prim.GetAttribute("physxConvexHullCollision:hullVertexLimit").Get() == 48 + + +# ------------------------------------------------------------------------------------- +# apply_mesh_collision: the per-fragment func carrying the approximation-token coupling +# ------------------------------------------------------------------------------------- + + +def test_apply_mesh_collision_writes_namespace_and_implied_token(): + # the per-fragment func (the default ``func`` of every MeshCollisionFragment) writes the + # fragment's namespaced cooking attrs AND the ``physics:approximation`` token it implies + from isaaclab_physx.sim.schemas import PhysxConvexHullCfg + + from isaaclab.sim.schemas import apply_mesh_collision + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/Mfunc") + UsdPhysics.MeshCollisionAPI.Apply(prim) + apply_mesh_collision(PhysxConvexHullCfg(hull_vertex_limit=16), "/World/Mfunc", stage) + assert prim.GetAttribute("physxConvexHullCollision:hullVertexLimit").Get() == 16 + assert prim.GetAttribute("physics:approximation").Get() == "convexHull" + + +def test_apply_mesh_collision_rejects_invalid_token(): + import pytest + + from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Mfunc2") + with pytest.raises(ValueError): + apply_mesh_collision(UsdPhysicsMeshCollisionCfg(mesh_approximation_name="notAToken"), "/World/Mfunc2", stage) + + +# ------------------------------------------------------------------------------------- +# Public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_newton.sim.schemas import NewtonMeshCollisionCfg, NewtonSDFCollisionCfg # noqa: F401 + from isaaclab_physx.sim.schemas import ( # noqa: F401 + PhysxConvexDecompositionCfg, + PhysxConvexHullCfg, + PhysxSDFMeshCfg, + PhysxTriangleMeshCfg, + PhysxTriangleMeshSimplificationCfg, + ) + + from isaaclab.sim.schemas import ( # noqa: F401 + MeshCollisionFragment, + SchemaFragment, + UsdPhysicsMeshCollisionCfg, + apply_mesh_collision, + apply_mesh_collision_properties, + apply_namespaced, + ) diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-meshcollision.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-meshcollision.minor.rst new file mode 100644 index 00000000000..4246cfd9a1f --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-meshcollision.minor.rst @@ -0,0 +1,9 @@ +Added +^^^^^ + +* Added the Newton mesh-collision cooking fragments: + :class:`~isaaclab_newton.sim.schemas.NewtonMeshCollisionCfg` (``newton:maxHullVertices`` via + ``NewtonMeshCollisionAPI``) and :class:`~isaaclab_newton.sim.schemas.NewtonSDFCollisionCfg` + (Newton SDF generation and hydroelastic-contact attributes via ``NewtonSDFCollisionAPI``). Each is + a single-namespace :class:`~isaaclab.sim.schemas.MeshCollisionFragment` dispatched via + :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi index 1a94b3d659a..19581ff221f 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -15,8 +15,10 @@ __all__ = [ "NewtonDeformableBodyPropertiesCfg", "NewtonJointDrivePropertiesCfg", "NewtonMaterialPropertiesCfg", + "NewtonMeshCollisionCfg", "NewtonMeshCollisionPropertiesCfg", "NewtonRigidBodyPropertiesCfg", + "NewtonSDFCollisionCfg", "NewtonSDFCollisionPropertiesCfg", ] @@ -34,7 +36,9 @@ from .schemas_cfg import ( NewtonDeformableBodyPropertiesCfg, NewtonJointDrivePropertiesCfg, NewtonMaterialPropertiesCfg, + NewtonMeshCollisionCfg, NewtonMeshCollisionPropertiesCfg, NewtonRigidBodyPropertiesCfg, + NewtonSDFCollisionCfg, NewtonSDFCollisionPropertiesCfg, ) 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 85f14b37bc6..a7bca3d90ce 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -16,6 +16,7 @@ FixedTendonFragment, JointDriveBaseCfg, MeshCollisionBaseCfg, + MeshCollisionFragment, RigidBodyBaseCfg, RigidBodyFragment, ) @@ -327,6 +328,118 @@ class NewtonSDFCollisionPropertiesCfg(NewtonCollisionPropertiesCfg): """ +# ------------------------------------------------------------------------------------- +# Mesh-collision cooking fragments (single-namespace; Newton cooking add-on schemas). +# +# Each fragment owns the ``newton`` namespace + its applied schema, dispatched via ``apply_namespaced``. +# They author no ``mesh_approximation_name`` (the token is set by the PhysX/USD fragment in the same +# list), so they only tune Newton-native cooking attributes. +# ------------------------------------------------------------------------------------- + + +@configclass +class NewtonMeshCollisionCfg(MeshCollisionFragment): + """``newton:maxHullVertices`` mesh-cooking attribute from ``NewtonMeshCollisionAPI``. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) carrying + Newton's convex-hull vertex limit. Dispatched alongside the USD/PhysX mesh-collision fragments + via :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonMeshCollisionAPI" + + 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 NewtonSDFCollisionCfg(MeshCollisionFragment): + """``newton:*`` SDF and hydroelastic mesh-cooking attributes from ``NewtonSDFCollisionAPI``. + + A single-namespace fragment carrying Newton SDF generation and hydroelastic-contact attributes + consumed by Newton's USD importer. Mirrors the legacy + :class:`NewtonSDFCollisionPropertiesCfg`. Dispatched alongside the USD/PhysX mesh-collision + fragments via :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. + + .. note:: + These ``newton:sdf*`` / ``newton:hydroelastic*`` attributes are read by Newton's USD + importer starting in Newton 1.3.0 (which also detects the unregistered + ``NewtonSDFCollisionAPI`` token via the raw ``apiSchemas`` list-op). On older Newton builds + they are authored but inert. + + .. note:: + If the values are None, they are not modified. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + # ``NewtonSDFCollisionAPI`` is authored into the prim's ``apiSchemas`` listOp (matching the + # legacy ``NewtonSDFCollisionPropertiesCfg``). It is not a *registered* applied API schema in + # the current Newton build, so it does not appear in the composed ``GetAppliedSchemas()`` until + # the schema ships -- but it is authored, and Newton's importer reads the ``newton:*`` attrs. + _usd_applied_schema: ClassVar[str | None] = "NewtonSDFCollisionAPI" + + sdf_max_resolution: int | None = None + """Maximum SDF grid dimension [dimensionless]. + + Newton requires this value to be divisible by 8. If :attr:`sdf_target_voxel_size` is also + authored, Newton uses the target voxel size and ignores this resolution. + Written to ``newton:sdfMaxResolution`` via ``NewtonSDFCollisionAPI``. + """ + + sdf_narrow_band_inner: float | None = None + """Inner narrow-band distance for SDF generation [m]. + + Written to ``newton:sdfNarrowBandInner`` via ``NewtonSDFCollisionAPI``. + """ + + sdf_narrow_band_outer: float | None = None + """Outer narrow-band distance for SDF generation [m]. + + Written to ``newton:sdfNarrowBandOuter`` via ``NewtonSDFCollisionAPI``. + """ + + sdf_target_voxel_size: float | None = None + """Target SDF voxel size [m]. + + Takes precedence over :attr:`sdf_max_resolution` in Newton's USD importer. + Written to ``newton:sdfTargetVoxelSize`` via ``NewtonSDFCollisionAPI``. + """ + + sdf_texture_format: Literal["uint8", "uint16", "float32"] | None = None + """Subgrid texture storage format for generated SDFs. + + Written to ``newton:sdfTextureFormat`` via ``NewtonSDFCollisionAPI``. + """ + + sdf_padding: float | None = None + """SDF AABB padding [m]. + + Written to ``newton:sdfPadding`` via ``NewtonSDFCollisionAPI``. + """ + + hydroelastic_enabled: bool | None = None + """Whether Newton should use SDF-based hydroelastic contacts for this shape. + + Both participating collision shapes must enable hydroelastic contacts for Newton to use this + path. Written to ``newton:hydroelasticEnabled`` via ``NewtonSDFCollisionAPI``. + """ + + hydroelastic_stiffness: float | None = None + """Hydroelastic contact stiffness. + + Written to ``newton:hydroelasticStiffness`` via ``NewtonSDFCollisionAPI``. + """ + + @configclass class NewtonMaterialPropertiesCfg(RigidBodyMaterialBaseCfg): """Newton-specific rigid body material properties. diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-meshcollision.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-meshcollision.minor.rst new file mode 100644 index 00000000000..8ced9b2e5d2 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-meshcollision.minor.rst @@ -0,0 +1,11 @@ +Added +^^^^^ + +* Added the PhysX mesh-collision cooking fragments: + :class:`~isaaclab_physx.sim.schemas.PhysxConvexHullCfg`, + :class:`~isaaclab_physx.sim.schemas.PhysxConvexDecompositionCfg`, + :class:`~isaaclab_physx.sim.schemas.PhysxTriangleMeshCfg`, + :class:`~isaaclab_physx.sim.schemas.PhysxTriangleMeshSimplificationCfg`, and + :class:`~isaaclab_physx.sim.schemas.PhysxSDFMeshCfg`. Each is a single-namespace + :class:`~isaaclab.sim.schemas.MeshCollisionFragment` owning one ``physx*Collision:*`` namespace and + applied schema, dispatched via :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 9541067c83a..124abf09ada 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -20,7 +20,9 @@ __all__ = [ "PhysxArticulationRootPropertiesCfg", "PhysxCollisionCfg", "PhysxCollisionPropertiesCfg", + "PhysxConvexDecompositionCfg", "PhysxConvexDecompositionPropertiesCfg", + "PhysxConvexHullCfg", "PhysxConvexHullPropertiesCfg", "PhysxDeformableBodyPropertiesCfg", "PhysxDeformableCollisionPropertiesCfg", @@ -29,10 +31,13 @@ __all__ = [ "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyCfg", "PhysxRigidBodyPropertiesCfg", + "PhysxSDFMeshCfg", "PhysxSDFMeshPropertiesCfg", "PhysxSpatialTendonCfg", "PhysxSpatialTendonPropertiesCfg", + "PhysxTriangleMeshCfg", "PhysxTriangleMeshPropertiesCfg", + "PhysxTriangleMeshSimplificationCfg", "PhysxTriangleMeshSimplificationPropertiesCfg", "RigidBodyPropertiesCfg", "SDFMeshPropertiesCfg", @@ -60,7 +65,9 @@ from .schemas_cfg import ( PhysxArticulationRootPropertiesCfg, PhysxCollisionCfg, PhysxCollisionPropertiesCfg, + PhysxConvexDecompositionCfg, PhysxConvexDecompositionPropertiesCfg, + PhysxConvexHullCfg, PhysxConvexHullPropertiesCfg, PhysxDeformableBodyPropertiesCfg, PhysxDeformableCollisionPropertiesCfg, @@ -69,10 +76,13 @@ from .schemas_cfg import ( PhysxJointDrivePropertiesCfg, PhysxRigidBodyCfg, PhysxRigidBodyPropertiesCfg, + PhysxSDFMeshCfg, PhysxSDFMeshPropertiesCfg, PhysxSpatialTendonCfg, PhysxSpatialTendonPropertiesCfg, + PhysxTriangleMeshCfg, PhysxTriangleMeshPropertiesCfg, + PhysxTriangleMeshSimplificationCfg, PhysxTriangleMeshSimplificationPropertiesCfg, RigidBodyPropertiesCfg, SDFMeshPropertiesCfg, 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 45e0fda37d0..630fd37af2c 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -17,6 +17,7 @@ FixedTendonFragment, JointDriveBaseCfg, MeshCollisionBaseCfg, + MeshCollisionFragment, RigidBodyBaseCfg, RigidBodyFragment, SpatialTendonFragment, @@ -591,6 +592,171 @@ def __post_init__(self): super().__post_init__() +# ------------------------------------------------------------------------------------- +# Mesh-collision cooking fragments (single-namespace; PhysX cooking add-on schemas). +# +# Each fragment owns one ``physx*Collision:*`` namespace + applied schema; its +# ``mesh_approximation_name`` default encodes the ``physics:approximation`` token its cooking +# schema implies. The token is written by the family writer +# ``isaaclab.sim.schemas.apply_mesh_collision_properties``; tuning attrs go via ``apply_namespaced``. +# ------------------------------------------------------------------------------------- + + +@configclass +class PhysxConvexHullCfg(MeshCollisionFragment): + """``physxConvexHullCollision:*`` mesh-cooking attributes from `PhysxConvexHullCollisionAPI`_. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) for the PhysX + convex-hull cooking schema. The ``convexHull`` token is written to ``physics:approximation`` by + :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. + + .. _PhysxConvexHullCollisionAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_convex_hull_collision_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physxConvexHullCollision" + _usd_applied_schema: ClassVar[str | None] = "PhysxConvexHullCollisionAPI" + + 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 [dimensionless]. Defaults to 64.""" + + min_thickness: float | None = None + """Convex hull min thickness [m]. Range: [0, inf). Default value is 0.001.""" + + +@configclass +class PhysxConvexDecompositionCfg(MeshCollisionFragment): + """``physxConvexDecompositionCollision:*`` mesh-cooking attributes from `PhysxConvexDecompositionCollisionAPI`_. + + A single-namespace fragment for the PhysX convex-decomposition cooking schema. The + ``convexDecomposition`` token is written to ``physics:approximation`` by + :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. + + .. _PhysxConvexDecompositionCollisionAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_convex_decomposition_collision_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physxConvexDecompositionCollision" + _usd_applied_schema: ClassVar[str | None] = "PhysxConvexDecompositionCollisionAPI" + + 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 [dimensionless]. Defaults to 64.""" + + max_convex_hulls: int | None = None + """Maximum of convex hulls created during convex decomposition [dimensionless]. Default value is 32.""" + + min_thickness: float | None = None + """Convex hull min thickness [m]. Range: [0, inf). Default value is 0.001.""" + + voxel_resolution: int | None = None + """Voxel resolution used for convex decomposition [dimensionless]. Defaults to 500,000 voxels.""" + + error_percentage: float | None = None + """Convex decomposition error percentage parameter [%]. Defaults to 10 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 PhysxTriangleMeshCfg(MeshCollisionFragment): + """``physxTriangleMeshCollision:*`` mesh-cooking attributes from `PhysxTriangleMeshCollisionAPI`_. + + A single-namespace fragment for the PhysX triangle-mesh cooking schema (PhysX-only colliders). + + .. _PhysxTriangleMeshCollisionAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_triangle_mesh_collision_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physxTriangleMeshCollision" + _usd_applied_schema: ClassVar[str | None] = "PhysxTriangleMeshCollisionAPI" + + mesh_approximation_name: str = "none" + """Name of mesh collision approximation method. Default: "none" (uses triangle mesh).""" + + weld_tolerance: float | None = None + """Mesh weld tolerance controlling the distance at which vertices are welded [m]. + + Default ``-inf`` autocomputes the welding tolerance from the mesh size; ``0`` disables welding. + Range: [0, inf). + """ + + +@configclass +class PhysxTriangleMeshSimplificationCfg(MeshCollisionFragment): + """``physxTriangleMeshSimplificationCollision:*`` attributes from `PhysxTriangleMeshSimplificationCollisionAPI`_. + + A single-namespace fragment for the PhysX triangle-mesh-simplification cooking schema. The + ``meshSimplification`` token is written to ``physics:approximation`` by + :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. + + .. _PhysxTriangleMeshSimplificationCollisionAPI: 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_namespace: ClassVar[str | None] = "physxTriangleMeshSimplificationCollision" + _usd_applied_schema: ClassVar[str | None] = "PhysxTriangleMeshSimplificationCollisionAPI" + + mesh_approximation_name: str = "meshSimplification" + """Name of mesh collision approximation method. Default: "meshSimplification".""" + + simplification_metric: float | None = None + """Mesh simplification accuracy [dimensionless]. Defaults to 0.55.""" + + weld_tolerance: float | None = None + """Mesh weld tolerance controlling the distance at which vertices are welded [m]. + + Default ``-inf`` autocomputes the welding tolerance from the mesh size; ``0`` disables welding. + Range: [0, inf). + """ + + +@configclass +class PhysxSDFMeshCfg(MeshCollisionFragment): + """``physxSDFMeshCollision:*`` mesh-cooking attributes from `PhysxSDFMeshCollisionAPI`_. + + A single-namespace fragment for the PhysX signed-distance-field cooking schema (PhysX-only + colliders). The ``sdf`` token is written to ``physics:approximation`` by + :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. + + .. _PhysxSDFMeshCollisionAPI: 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 + """ + + _usd_namespace: ClassVar[str | None] = "physxSDFMeshCollision" + _usd_applied_schema: ClassVar[str | None] = "PhysxSDFMeshCollisionAPI" + + 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 mesh bounding-box diagonal [dimensionless]. + + Scale-independent (fraction of the bounding-box diagonal). Default value is 0.01. Range: [0, inf). + """ + + sdf_narrow_band_thickness: float | None = None + """Size of the narrow band around the mesh surface with high-resolution SDF samples [dimensionless]. + + Scale-independent (fraction of the bounding-box diagonal). Default value is 0.01. Range: [0, 1]. + """ + + sdf_resolution: int | None = None + """Uniform SDF sampling resolution (largest AABB extent divided by this value) [dimensionless]. + + Default value is 256. Range: (1, inf). + """ + + sdf_subgrid_resolution: int | None = None + """Subgrid resolution enabling SDF sparsity; ``0`` selects a dense SDF [dimensionless]. + + Default value is 6. Range: [0, inf). + """ + + @configclass class PhysxConvexHullPropertiesCfg(MeshCollisionBaseCfg): """PhysX convex-hull cooking properties for a mesh collider. From 5137c592b5f150afacec93b9fbf9d11e080e16a3 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Sat, 27 Jun 2026 14:34:52 -0700 Subject: [PATCH 041/139] Select fixed-root-joint creator by active physics-cfg type The fixed-root-joint registry stored (is_active, creator) pairs and resolved by calling every backend's is_active() probe. That had two problems: a backend being imported (hence registered) is not the same as it being active, and each probe imported its backend's cfg, so resolving during a Newton run could execute PhysX import code whenever both extensions were present. Key the registry by the backend's physics-cfg class instead and resolve with a direct lookup on the active simulation's cfg.physics type. "Active" now comes solely from the live cfg, and resolution never imports or executes a non-active backend. --- .../isaaclab/sim/schemas/_backend_hooks.py | 54 ++++++++++--------- .../isaaclab/isaaclab/sim/schemas/schemas.py | 11 ++-- .../test/sim/test_articulation_fragments.py | 26 +++++---- .../isaaclab_newton/sim/schemas/__init__.py | 22 +++----- .../isaaclab_physx/sim/schemas/__init__.py | 21 +++----- 5 files changed, 67 insertions(+), 67 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py index 17299bd81f7..55730d94b24 100644 --- a/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py +++ b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py @@ -16,37 +16,39 @@ from collections.abc import Callable -# Backend-registered creators that fix an articulation base by authoring a world<->root fixed joint. -# Creating it is backend-specific (PhysX requires relocating the articulation root to the parent prim -# to work around its parser; Newton reads the fixed joint directly), so each backend registers its own -# creator plus a predicate that reports when that backend is the active simulation. This keeps core -# free of backend knowledge while letting each backend run on its own. -_FIXED_ROOT_JOINT_CREATORS: list[tuple[Callable, Callable]] = [] +# Backend-registered creators that fix an articulation base by authoring a world<->root fixed joint, +# keyed by the backend's physics-cfg class (the type of ``SimulationContext.cfg.physics``). Creating it +# is backend-specific (PhysX relocates the articulation root to the parent prim to work around its +# parser; Newton reads the fixed joint directly), so each backend registers its own creator under its +# cfg type. The writer looks up the creator for the active simulation's ``cfg.physics`` type -- a plain +# dict lookup that never imports or executes a non-active backend, and "active" comes solely from the +# live ``cfg.physics`` rather than from which backends happen to be imported. +_FIXED_ROOT_JOINT_CREATORS: dict[type, Callable] = {} -def register_fixed_root_joint_creator(creator: Callable, is_active: Callable) -> None: - """Register a backend creator that fixes an articulation base via a world<->root fixed joint. +def register_fixed_root_joint_creator(physics_cfg: type, creator: Callable) -> None: + """Register the creator that fixes an articulation base for a given physics-backend cfg type. Called by :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` when ``fix_root_link`` - is True and no fixed joint yet exists. Multiple backends may register; the creator whose - ``is_active()`` predicate returns True for the running simulation is selected, so each physics - backend (e.g. ``isaaclab_physx``, ``isaaclab_newton``) provides and is matched to its own - implementation without core knowing any of them. + is True and no fixed joint yet exists. Each physics backend (e.g. ``isaaclab_physx``, + ``isaaclab_newton``) registers its own creator keyed by its physics-cfg class; the writer looks up + the creator for the active simulation's ``cfg.physics`` type, so core selects the right backend + without importing or probing any of them. Args: + physics_cfg: The backend's physics-cfg class (the type of ``SimulationContext.cfg.physics`` for + that backend), used as the lookup key. creator: A callable ``creator(articulation_prim, stage)`` that authors the fixed joint. - is_active: A callable ``is_active() -> bool`` returning True when ``creator``'s backend is the - active simulation backend. """ - _FIXED_ROOT_JOINT_CREATORS.append((is_active, creator)) - - -def _resolve_fixed_root_joint_creator(): - """Return the creator whose backend is active, or None if no registered backend matches.""" - for is_active, creator in _FIXED_ROOT_JOINT_CREATORS: - try: - if is_active(): - return creator - except Exception: # noqa: BLE001 -- a backend's probe must never break creator resolution - continue - return None + _FIXED_ROOT_JOINT_CREATORS[physics_cfg] = creator + + +def _resolve_fixed_root_joint_creator(physics_cfg: type | None) -> Callable | None: + """Return the creator registered for the active backend's physics-cfg type, or None. + + Args: + physics_cfg: The type of the active simulation's ``cfg.physics`` (or None if no simulation). + """ + if physics_cfg is None: + return None + return _FIXED_ROOT_JOINT_CREATORS.get(physics_cfg) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index c9419d91575..e39e3809560 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -356,10 +356,13 @@ def apply_articulation_root_properties( existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) elif fix_root_link: logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") - # Creating a world<->root fixed joint to fix the base is backend-specific. Delegate to the - # creator for the active backend so core carries no backend logic; see - # register_fixed_root_joint_creator. - creator = _resolve_fixed_root_joint_creator() + # Creating a world<->root fixed joint to fix the base is backend-specific. Look up the + # creator registered for the active simulation's physics-cfg type so core carries no backend + # logic and never touches a non-active backend; see register_fixed_root_joint_creator. + from isaaclab.sim import SimulationContext + + sim = SimulationContext.instance() + creator = _resolve_fixed_root_joint_creator(type(sim.cfg.physics) if sim is not None else None) if creator is None: raise RuntimeError( f"Cannot fix the articulation root '{root_path}': no fixed-root-joint creator is" diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 8275e69b872..d4d131165a3 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -256,7 +256,7 @@ def test_apply_articulation_root_properties_fix_root_link_without_backend_raises """ from isaaclab.sim.schemas import _backend_hooks, apply_articulation_root_properties - monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", []) + monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", {}) sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -269,20 +269,28 @@ def test_apply_articulation_root_properties_fix_root_link_without_backend_raises def test_register_fixed_root_joint_creator_selects_active_backend(monkeypatch): - """The writer selects the registered creator whose backend predicate reports active, and ignores - creators whose backend is inactive.""" + """The writer selects the creator registered for the active simulation's ``cfg.physics`` type and + ignores creators registered for other backend cfg types -- no probing of inactive backends.""" from isaaclab.sim.schemas import _backend_hooks, apply_articulation_root_properties - from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator - monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", []) + monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", {}) called = {} - # an inactive backend's creator must be skipped, the active one's used - register_fixed_root_joint_creator(lambda p, s: called.__setitem__("path", "INACTIVE"), lambda: False) - register_fixed_root_joint_creator(lambda p, s: called.__setitem__("path", p.GetPath().pathString), lambda: True) - sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) + active_cfg_type = type(SimulationContext.instance().cfg.physics) + + class _OtherBackendCfg: + """Stand-in for an inactive backend's physics-cfg type.""" + + # a creator keyed by an inactive backend's cfg type must be ignored; the active one's is used + _backend_hooks.register_fixed_root_joint_creator( + _OtherBackendCfg, lambda p, s: called.__setitem__("path", "INACTIVE") + ) + _backend_hooks.register_fixed_root_joint_creator( + active_cfg_type, lambda p, s: called.__setitem__("path", p.GetPath().pathString) + ) + stage = sim_utils.get_current_stage() root = _make_xform(stage, "/World/Robot4") UsdPhysics.RigidBodyAPI.Apply(root) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index 244b00e3336..195ced9ac38 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -8,6 +8,8 @@ from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export +from isaaclab_newton.physics import NewtonCfg + lazy_export() @@ -41,17 +43,9 @@ def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim) -def _newton_is_active() -> bool: - """Return whether the running simulation uses the Newton backend.""" - from isaaclab.sim import SimulationContext - - from isaaclab_newton.physics import NewtonCfg - - sim = SimulationContext.instance() - return sim is not None and isinstance(sim.cfg.physics, NewtonCfg) - - -# Register the Newton fixed-root-joint creator (matched to the active Newton backend) so fixing an -# articulation base works in a Newton-only run, without core carrying any backend logic. Registered on -# import of this package (which a caller does to construct Newton schema fragments). -register_fixed_root_joint_creator(_create_fixed_root_joint_newton, _newton_is_active) +# Register the Newton fixed-root-joint creator with the core articulation-root writer keyed by +# ``NewtonCfg``, so the writer selects it only when Newton is the active simulation backend +# (``cfg.physics`` is a ``NewtonCfg``). This makes fixing an articulation base work in a Newton-only run +# without core carrying any backend logic. Registered on import of this package (which a caller does to +# construct Newton schema fragments). +register_fixed_root_joint_creator(NewtonCfg, _create_fixed_root_joint_newton) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index 2e41f5f7112..2681f9f6a6d 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -8,6 +8,8 @@ from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export +from isaaclab_physx.physics import PhysxCfg + lazy_export() @@ -75,17 +77,8 @@ def _create_fixed_root_joint(articulation_prim, stage) -> None: articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) -def _physx_is_active() -> bool: - """Return whether the running simulation uses the PhysX backend.""" - from isaaclab.sim import SimulationContext - - from isaaclab_physx.physics import PhysxCfg - - sim = SimulationContext.instance() - return sim is not None and isinstance(sim.cfg.physics, PhysxCfg) - - -# Keep PhysX articulation-root logic out of core: register the creator (matched to the active PhysX -# backend) with the core articulation-root writer. Registered on import of this package (which a caller -# does to construct PhysX schema fragments), inverting the dependency so core carries no PhysX logic. -register_fixed_root_joint_creator(_create_fixed_root_joint, _physx_is_active) +# Keep PhysX articulation-root logic out of core: register the creator with the core articulation-root +# writer keyed by ``PhysxCfg``, so the writer selects it only when PhysX is the active simulation +# backend (``cfg.physics`` is a ``PhysxCfg``). Registered on import of this package (which a caller does +# to construct PhysX schema fragments), inverting the dependency so core carries no PhysX logic. +register_fixed_root_joint_creator(PhysxCfg, _create_fixed_root_joint) From 798954457ca88f75041d004f432e38bdbac483a2 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Sat, 27 Jun 2026 14:50:01 -0700 Subject: [PATCH 042/139] Author fixed root joint with backend-neutral USD helper The Newton fixed-root-joint creator authored the world<->root joint via omni.physx's createJoint, pulling a PhysX-flavored Kit dependency into the Newton backend. Add a backend-neutral create_fixed_root_joint helper in core (pure USD: world-anchored UsdPhysics.FixedJoint whose local frame is set to the prim's current world pose so the body is pinned in place) and use it from both backends. The Newton backend now depends on nothing PhysX-flavored. Add tests for the helper (world-anchored joint, world-pose pinning, unique naming), the active-backend resolution (creator selected by the live cfg.physics type, unregistered type and no-sim return None), and the Newton creator (authors the joint without the PhysX root relocation). --- source/isaaclab/isaaclab/sim/__init__.pyi | 2 + .../isaaclab/sim/schemas/__init__.pyi | 2 + .../isaaclab/isaaclab/sim/schemas/schemas.py | 54 ++++++++ .../test/sim/test_articulation_fragments.py | 122 +++++++++++++++++- .../isaaclab_newton/sim/schemas/__init__.py | 8 +- .../isaaclab_physx/sim/schemas/__init__.py | 4 +- 6 files changed, 185 insertions(+), 7 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 5afba7dd039..4ce3bbc2ef5 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -72,6 +72,7 @@ __all__ = [ "UsdPhysicsMeshCollisionCfg", "UsdPhysicsRigidBodyCfg", "apply_articulation_root_properties", + "create_fixed_root_joint", "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", @@ -260,6 +261,7 @@ from .schemas import ( UsdPhysicsRigidBodyCfg, activate_contact_sensors, apply_articulation_root_properties, + create_fixed_root_joint, apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index 64198a45b35..1b2f922bfd8 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -9,6 +9,7 @@ __all__ = [ "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", "apply_articulation_root_properties", + "create_fixed_root_joint", "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", @@ -72,6 +73,7 @@ from .schemas import ( USD_MESH_COLLISION_CFGS, activate_contact_sensors, apply_articulation_root_properties, + create_fixed_root_joint, apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index e39e3809560..61ec31d4e89 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -374,6 +374,60 @@ def apply_articulation_root_properties( return True +def create_fixed_root_joint(articulation_prim, stage: Usd.Stage | None = None): + """Author a world<->prim ``UsdPhysics.FixedJoint`` that pins a prim at its current world pose. + + Backend-neutral USD authoring with no Kit/``omni`` dependency: it mirrors the fixed, world-anchored + joint produced by Kit's ``omni.physx`` ``createJoint`` helper -- ``body0`` is left empty (the world + frame), ``body1`` targets the prim, and the joint's world-side local frame is set to the prim's + current world transform so the constraint pins the body where it is (otherwise it would be pinned to + the world origin). The joint prim is created under the first writable (non-instanced) ancestor. + + Args: + articulation_prim: The prim to fix to the world frame. + stage: The stage the prim lives on. Defaults to None, in which case the current stage is used. + + Returns: + The created ``UsdPhysics.FixedJoint`` prim. + """ + from pxr import Gf, UsdGeom + + if stage is None: + stage = get_current_stage() + + # host the joint under the first writable ancestor (an instanced/prototype prim is not writable) + base_prim = articulation_prim + while base_prim != stage.GetPseudoRoot(): + if base_prim.IsInPrototype() or base_prim.IsInstanceProxy() or base_prim.IsInstanceable(): + base_prim = base_prim.GetParent() + else: + break + base_path = str(base_prim.GetPrimPath()) + if base_path == "/": + base_path = "" + # pick a unique child name so repeated calls do not collide + name = "FixedJoint" + if stage.GetPrimAtPath(f"{base_path}/{name}").IsValid(): + index = 0 + while stage.GetPrimAtPath(f"{base_path}/{name}{index}").IsValid(): + index += 1 + name = f"{name}{index}" + joint = UsdPhysics.FixedJoint.Define(stage, f"{base_path}/{name}") + + # body0 empty -> world; body1 -> the prim, pinned at its current world pose + world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() + joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) + joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) + joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) + joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0)) + joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0)) + # disable joint breaking (matches Kit's createJoint defaults; float32 max) + max_float = 3.40282347e38 + joint.CreateBreakForceAttr().Set(max_float) + joint.CreateBreakTorqueAttr().Set(max_float) + return joint.GetPrim() + + def define_articulation_root_properties( prim_path: str, cfg: schemas_cfg.ArticulationRootBaseCfg, stage: Usd.Stage | None = None ): diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index d4d131165a3..227deb10e2c 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -14,7 +14,7 @@ import pytest -from pxr import UsdGeom, UsdPhysics +from pxr import Gf, UsdGeom, UsdPhysics import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext @@ -299,6 +299,125 @@ class _OtherBackendCfg: assert called["path"] == "/World/Robot4" +# ------------------------------------------------------------------------------------- +# create_fixed_root_joint -- backend-neutral, pure-USD world<->prim fixed joint +# ------------------------------------------------------------------------------------- + + +def test_create_fixed_root_joint_authors_world_anchored_joint(): + """create_fixed_root_joint authors a world<->prim ``UsdPhysics.FixedJoint``: ``body1`` targets the + prim, ``body0`` is left empty (the world frame), and the body1-side local frame is identity.""" + from isaaclab.sim.schemas import create_fixed_root_joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/Fix1") + joint_prim = create_fixed_root_joint(prim, stage) + + assert joint_prim.IsA(UsdPhysics.FixedJoint) + joint = UsdPhysics.FixedJoint(joint_prim) + assert list(joint.GetBody1Rel().GetTargets()) == [prim.GetPath()] + # body0 left empty -> the world frame + body0_rel = joint.GetBody0Rel() + assert not body0_rel or list(body0_rel.GetTargets()) == [] + # the body1-side local frame is identity + assert joint_prim.GetAttribute("physics:localPos1").Get() == Gf.Vec3f(0.0) + assert joint_prim.GetAttribute("physics:localRot1").Get() == Gf.Quatf(1.0) + + +def test_create_fixed_root_joint_pins_prim_at_current_world_pose(): + """The world-side local frame is set to the prim's current world transform, so the constraint pins + the body where it is rather than teleporting it to the world origin.""" + from isaaclab.sim.schemas import create_fixed_root_joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/Fix2") + UsdGeom.Xform(prim).AddTranslateOp().Set(Gf.Vec3d(1.0, 2.0, 3.0)) + + joint_prim = create_fixed_root_joint(prim, stage) + pos0 = joint_prim.GetAttribute("physics:localPos0").Get() + assert (pos0[0], pos0[1], pos0[2]) == pytest.approx((1.0, 2.0, 3.0)) + + +def test_create_fixed_root_joint_uses_unique_names(): + """Repeated calls author distinct joint prims (no path collision).""" + from isaaclab.sim.schemas import create_fixed_root_joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/Fix3") + first = create_fixed_root_joint(prim, stage) + second = create_fixed_root_joint(prim, stage) + assert first.GetPath() != second.GetPath() + assert first.IsA(UsdPhysics.FixedJoint) and second.IsA(UsdPhysics.FixedJoint) + + +# ------------------------------------------------------------------------------------- +# active-backend detection: creator is resolved by the live cfg.physics type +# ------------------------------------------------------------------------------------- + + +def test_resolve_fixed_root_joint_creator_matches_cfg_type(monkeypatch): + """_resolve_fixed_root_joint_creator returns the creator registered for the given physics-cfg type, + and None for an unregistered type or no active simulation -- a pure dict lookup, no probing.""" + from isaaclab.sim.schemas import _backend_hooks + + monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", {}) + + class _CfgA: + pass + + class _CfgB: + pass + + def _creator_a(prim, stage): + pass + + _backend_hooks.register_fixed_root_joint_creator(_CfgA, _creator_a) + + assert _backend_hooks._resolve_fixed_root_joint_creator(_CfgA) is _creator_a + assert _backend_hooks._resolve_fixed_root_joint_creator(_CfgB) is None + assert _backend_hooks._resolve_fixed_root_joint_creator(None) is None + + +def test_newton_creator_authors_joint_without_reparenting(): + """The Newton fixed-root-joint creator authors a ``UsdPhysics.FixedJoint`` but, unlike PhysX, leaves + the articulation root in place (Newton reads the fixed joint directly), and pulls in no PhysX deps.""" + from isaaclab_newton.sim.schemas import _create_fixed_root_joint_newton + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/NewtonRobot") + root = _make_xform(stage, "/World/NewtonRobot/base") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + + _create_fixed_root_joint_newton(root, stage) + + assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + # Newton does NOT relocate the articulation root to the parent (that is a PhysX-parser workaround) + assert root.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not stage.GetPrimAtPath("/World/NewtonRobot").HasAPI(UsdPhysics.ArticulationRootAPI) + + +def test_newton_creator_requires_rigid_body(): + """The Newton creator raises NotImplementedError on a non-rigid-body root (cannot anchor the joint).""" + from isaaclab_newton.sim.schemas import _create_fixed_root_joint_newton + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/NewtonNoRB") + UsdPhysics.ArticulationRootAPI.Apply(root) # articulation root but NOT a rigid body + with pytest.raises(NotImplementedError): + _create_fixed_root_joint_newton(root, stage) + + # ------------------------------------------------------------------------------------- # public imports # ------------------------------------------------------------------------------------- @@ -312,4 +431,5 @@ def test_public_imports(): ArticulationRootFragment, SchemaFragment, apply_articulation_root_properties, + create_fixed_root_joint, ) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index 195ced9ac38..80f96e5ea27 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -19,8 +19,8 @@ def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: Backend creator registered with the core articulation-root writer for the Newton backend. Newton's importer reads a ``UsdPhysics.FixedJoint`` directly as a fixed root joint (a jointless root would otherwise default to floating), so -- unlike PhysX -- no articulation-root relocation is needed. The - joint is authored with the Kit ``omni.physx`` utility (a USD-authoring helper present in any Isaac - Sim app regardless of the active solver), so nothing is hand-rolled. + joint is authored with the backend-neutral :func:`~isaaclab.sim.schemas.create_fixed_root_joint` + helper (pure USD, no Kit/``omni`` dependency), so the Newton backend pulls in nothing PhysX-flavored. Args: articulation_prim: The resolved articulation-root prim to fix to the world frame. @@ -32,7 +32,7 @@ def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: """ from pxr import UsdPhysics - from omni.physx.scripts import utils as physx_utils + from isaaclab.sim.schemas.schemas import create_fixed_root_joint if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): raise NotImplementedError( @@ -40,7 +40,7 @@ def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: " RigidBodyAPI applied. To create a fixed joint, we need to determine the first rigid body" " link in the articulation tree. However, this is not implemented yet." ) - physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim) + create_fixed_root_joint(articulation_prim, stage) # Register the Newton fixed-root-joint creator with the core articulation-root writer keyed by diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index 2681f9f6a6d..87dec8418cd 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -32,7 +32,7 @@ def _create_fixed_root_joint(articulation_prim, stage) -> None: """ from pxr import UsdPhysics - from omni.physx.scripts import utils as physx_utils + from isaaclab.sim.schemas.schemas import create_fixed_root_joint # note: we assume the root prim is a rigid body; there is no obvious way to get the first rigid # body link identified by the PhysX parser when it is not. @@ -44,7 +44,7 @@ def _create_fixed_root_joint(articulation_prim, stage) -> None: ) # create a fixed joint between the root link and the world frame - physx_utils.createJoint(stage=stage, joint_type="Fixed", from_prim=None, to_prim=articulation_prim) + create_fixed_root_joint(articulation_prim, stage) # Having a fixed joint on a rigid body is not treated as "fixed base articulation"; it is treated as # part of the maximal coordinate tree. Moving the articulation root to the parent solves this (a From aae491886f06fa4dab297cb2eff03c3356da1543 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Sat, 27 Jun 2026 15:04:08 -0700 Subject: [PATCH 043/139] Document why backend creators defer their imports The articulation-root creators import pxr and the core schema helper at call time rather than module top. Add a comment explaining this is deliberate: the backend schema package is imported eagerly when a config references a backend cfg name, so its import path must stay free of the USD/Omniverse runtime; the creator itself only runs while a simulation is live, when those imports are available. --- .../isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py | 4 ++++ source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index 80f96e5ea27..a15f8904bb5 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -30,6 +30,10 @@ def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: NotImplementedError: When the root prim is not a rigid body (the first rigid-body link cannot be determined to anchor the fixed joint). """ + # Imports are deferred to call time on purpose: this creator only runs while a simulation is live + # (USD available), whereas this package is imported eagerly when a config references a Newton cfg + # name. ``pxr`` and ``schemas.schemas`` (which imports ``pxr`` at its top) must stay off that eager + # path to keep config import free of the USD/Omniverse runtime. from pxr import UsdPhysics from isaaclab.sim.schemas.schemas import create_fixed_root_joint diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index 87dec8418cd..448533efd3e 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -30,6 +30,10 @@ def _create_fixed_root_joint(articulation_prim, stage) -> None: NotImplementedError: When the root prim is not a rigid body (the first rigid-body link cannot be determined to attach the fixed joint). """ + # Imports are deferred to call time on purpose: this creator only runs while a simulation is live + # (USD available), whereas this package is imported eagerly when a config references a PhysX cfg + # name. ``pxr`` and ``schemas.schemas`` (which imports ``pxr`` at its top) must stay off that eager + # path to keep config import free of the USD/Omniverse runtime. from pxr import UsdPhysics from isaaclab.sim.schemas.schemas import create_fixed_root_joint From 3ee9500e2a04f381217bdf7c1996e04f9ea6a2cf Mon Sep 17 00:00:00 2001 From: HuiDong Chen Date: Sun, 28 Jun 2026 08:06:24 +0800 Subject: [PATCH 044/139] Sort test files alphabetically to ensure consistent test ordering (#6274) # Description Sort test files alphabetically to ensure consistent test ordering, making it easier to observe and debug issues. ## Type of change - Test 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 - [ ] 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 --- tools/conftest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/conftest.py b/tools/conftest.py index 6052ca05b25..a34812ddcca 100644 --- a/tools/conftest.py +++ b/tools/conftest.py @@ -856,7 +856,10 @@ def _collect_test_files( test_files.append(full_path) - # Apply file-level sharding: sort deterministically, then select every Nth file. + # Sort test files deterministically to ensure consistent test ordering. + test_files.sort() + + # Apply file-level sharding: select every Nth file from the deterministic order. # Skip when include_files is set — in that case the test's own conftest handles # sharding at the test-item level (e.g. parametrized test cases). shard_index = os.environ.get("TEST_SHARD_INDEX", "") @@ -864,7 +867,6 @@ def _collect_test_files( if shard_index and shard_count and not include_files: shard_index = int(shard_index) shard_count = int(shard_count) - test_files.sort() test_files = [f for i, f in enumerate(test_files) if i % shard_count == shard_index] print(f"Shard {shard_index}/{shard_count}: selected {len(test_files)} test files") From 0c9b40ec0f242ff18874b6281b6b057354e6c4b0 Mon Sep 17 00:00:00 2001 From: vidurv-nvidia Date: Sun, 28 Jun 2026 00:22:21 -0500 Subject: [PATCH 045/139] Adding Joint Drive USD data classes and writers (#6273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Adds the **joint-drive** schema-fragment API. - `JointDriveFragment` marker + `UsdPhysicsDriveCfg` (core) and the PhysX joint fragment. `UsdPhysicsDriveCfg` targets the multi-instance `UsdPhysics.DriveAPI` (instance `"angular"` for revolute joints, `"linear"` for prismatic), so it overrides `func` with `apply_drive` rather than the generic `apply_namespaced`: `apply_drive` selects the instance, applies `DriveAPI` (presence-gated — the conditional anchor for this family), performs the radian-to-degree conversion for angular drives, and writes the typed `drive::physics:{type,maxForce,stiffness,damping}` attributes. - `apply_joint_drive_properties` family writer — dispatches each fragment's `func`, and skips tendon-child joints (a joint carrying `PhysxTendonAxisAPI` without `PhysxTendonAxisRootAPI`) so no fragment authors a drive on them, descending into children (mirrors the legacy behaviour). - The non-USD `ensure_drives_exist` flag is a **spawner-cfg field** (`UsdFileCfg.ensure_drives_exist`), passed to the writer as a keyword argument — fragments carry USD attributes only. This is additive; the legacy `joint_drive_props` path is untouched. - `max_effort` is renamed to `max_force` (snake↔camel identity with the `maxForce` USD attr) with a deprecation alias forwarded in `__post_init__`. - The spawner `joint_drive_props` slot now also accepts a fragment list (transition bridge routes legacy single cfgs to the existing `modify_` writer). Purely **additive** and self-contained: builds only on the schema-fragment base already in `develop`, existing call sites untouched, depends on no other open PR. Local `test_joint_drive_fragments.py`: 13/13 passing. ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots N/A — non-visual API 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 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: ooctipus --- .../vidurv-schema-frag-jointdrive.minor.rst | 22 + source/isaaclab/isaaclab/sim/__init__.pyi | 8 + .../isaaclab/sim/schemas/__init__.pyi | 8 + .../isaaclab/sim/schemas/_backend_hooks.py | 43 ++ .../isaaclab/isaaclab/sim/schemas/schemas.py | 221 ++++++++++ .../isaaclab/sim/schemas/schemas_cfg.py | 88 ++++ .../sim/spawners/from_files/from_files.py | 60 +-- .../sim/spawners/from_files/from_files_cfg.py | 22 +- .../test/sim/test_joint_drive_fragments.py | 406 ++++++++++++++++++ .../test/sim/test_schema_fragments.py | 16 + .../vidurv-schema-frag-jointdrive.minor.rst | 8 + .../isaaclab_newton/sim/schemas/__init__.pyi | 4 + .../isaaclab_newton/sim/schemas/schemas.py | 54 ++- .../sim/schemas/schemas_cfg.py | 30 ++ .../vidurv-schema-frag-jointdrive.minor.rst | 11 + .../isaaclab_physx/sim/schemas/__init__.py | 19 + .../isaaclab_physx/sim/schemas/__init__.pyi | 4 + .../isaaclab_physx/sim/schemas/schemas.py | 46 +- .../isaaclab_physx/sim/schemas/schemas_cfg.py | 52 +++ 19 files changed, 1089 insertions(+), 33 deletions(-) create mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-jointdrive.minor.rst create mode 100644 source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py create mode 100644 source/isaaclab/test/sim/test_joint_drive_fragments.py create mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-jointdrive.minor.rst create mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-jointdrive.minor.rst diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-jointdrive.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-jointdrive.minor.rst new file mode 100644 index 00000000000..03873c2b0ef --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-jointdrive.minor.rst @@ -0,0 +1,22 @@ +Added +^^^^^ + +* Added the joint-drive schema-fragment API: the + :class:`~isaaclab.sim.schemas.JointDriveFragment` marker and + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` (writing the typed multi-instance + ``UsdPhysics.DriveAPI`` attributes). The drive fragment overrides its ``func`` with + :func:`~isaaclab.sim.schemas.apply_drive`, which selects the angular/linear instance, performs + the radian-to-degree conversion for angular drives, and skips tendon child prims. +* Added :func:`~isaaclab.sim.schemas.apply_joint_drive_properties` (applies a list of joint-drive + fragments to all joint prims under a path; ``UsdPhysics.DriveAPI`` is presence-gated and applied + only when a :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment is present). + +Changed +^^^^^^^ + +* Changed the spawner ``joint_drive_props`` slot + (:attr:`~isaaclab.sim.spawners.FileCfg.joint_drive_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.JointDriveFragment` fragments. Legacy single cfgs continue to work + through a transition bridge at the from-files spawn site. Added the spawner-level + ``ensure_drives_exist`` flag to reproduce the legacy minimal-stiffness behaviour for the fragment + path. diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index eadfbca6d34..d8d4e1beda2 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -49,6 +49,7 @@ __all__ = [ "JointDriveBaseCfg", "MassCfg", "MassFragment", + "JointDriveFragment", "MassPropertiesCfg", "MeshCollisionPropertiesCfg", "MujocoJointDrivePropertiesCfg", @@ -73,6 +74,9 @@ __all__ = [ "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", + "UsdPhysicsDriveCfg", + "apply_drive", + "apply_joint_drive_properties", "apply_mesh_collision", "apply_mesh_collision_properties", "apply_namespaced", @@ -239,6 +243,7 @@ from .schemas import ( JointDriveBaseCfg, MassCfg, MassFragment, + JointDriveFragment, MassPropertiesCfg, MeshCollisionFragment, MeshCollisionPropertiesCfg, @@ -259,6 +264,9 @@ from .schemas import ( apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, + UsdPhysicsDriveCfg, + apply_drive, + apply_joint_drive_properties, apply_mesh_collision, apply_mesh_collision_properties, apply_namespaced, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index f4e9f7cff59..b4f86cefcfc 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -11,6 +11,8 @@ __all__ = [ "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", + "apply_drive", + "apply_joint_drive_properties", "apply_mesh_collision", "apply_mesh_collision_properties", "apply_namespaced", @@ -43,6 +45,7 @@ __all__ = [ "JointDriveBaseCfg", "MassCfg", "MassFragment", + "JointDriveFragment", "MassPropertiesCfg", "MeshCollisionBaseCfg", "MeshCollisionFragment", @@ -50,6 +53,7 @@ __all__ = [ "SchemaFragment", "SpatialTendonFragment", "UsdPhysicsCollisionCfg", + "UsdPhysicsDriveCfg", "UsdPhysicsMeshCollisionCfg", "UsdPhysicsRigidBodyCfg", "MujocoJointDrivePropertiesCfg", @@ -72,6 +76,8 @@ from .schemas import ( apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, + apply_drive, + apply_joint_drive_properties, apply_mesh_collision, apply_mesh_collision_properties, apply_namespaced, @@ -108,6 +114,7 @@ from .schemas_cfg import ( JointDriveBaseCfg, MassCfg, MassFragment, + JointDriveFragment, MassPropertiesCfg, MeshCollisionBaseCfg, MeshCollisionFragment, @@ -116,6 +123,7 @@ from .schemas_cfg import ( SchemaFragment, SpatialTendonFragment, UsdPhysicsCollisionCfg, + UsdPhysicsDriveCfg, UsdPhysicsMeshCollisionCfg, UsdPhysicsRigidBodyCfg, ) diff --git a/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py new file mode 100644 index 00000000000..3c25d668a3f --- /dev/null +++ b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py @@ -0,0 +1,43 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Backend registration hooks for schema writers. + +This module holds the inversion-of-control registries that let physics backends (e.g. +``isaaclab_physx``, ``isaaclab_newton``) inject backend-specific behaviour into the core schema +writers without core importing any backend. It is deliberately kept free of ``pxr``/``omni`` imports +so a backend can register its hook at package-import time without eagerly pulling USD libraries into +an otherwise USD-free import path. +""" + +from __future__ import annotations + +from collections.abc import Callable + +# Backend-registered predicates that exclude a joint prim from joint-drive authoring. Backends (e.g. +# PhysX tendons) register here via :func:`register_joint_drive_skip_predicate` so the core joint-drive +# writers can skip backend-controlled joints without core carrying any backend-specific schema name. +_JOINT_DRIVE_SKIP_PREDICATES: list[Callable] = [] + + +def register_joint_drive_skip_predicate(predicate: Callable) -> None: + """Register a predicate that excludes a joint prim from joint-drive authoring. + + The joint-drive writers (:func:`~isaaclab.sim.schemas.apply_drive`, + :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`) skip any joint for which a registered + predicate returns ``True``. This is the backend hook for cases like PhysX fixed tendons, where the + controlling backend owns certain joints and no drive should be authored on them -- the backend + registers its own detector so core needs no backend-specific knowledge. + + Args: + predicate: A callable ``predicate(prim) -> bool`` returning True to exclude the prim. + """ + if predicate not in _JOINT_DRIVE_SKIP_PREDICATES: + _JOINT_DRIVE_SKIP_PREDICATES.append(predicate) + + +def _skip_joint_drive(prim) -> bool: + """Return whether any backend-registered predicate excludes ``prim`` from joint-drive authoring.""" + return any(predicate(prim) for predicate in _JOINT_DRIVE_SKIP_PREDICATES) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index ddae4582c7d..bdcc073d35c 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -28,6 +28,7 @@ safe_set_attribute_on_usd_schema, ) from . import schemas_cfg +from ._backend_hooks import _skip_joint_drive # import logger logger = logging.getLogger(__name__) @@ -985,6 +986,226 @@ def activate_contact_sensors(prim_path: str, threshold: float = 0.0, stage: Usd. """ +def _drive_instance_name(prim) -> str | None: + """Return the ``UsdPhysics.DriveAPI`` instance for a joint prim, or ``None`` if it has no drive. + + Revolute joints use the ``"angular"`` instance, prismatic joints the ``"linear"`` instance; any + other prim type has no joint drive. Shared by :func:`apply_drive` and :func:`_ensure_drive_exists`. + + Args: + prim: The candidate joint prim. + + Returns: + ``"angular"``, ``"linear"``, or ``None`` when the prim is not a revolute/prismatic joint. + """ + if prim.IsA(UsdPhysics.RevoluteJoint): + return "angular" + if prim.IsA(UsdPhysics.PrismaticJoint): + return "linear" + return None + + +def apply_drive(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Apply a :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment to a single joint prim. + + This is the override ``func`` for the ``UsdPhysics.DriveAPI`` fragment: the drive attributes + live under a multi-instance schema, so the generic :func:`apply_namespaced` writer cannot be + used. The writer reproduces the solver-common drive logic of + :func:`modify_joint_drive_properties`: + + * Selects the drive instance: ``"angular"`` for a revolute joint, ``"linear"`` for a prismatic + joint. For any other prim type, the function is a no-op and returns ``False``. + * Skips joints excluded by a backend-registered predicate (see + :func:`register_joint_drive_skip_predicate`, e.g. PhysX tendon members), returning ``False``. + * Applies ``UsdPhysics.DriveAPI`` for the selected instance (presence-gated -- only applied when + this fragment is present). + * Converts angular-drive :attr:`stiffness` and :attr:`damping` from radians to degrees + (``N·m/rad`` -> ``N·m/deg`` and ``N·m·s/rad`` -> ``N·m·s/deg``); linear drives are written + as-is. + * Writes the typed ``drive::physics:{type,maxForce,stiffness,damping}`` attributes, + mapping the :attr:`drive_type` field to the USD attribute named ``type``. + + Args: + cfg: The :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment to apply. + prim_path: The joint prim path to author on. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + + Returns: + True if the drive was applied to a joint prim, False if the prim is not a revolute or + prismatic joint (or is a tendon child). + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + + # select the drive instance based on the joint type + drive_api_name = _drive_instance_name(prim) + if drive_api_name is None: + return False + # skip joints a backend owns (e.g. PhysX tendon members); see register_joint_drive_skip_predicate + if _skip_joint_drive(prim): + return False + + # apply the multi-instance drive API (presence-gated anchor for the joint-drive family) + usd_drive_api = UsdPhysics.DriveAPI(prim, drive_api_name) + if not usd_drive_api: + usd_drive_api = UsdPhysics.DriveAPI.Apply(prim, drive_api_name) + + # gather the solver-common drive fields + drive_type = cfg.drive_type + max_force = cfg.max_force + stiffness = cfg.stiffness + damping = cfg.damping + + # angular drives use degree units in USD; convert stiffness/damping from radian units + if drive_api_name == "angular": + if stiffness is not None: + # N-m/rad --> N-m/deg + stiffness = stiffness * math.pi / 180.0 + if damping is not None: + # N-m-s/rad --> N-m-s/deg + damping = damping * math.pi / 180.0 + + # ``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. + for field_name, value in ( + ("drive_type", drive_type), + ("max_force", max_force), + ("stiffness", stiffness), + ("damping", damping), + ): + if value is None: + continue + usd_attr_name = "type" if field_name == "drive_type" else field_name + safe_set_attribute_on_usd_schema(usd_drive_api, usd_attr_name, value, camel_case=True) + + return True + + +def apply_joint_drive_properties( + prim_path: str, fragments, stage: Usd.Stage | None = None, ensure_drives_exist: bool = False +) -> bool: + """Apply a list of joint-drive fragments to all joint prims under a prim path. + + Mirrors the recursion behaviour of :func:`modify_joint_drive_properties` (decorated with + :func:`~isaaclab.sim.utils.apply_nested`): the prim path and all its descendants are visited, + and the fragments are dispatched to every revolute/prismatic joint prim found. As soon as a + prim is successfully handled, its children are not descended into (nested joints are not + allowed). + + Unlike :func:`apply_rigid_body_properties`, the joint-drive family has no implicit anchor: + ``UsdPhysics.DriveAPI`` is *presence-gated* and applied only by :func:`apply_drive` when a + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment is present in ``fragments``. Each + fragment is dispatched via its :attr:`~isaaclab.sim.schemas.SchemaFragment.func`, so backend + fragments carry backend-specific funcs and core never imports a backend. + + Args: + prim_path: The root prim path to search for joint prims under. + fragments: An iterable of :class:`~isaaclab.sim.schemas.JointDriveFragment` instances. + stage: The stage where to find the prim. Defaults to None, in which case the current + stage is used. + ensure_drives_exist: If True, write a minimal stiffness (``1e-3``) to any drive whose + authored stiffness *and* damping are both zero, so that backends (e.g. Newton) treat + the drive as active. Reproduces the legacy + :attr:`~isaaclab.sim.schemas.JointDriveBaseCfg.ensure_drives_exist` behaviour. This is + a spawner-level flag, not a fragment field. + + Returns: + True if the fragments were applied to at least one joint prim, False otherwise. + """ + if stage is None: + stage = get_current_stage() + + fragments = list(fragments) + # detect whether a UsdPhysics.DriveAPI fragment is present (governs presence-gating + the + # ensure_drives_exist behaviour, which only makes sense for the solver-common drive fragment) + drive_cfg = next((f for f in fragments if isinstance(f, schemas_cfg.UsdPhysicsDriveCfg)), None) + + root_prim = stage.GetPrimAtPath(prim_path) + if not root_prim.IsValid(): + raise ValueError(f"Prim path '{prim_path}' is not valid.") + + count_success = 0 + instanced_prim_paths = [] + all_prims = [root_prim] + while len(all_prims) > 0: + child_prim = all_prims.pop(0) + child_prim_path = child_prim.GetPath().pathString + # skip instanced prims (cannot author on prototypes) + if child_prim.IsInstance(): + instanced_prim_paths.append(child_prim_path) + continue + # a prim is a valid joint-drive target only if it is a revolute/prismatic joint + is_joint = child_prim.IsA(UsdPhysics.RevoluteJoint) or child_prim.IsA(UsdPhysics.PrismaticJoint) + if not is_joint: + all_prims += child_prim.GetChildren() + continue + # skip backend-owned joints wholesale (e.g. PhysX tendon members): no fragment may author on + # them. The backend registers the detector via register_joint_drive_skip_predicate, so this + # gate carries no backend-specific knowledge; descend into children to reach nested joints. + if _skip_joint_drive(child_prim): + all_prims += child_prim.GetChildren() + continue + # dispatch each fragment via its func + success = False + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + if func(cfg, child_prim_path, stage): + success = True + # seed a minimal stiffness only when the drive fragment authored neither stiffness nor + # damping and the resulting drive is fully passive + if ensure_drives_exist and drive_cfg is not None and success: + _ensure_drive_exists(drive_cfg, child_prim) + if success: + count_success += 1 + else: + all_prims += child_prim.GetChildren() + + if count_success == 0: + logger.warning( + f"Could not apply joint-drive properties on any prims under: '{prim_path}'." + " This might be because no revolute/prismatic joint prims were found, or they are" + f" instanced. Discovered list of instanced prim paths: {instanced_prim_paths}" + ) + return count_success > 0 + + +def _ensure_drive_exists(drive_cfg, prim) -> None: + """Seed a minimal stiffness on a fully-passive drive so backends treat it as active. + + Reproduces the legacy ``ensure_drives_exist`` behaviour: if the drive fragment authored + neither :attr:`stiffness` nor :attr:`damping` and the authored drive currently has zero + (or unset) stiffness *and* damping, write a minimal stiffness of ``1e-3`` directly to the + drive API (converted to degree units for angular drives, matching :func:`apply_drive`). The + fragment is not mutated, so this is safe across multiple joint prims sharing one fragment. + + Args: + drive_cfg: The :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment. + prim: The joint prim being authored. + """ + if drive_cfg.stiffness is not None or drive_cfg.damping is not None: + return + drive_api_name = _drive_instance_name(prim) + if drive_api_name is None: + return + usd_drive_api = UsdPhysics.DriveAPI(prim, drive_api_name) + if not usd_drive_api: + usd_drive_api = UsdPhysics.DriveAPI.Apply(prim, drive_api_name) + 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): + # mirror the legacy writer: 1e-3 is set before the rad->deg conversion, so an angular + # drive ends up with ``1e-3 * pi / 180``. + stiffness = 1e-3 + if drive_api_name == "angular": + stiffness = stiffness * math.pi / 180.0 + safe_set_attribute_on_usd_schema(usd_drive_api, "stiffness", stiffness, camel_case=True) + + @apply_nested def modify_joint_drive_properties( prim_path: str, cfg: schemas_cfg.JointDriveBaseCfg, stage: Usd.Stage | None = None diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index 138b4ebbe69..f6483832214 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -183,6 +183,13 @@ class CollisionFragment(SchemaFragment): pass +@configclass +class JointDriveFragment(SchemaFragment): + """Marker base for joint-drive fragments; types the ``joint_drive_props`` slot.""" + + pass + + @configclass class MeshCollisionFragment(SchemaFragment): """Marker base for mesh-collision fragments; types the ``mesh_collision_props`` slot. @@ -251,6 +258,87 @@ class UsdPhysicsCollisionCfg(CollisionFragment): """ +@configclass +class UsdPhysicsDriveCfg(JointDriveFragment): + """``drive::physics:*`` joint-drive attributes from `UsdPhysics.DriveAPI`_. + + The drive attributes live under a multi-instance ``UsdPhysics.DriveAPI`` (instance + ``"angular"`` for revolute joints, ``"linear"`` for prismatic joints), so this fragment + cannot use the generic :func:`~isaaclab.sim.schemas.apply_namespaced` writer. It overrides + :attr:`func` with :func:`~isaaclab.sim.schemas.apply_drive`, which selects the instance, + applies ``UsdPhysics.DriveAPI`` (presence-gated, the conditional anchor for the joint-drive + family), performs the radian-to-degree conversion for angular drives, and writes the typed + ``drive::physics:{type,maxForce,stiffness,damping}`` attributes. + + .. note:: + Unlike most fragments, this one is not a metadata-driven write. ``DriveAPI`` is applied + only when this fragment is present in the slot. + + .. _UsdPhysics.DriveAPI: https://openusd.org/dev/api/class_usd_physics_drive_a_p_i.html + """ + + # No metadata-driven namespace: the typed multi-instance ``UsdPhysics.DriveAPI`` is written + # directly by ``apply_drive``. ``DriveAPI`` is presence-gated, not an implicit anchor. + _usd_namespace: ClassVar[str | None] = None + _usd_applied_schema: ClassVar[str | None] = None + + func: Callable | str = "isaaclab.sim.schemas:apply_drive" + + def __post_init__(self): + # Deprecation alias: ``max_effort`` -> ``max_force`` (the USD attr is ``maxForce``). + # Mirrors the legacy :class:`JointDriveBaseCfg` alias forwarding. + _deprecate_field_alias(self, "max_effort", "max_force") + + drive_type: Literal["force", "acceleration"] | None = None + """Joint drive type to apply. + + If the drive type is ``"force"``, then the joint is driven by a force. If the drive type is + ``"acceleration"``, then the joint is driven by an acceleration (usually used for kinematic + joints). Written to ``drive::physics:type`` (the USD attr is ``type``, a permanent + inline carve-out from the snake-to-camel convention). + """ + + max_force: float | None = None + """Maximum force/torque that can be applied to the joint [N for linear joints, N·m for angular joints]. + + Written to ``drive::physics:maxForce`` via :class:`UsdPhysics.DriveAPI`. + """ + + max_effort: float | None = None + """Deprecated alias for :attr:`max_force`. + + .. 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 4.0. + """ + + stiffness: float | None = None + """Stiffness of the joint drive. + + The unit depends on the joint model: + + * For linear joints, the unit is kg-m/s² (N/m). + * For angular joints, the unit is kg-m²/s²/rad (N·m/rad). + + Angular drives are converted from radians to degrees (``N·m/rad`` -> ``N·m/deg``) before + being written to ``drive:angular:physics:stiffness``. + """ + + damping: float | None = None + """Damping of the joint drive. + + The unit depends on the joint model: + + * For linear joints, the unit is kg-m/s (N·s/m). + * For angular joints, the unit is kg-m²/s/rad (N·m·s/rad). + + Angular drives are converted from radians to degrees (``N·m·s/rad`` -> ``N·m·s/deg``) before + being written to ``drive:angular:physics:damping``. + """ + + @configclass class UsdPhysicsMeshCollisionCfg(MeshCollisionFragment): """``physics:approximation`` mesh-collision token from `UsdPhysics.MeshCollisionAPI`_. 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 8486234b221..d9f789b666f 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -403,34 +403,44 @@ 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, - MujocoRigidBodyCfg, - MujocoRigidBodyPropertiesCfg, + # transition shim, remove later: a fragment list -> apply_joint_drive_properties (the + # MujocoJointCfg fragment handles its own body-gravcomp coupling in apply_mujoco_joint, so + # the fragment path adds no backend coupling here); a legacy single cfg -> the pre-existing + # gravcomp auto-enable + modify_joint_drive_properties below. + joint_frags = ( + cfg.joint_drive_props if isinstance(cfg.joint_drive_props, (list, tuple)) else [cfg.joint_drive_props] ) + if joint_frags and all(isinstance(f, schemas.SchemaFragment) for f in joint_frags): + schemas.apply_joint_drive_properties(prim_path, joint_frags, ensure_drives_exist=cfg.ensure_drives_exist) + else: + # 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, + MujocoRigidBodyCfg, + MujocoRigidBodyPropertiesCfg, + ) - # gravcomp may be authored either via the legacy MujocoRigidBodyPropertiesCfg or via a - # MujocoRigidBodyCfg fragment in a rigid_props list. Treat either as "already set". - rigid_props_list = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] - body_gravcomp_unset = not any( - isinstance(f, (MujocoRigidBodyPropertiesCfg, MujocoRigidBodyCfg)) and f.gravcomp is not None - for f in rigid_props_list - ) - 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)." + # gravcomp may be authored either via the legacy MujocoRigidBodyPropertiesCfg or via a + # MujocoRigidBodyCfg fragment in a rigid_props list. Treat either as "already set". + rigid_props_list = cfg.rigid_props if isinstance(cfg.rigid_props, (list, tuple)) else [cfg.rigid_props] + body_gravcomp_unset = not any( + isinstance(f, (MujocoRigidBodyPropertiesCfg, MujocoRigidBodyCfg)) and f.gravcomp is not None + for f in rigid_props_list ) - schemas.modify_rigid_body_properties(prim_path, MujocoRigidBodyPropertiesCfg(gravcomp=1.0)) - schemas.modify_joint_drive_properties(prim_path, cfg.joint_drive_props) + 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) if cfg.deformable_props is not None: 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 d002588cf82..ea2cfe9b6c9 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 @@ -55,9 +55,18 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): or one or more :class:`~isaaclab.sim.schemas.SpatialTendonFragment` instances. """ - joint_drive_props: schemas.JointDriveBaseCfg | None = None + joint_drive_props: ( + schemas.JointDriveBaseCfg | schemas.JointDriveFragment | list[schemas.JointDriveFragment] | None + ) = None """Properties to apply to a joint. + Accepts either a single legacy cfg (e.g. :class:`~isaaclab.sim.schemas.JointDriveBaseCfg`) or a + list of :class:`~isaaclab.sim.schemas.JointDriveFragment` fragments + (e.g. ``[UsdPhysicsDriveCfg(...), PhysxJointCfg(...)]``). When a fragment list is given, + ``UsdPhysics.DriveAPI`` is applied (presence-gated) only when a + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment is present, and each fragment writes + its own namespace. + .. note:: The joint drive properties set the USD attributes of all the joint drives in the asset. We recommend using this attribute sparingly and only when necessary. Instead, please use the @@ -65,6 +74,17 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): for specific joints in an articulation. """ + ensure_drives_exist: bool = False + """Whether to ensure every joint drive is active when authoring :attr:`joint_drive_props`. + + When True, any joint drive whose authored stiffness *and* damping are both zero is given a + minimal stiffness (``1e-3``) so that backends (e.g. Newton) create proper actuators for it. + This is a spawner-level behavior flag (not a USD attribute and not a fragment field). It is + only consumed when :attr:`joint_drive_props` is given as a fragment list; legacy + :class:`~isaaclab.sim.schemas.JointDriveBaseCfg` cfgs carry their own + ``ensure_drives_exist`` field. + """ + visual_material_path: str = "material" """Path to the visual material to use for the prim. Defaults to "material". diff --git a/source/isaaclab/test/sim/test_joint_drive_fragments.py b/source/isaaclab/test/sim/test_joint_drive_fragments.py new file mode 100644 index 00000000000..8e43f6f3d5f --- /dev/null +++ b/source/isaaclab/test/sim/test_joint_drive_fragments.py @@ -0,0 +1,406 @@ +# 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 math + +import pytest + +from pxr import UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + + +def _make_revolute_joint(stage, path="/World/Articulation/joint_0"): + UsdGeom.Xform.Define(stage, "/World/Articulation") + UsdGeom.Cube.Define(stage, "/World/Articulation/body0") + UsdGeom.Cube.Define(stage, "/World/Articulation/body1") + UsdPhysics.RevoluteJoint.Define(stage, path) + return stage.GetPrimAtPath(path) + + +def _make_prismatic_joint(stage, path="/World/Articulation/joint_p"): + UsdGeom.Xform.Define(stage, "/World/Articulation") + UsdGeom.Cube.Define(stage, "/World/Articulation/body0") + UsdGeom.Cube.Define(stage, "/World/Articulation/body1") + UsdPhysics.PrismaticJoint.Define(stage, path) + return stage.GetPrimAtPath(path) + + +# ------------------------------------------------------------------------------------- +# Fragment metadata +# ------------------------------------------------------------------------------------- + + +def test_drive_fragment_metadata_defaults(): + from isaaclab.sim.schemas import JointDriveFragment, SchemaFragment, UsdPhysicsDriveCfg + + cfg = UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1) + assert isinstance(cfg, JointDriveFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace is None # typed multi-instance DriveAPI, no namespace writes + assert type(cfg)._usd_applied_schema is None # DriveAPI applied by apply_drive (presence-gated) + assert cfg.func == "isaaclab.sim.schemas:apply_drive" + assert cfg.stiffness == 10.0 and cfg.damping == 0.1 + + +def test_drive_fragment_max_effort_alias(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg + + with pytest.warns(DeprecationWarning, match="max_effort"): + cfg = UsdPhysicsDriveCfg(max_effort=42.0) + assert cfg.max_force == 42.0 + assert cfg.max_effort is None + + +# ------------------------------------------------------------------------------------- +# apply_drive -- revolute (angular) rad->deg conversion +# ------------------------------------------------------------------------------------- + + +def test_apply_drive_revolute_converts_rad_to_deg(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_drive + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + assert apply_drive( + UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1), + prim.GetPath().pathString, + stage, + ) + assert bool(UsdPhysics.DriveAPI(prim, "angular")) + assert prim.GetAttribute("drive:angular:physics:type").Get() == "acceleration" + assert prim.GetAttribute("drive:angular:physics:maxForce").Get() == pytest.approx(80.0, rel=1e-6) + # angular stiffness/damping are converted from radian to degree units + assert prim.GetAttribute("drive:angular:physics:stiffness").Get() == pytest.approx(10.0 * math.pi / 180.0, rel=1e-6) + assert prim.GetAttribute("drive:angular:physics:damping").Get() == pytest.approx(0.1 * math.pi / 180.0, rel=1e-6) + + +# ------------------------------------------------------------------------------------- +# apply_drive -- prismatic (linear) no conversion +# ------------------------------------------------------------------------------------- + + +def test_apply_drive_prismatic_writes_linear_unchanged(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_drive + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_prismatic_joint(stage) + assert apply_drive( + UsdPhysicsDriveCfg(drive_type="force", max_force=42.0, stiffness=10.0, damping=0.1), + prim.GetPath().pathString, + stage, + ) + assert bool(UsdPhysics.DriveAPI(prim, "linear")) + assert prim.GetAttribute("drive:linear:physics:type").Get() == "force" + assert prim.GetAttribute("drive:linear:physics:maxForce").Get() == pytest.approx(42.0, rel=1e-6) + # linear drives are written as authored (no rad->deg conversion) + assert prim.GetAttribute("drive:linear:physics:stiffness").Get() == pytest.approx(10.0, rel=1e-6) + assert prim.GetAttribute("drive:linear:physics:damping").Get() == pytest.approx(0.1, rel=1e-6) + + +def test_apply_drive_returns_false_on_non_joint(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_drive + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/NotAJoint") + assert apply_drive(UsdPhysicsDriveCfg(stiffness=1.0), "/World/NotAJoint", stage) is False + + +# ------------------------------------------------------------------------------------- +# PhysxJointCfg (isaaclab_physx) -- physxJoint namespace via apply_namespaced +# ------------------------------------------------------------------------------------- + + +def test_physx_joint_fragment_converts_max_velocity_by_joint_type(): + from isaaclab_physx.sim.schemas import PhysxJointCfg, apply_physx_joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + # angular (revolute) joint: rad/s -> deg/s conversion + rev = _make_revolute_joint(stage) + apply_physx_joint(PhysxJointCfg(max_joint_velocity=10.0), rev.GetPath().pathString, stage) + assert rev.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(10.0 * 180.0 / math.pi, rel=1e-6) + # linear (prismatic) joint: written unchanged + prismatic = _make_prismatic_joint(stage) + apply_physx_joint(PhysxJointCfg(max_joint_velocity=10.0), prismatic.GetPath().pathString, stage) + assert prismatic.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(10.0, rel=1e-6) + + +def test_physx_joint_fragment_max_velocity_alias(): + from isaaclab_physx.sim.schemas import PhysxJointCfg + + with pytest.warns(DeprecationWarning, match="max_velocity"): + cfg = PhysxJointCfg(max_velocity=10.0) + assert cfg.max_joint_velocity == 10.0 + assert cfg.max_velocity is None + + +# ------------------------------------------------------------------------------------- +# MujocoJointCfg (isaaclab_newton) -- mjc namespace via apply_namespaced +# ------------------------------------------------------------------------------------- + + +def test_mujoco_joint_fragment_writes_mjc_namespace(): + from isaaclab_newton.sim.schemas import MujocoJointCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + apply_namespaced(MujocoJointCfg(actuatorgravcomp=True), prim.GetPath().pathString, stage) + assert prim.GetAttribute("mjc:actuatorgravcomp").Get() is True + + +def test_mujoco_joint_applier_does_not_write_actuatorgravcomp_when_none(): + # fragment-path equivalent of the legacy test_mujoco_actuatorgravcomp_not_written_when_none: + # an unset actuatorgravcomp must not *author* mjc:actuatorgravcomp through the joint applier. + # (Unlike the legacy path, the fragment applies its MjcJointAPI schema, so the attribute exists + # and resolves to the schema default ``False`` -- but it must not be authored to ``True``.) + from isaaclab_newton.sim.schemas import MujocoJointCfg, apply_mujoco_joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + apply_mujoco_joint(MujocoJointCfg(), prim.GetPath().pathString, stage) + assert not prim.GetAttribute("mjc:actuatorgravcomp").HasAuthoredValue() + + +def test_mujoco_joint_actuatorgravcomp_enables_child_body_gravcomp(): + # actuatorgravcomp is inert unless the actuated body has non-zero mjc:gravcomp, so the Mujoco + # joint applier flips gravcomp on the joint's child body (physics:body1) when it is unset + from isaaclab_newton.sim.schemas import MujocoJointCfg + + from isaaclab.sim.schemas import apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + UsdPhysics.RevoluteJoint(prim).CreateBody1Rel().SetTargets(["/World/Articulation/body1"]) + apply_joint_drive_properties("/World/Articulation", [MujocoJointCfg(actuatorgravcomp=True)], stage) + body = stage.GetPrimAtPath("/World/Articulation/body1") + assert body.GetAttribute("mjc:gravcomp").Get() == pytest.approx(1.0) + + +def test_mujoco_joint_without_actuatorgravcomp_leaves_body_gravcomp_untouched(): + # the gravcomp coupling must fire ONLY when actuatorgravcomp is requested; an unset flag leaves + # the child body's mjc:gravcomp unauthored + from isaaclab_newton.sim.schemas import MujocoJointCfg + + from isaaclab.sim.schemas import apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + UsdPhysics.RevoluteJoint(prim).CreateBody1Rel().SetTargets(["/World/Articulation/body1"]) + apply_joint_drive_properties("/World/Articulation", [MujocoJointCfg()], stage) + body = stage.GetPrimAtPath("/World/Articulation/body1") + assert body.GetAttribute("mjc:gravcomp").Get() is None + + +def test_mujoco_joint_actuatorgravcomp_enables_gravcomp_on_every_joint_body(): + # per-joint dispatch must enable gravcomp on each joint's own child body across the articulation + from isaaclab_newton.sim.schemas import MujocoJointCfg + + from isaaclab.sim.schemas import apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + UsdGeom.Xform.Define(stage, "/World/Articulation") + for link in ("link_a", "link_b"): + UsdGeom.Cube.Define(stage, f"/World/Articulation/{link}") + j0 = UsdPhysics.RevoluteJoint.Define(stage, "/World/Articulation/joint_0") + j0.CreateBody1Rel().SetTargets(["/World/Articulation/link_a"]) + j1 = UsdPhysics.PrismaticJoint.Define(stage, "/World/Articulation/joint_1") + j1.CreateBody1Rel().SetTargets(["/World/Articulation/link_b"]) + apply_joint_drive_properties("/World/Articulation", [MujocoJointCfg(actuatorgravcomp=True)], stage) + # both the revolute joint's body and the prismatic joint's body get gravcomp enabled + assert stage.GetPrimAtPath("/World/Articulation/link_a").GetAttribute("mjc:gravcomp").Get() == pytest.approx(1.0) + assert stage.GetPrimAtPath("/World/Articulation/link_b").GetAttribute("mjc:gravcomp").Get() == pytest.approx(1.0) + + +def test_mujoco_joint_actuatorgravcomp_preserves_authored_body_gravcomp(): + # an explicitly authored body gravcomp must not be clobbered by the actuatorgravcomp auto-enable + from isaaclab_newton.sim.schemas import MujocoJointCfg + + from isaaclab.sim.schemas import apply_joint_drive_properties + from isaaclab.sim.utils import safe_set_attribute_on_usd_prim + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + UsdPhysics.RevoluteJoint(prim).CreateBody1Rel().SetTargets(["/World/Articulation/body1"]) + body = stage.GetPrimAtPath("/World/Articulation/body1") + safe_set_attribute_on_usd_prim(body, "mjc:gravcomp", 0.5, camel_case=False) + apply_joint_drive_properties("/World/Articulation", [MujocoJointCfg(actuatorgravcomp=True)], stage) + assert body.GetAttribute("mjc:gravcomp").Get() == pytest.approx(0.5) + + +# ------------------------------------------------------------------------------------- +# apply_joint_drive_properties dispatch (presence-gated DriveAPI + multi-namespace) +# ------------------------------------------------------------------------------------- + + +def test_apply_joint_drive_properties_composes_namespaces(): + from isaaclab_newton.sim.schemas import MujocoJointCfg + from isaaclab_physx.sim.schemas import PhysxJointCfg + + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + apply_joint_drive_properties( + "/World/Articulation", + [ + UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1), + PhysxJointCfg(max_joint_velocity=5.0), + MujocoJointCfg(actuatorgravcomp=True), + ], + stage, + ) + assert bool(UsdPhysics.DriveAPI(prim, "angular")) # presence-gated anchor applied + assert prim.GetAttribute("drive:angular:physics:maxForce").Get() == pytest.approx(80.0, rel=1e-6) + assert prim.GetAttribute("drive:angular:physics:stiffness").Get() == pytest.approx(10.0 * math.pi / 180.0, rel=1e-6) + # revolute joint -> rad/s to deg/s conversion via apply_physx_joint + assert prim.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(5.0 * 180.0 / math.pi, rel=1e-6) + assert prim.GetAttribute("mjc:actuatorgravcomp").Get() is True + + +def test_apply_joint_drive_properties_without_drive_does_not_apply_drive_api(): + from isaaclab_physx.sim.schemas import PhysxJointCfg + + from isaaclab.sim.schemas import apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + apply_joint_drive_properties("/World/Articulation", [PhysxJointCfg(max_joint_velocity=5.0)], stage) + # DriveAPI is presence-gated: not applied when no UsdPhysicsDriveCfg fragment is present + assert not bool(UsdPhysics.DriveAPI(prim, "angular")) + # revolute joint -> rad/s to deg/s conversion via apply_physx_joint + assert prim.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(5.0 * 180.0 / math.pi, rel=1e-6) + + +def test_apply_joint_drive_properties_skips_tendon_child_joint(): + """A tendon-child joint (``PhysxTendonAxisAPI`` without the root API) must be skipped wholesale + by the dispatch loop: no fragment -- drive, physxJoint, or mjc -- may author on it, matching the + legacy :func:`modify_joint_drive_properties` writer (which skipped the whole prim).""" + from isaaclab_physx.sim.schemas import PhysxJointCfg + + from pxr import PhysxSchema + + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + joint = _make_revolute_joint(stage) + PhysxSchema.PhysxTendonAxisAPI.Apply(joint, "axis0") # tendon child: axis API, no root API + applied = str(joint.GetAppliedSchemas()) + assert "PhysxTendonAxisAPI" in applied and "PhysxTendonAxisRootAPI" not in applied + + apply_joint_drive_properties( + "/World/Articulation", + [ + UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1), + PhysxJointCfg(max_joint_velocity=5.0), + ], + stage, + ) + # neither the presence-gated DriveAPI nor the physxJoint fragment may author on a tendon child + assert not bool(UsdPhysics.DriveAPI(joint, "angular")) + assert not joint.GetAttribute("physxJoint:maxJointVelocity").HasAuthoredValue() + + +def test_apply_joint_drive_properties_skips_joint_via_registered_predicate(monkeypatch): + """Core delegates joint exclusion to backend-registered predicates: one returning True skips the + joint, with no backend-specific schema knowledge in core. + + monkeypatch clears the module-global predicate list (other tests register the PhysX detector + session-wide) and restores it afterwards, keeping this isolated. + """ + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, _backend_hooks, apply_joint_drive_properties + + monkeypatch.setattr(_backend_hooks, "_JOINT_DRIVE_SKIP_PREDICATES", []) + _backend_hooks.register_joint_drive_skip_predicate(lambda prim: True) # exclude every joint + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + joint = _make_revolute_joint(stage) + apply_joint_drive_properties("/World/Articulation", [UsdPhysicsDriveCfg(stiffness=10.0)], stage) + assert not bool(UsdPhysics.DriveAPI(joint, "angular")) + + +def test_apply_joint_drive_properties_authors_when_no_skip_predicate(monkeypatch): + """Empty predicate registry (the default) skips nothing -- the writer authors on the joint.""" + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, _backend_hooks, apply_joint_drive_properties + + monkeypatch.setattr(_backend_hooks, "_JOINT_DRIVE_SKIP_PREDICATES", []) + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + joint = _make_revolute_joint(stage) + apply_joint_drive_properties("/World/Articulation", [UsdPhysicsDriveCfg(stiffness=10.0)], stage) + assert bool(UsdPhysics.DriveAPI(joint, "angular")) + + +def test_apply_joint_drive_properties_ensure_drives_exist_seeds_stiffness(): + from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_joint_drive_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_revolute_joint(stage) + # neither stiffness nor damping authored -> ensure_drives_exist seeds a minimal stiffness + apply_joint_drive_properties( + "/World/Articulation", [UsdPhysicsDriveCfg(max_force=1.0)], stage, ensure_drives_exist=True + ) + assert bool(UsdPhysics.DriveAPI(prim, "angular")) + assert prim.GetAttribute("drive:angular:physics:stiffness").Get() == pytest.approx(1e-3 * math.pi / 180.0, rel=1e-6) + + +# ------------------------------------------------------------------------------------- +# public imports +# ------------------------------------------------------------------------------------- + + +def test_public_imports(): + from isaaclab_newton.sim.schemas import MujocoJointCfg # noqa: F401 + from isaaclab_physx.sim.schemas import PhysxJointCfg # noqa: F401 + + from isaaclab.sim.schemas import ( # noqa: F401 + JointDriveFragment, + SchemaFragment, + UsdPhysicsDriveCfg, + apply_drive, + apply_joint_drive_properties, + ) diff --git a/source/isaaclab/test/sim/test_schema_fragments.py b/source/isaaclab/test/sim/test_schema_fragments.py index b76239963da..809f4f968b7 100644 --- a/source/isaaclab/test/sim/test_schema_fragments.py +++ b/source/isaaclab/test/sim/test_schema_fragments.py @@ -100,6 +100,22 @@ def test_mujoco_rigid_body_fragment_writes_mjc_namespace(): assert abs(prim.GetAttribute("mjc:gravcomp").Get() - 1.0) < 1e-6 +def test_mujoco_rigid_body_fragment_does_not_write_gravcomp_when_none(): + # fragment-path equivalent of the legacy test_mujoco_gravcomp_not_written_when_none: + # an unset gravcomp must not author mjc:gravcomp + from isaaclab_newton.sim.schemas import MujocoRigidBodyCfg + + from isaaclab.sim.schemas import apply_namespaced + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/B3b") + UsdPhysics.RigidBodyAPI.Apply(prim) + apply_namespaced(MujocoRigidBodyCfg(), "/World/B3b", stage) + assert prim.GetAttribute("mjc:gravcomp").Get() is None + + # ------------------------------------------------------------------------------------- # apply_rigid_body_properties dispatch (implicit anchor + multi-namespace) # ------------------------------------------------------------------------------------- diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-jointdrive.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-jointdrive.minor.rst new file mode 100644 index 00000000000..52ff628dcbd --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-jointdrive.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added the :class:`~isaaclab_newton.sim.schemas.MujocoJointCfg` joint-drive fragment + (``mjc:*`` / ``MjcJointAPI``), carrying joint-level ``actuatorgravcomp``. Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` via + :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. The from-files spawn site continues + to auto-enable body-level gravcomp for the fragment path. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi index 19581ff221f..a40c3fa1df5 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.pyi @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "MujocoJointCfg", "apply_mujoco_fixed_tendon", "MujocoFixedTendonCfg", "MujocoJointDrivePropertiesCfg", @@ -20,13 +21,16 @@ __all__ = [ "NewtonRigidBodyPropertiesCfg", "NewtonSDFCollisionCfg", "NewtonSDFCollisionPropertiesCfg", + "apply_mujoco_joint", ] from .schemas import ( apply_mujoco_fixed_tendon, + apply_mujoco_joint, ) from .schemas_cfg import ( MujocoFixedTendonCfg, + MujocoJointCfg, MujocoJointDrivePropertiesCfg, MujocoRigidBodyCfg, MujocoRigidBodyPropertiesCfg, diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py index 46727e03937..a4e46b2e697 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas.py @@ -3,21 +3,27 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Newton-backend schema writers (custom appliers for irregular fragments).""" +"""Newton-specific schema-fragment appliers. + +Hosts the custom ``func`` overrides for Newton/MuJoCo fragments whose application needs backend +logic beyond the generic :func:`~isaaclab.sim.schemas.apply_namespaced` writer. Keeping these here +(rather than in the core spawner) means ``isaaclab`` never imports a backend. +""" from __future__ import annotations import dataclasses -from pxr import Usd +from pxr import Usd, UsdPhysics +from isaaclab.sim.schemas.schemas import apply_namespaced from isaaclab.sim.utils import safe_set_attribute_on_usd_prim from isaaclab.sim.utils.stage import get_current_stage from isaaclab.utils.string import to_camel_case -from .schemas_cfg import MujocoFixedTendonCfg +from .schemas_cfg import MujocoFixedTendonCfg, MujocoJointCfg -__all__ = ["apply_mujoco_fixed_tendon"] +__all__ = ["apply_mujoco_fixed_tendon", "apply_mujoco_joint"] def apply_mujoco_fixed_tendon(cfg: MujocoFixedTendonCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: @@ -57,3 +63,43 @@ def apply_mujoco_fixed_tendon(cfg: MujocoFixedTendonCfg, prim_path: str, stage: for attr_name, value in values.items(): safe_set_attribute_on_usd_prim(prim, f"mjc:{to_camel_case(attr_name, 'cC')}", value, camel_case=False) return found + + +def apply_mujoco_joint(cfg: MujocoJointCfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Apply a :class:`MujocoJointCfg` fragment, including its body-level gravcomp coupling. + + Custom ``func`` override for :class:`MujocoJointCfg`. Writes the joint's ``mjc:*`` attributes via + :func:`~isaaclab.sim.schemas.apply_namespaced`, then enforces the MuJoCo coupling that joint-level + ``actuatorgravcomp`` requires body-level ``gravcomp``: in MuJoCo ``actuatorgravcomp`` is a per-joint + flag (``jnt_actgravcomp``) that routes the gravity-compensation force of the joint's actuated body + through the actuator, and it is inert unless that body's ``mjc:gravcomp`` is non-zero. So when + :attr:`~MujocoJointCfg.actuatorgravcomp` is requested, this enables ``mjc:gravcomp = 1.0`` on the + joint's child body (its ``physics:body1`` target) when the body has not authored it. An explicitly + authored body gravcomp is preserved. Keeping this coupling in the Newton applier (not the core + spawner) keeps the core package free of any backend dependency. + + Args: + cfg: The :class:`MujocoJointCfg` fragment to apply. + prim_path: The joint prim path to author on. + stage: The stage where to find the prim. Defaults to the current stage. + + Returns: + True if the joint fragment was applied successfully. + """ + if stage is None: + stage = get_current_stage() + success = apply_namespaced(cfg, prim_path, stage) + # actuatorgravcomp is inert unless the actuated body has non-zero gravcomp; flip it on the joint's + # child body when requested and unset (per-joint dispatch covers every actuated body in the + # articulation; the non-actuated base has no parent joint and needs no compensation). + if cfg.actuatorgravcomp: + joint = UsdPhysics.Joint(stage.GetPrimAtPath(prim_path)) + targets = joint.GetBody1Rel().GetTargets() if joint else [] + for body_path in targets: + body = stage.GetPrimAtPath(body_path) + if not body.IsValid(): + continue + current = body.GetAttribute("mjc:gravcomp").Get() + if current is None or current == 0.0: + safe_set_attribute_on_usd_prim(body, "mjc:gravcomp", 1.0, camel_case=False) + return success 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 a7bca3d90ce..05d63921efd 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -15,6 +15,7 @@ DeformableBodyPropertiesBaseCfg, FixedTendonFragment, JointDriveBaseCfg, + JointDriveFragment, MeshCollisionBaseCfg, MeshCollisionFragment, RigidBodyBaseCfg, @@ -114,6 +115,35 @@ class MujocoRigidBodyCfg(RigidBodyFragment): """ +@configclass +class MujocoJointCfg(JointDriveFragment): + """``mjc:*`` joint attributes for Newton's MuJoCo solver from ``MjcJointAPI``. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) carrying + joint-level gravity compensation. Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` via + :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. It overrides :attr:`func` with + :func:`~isaaclab_newton.sim.schemas.apply_mujoco_joint`, which writes the ``mjc:*`` attributes + and enforces the body-level gravcomp coupling that joint-level ``actuatorgravcomp`` requires. + """ + + _usd_namespace: ClassVar[str | None] = "mjc" + _usd_applied_schema: ClassVar[str | None] = "MjcJointAPI" + + # Custom applier: writes the mjc:* joint attrs and, when ``actuatorgravcomp`` is requested, flips + # body-level ``mjc:gravcomp`` on the joint's child body (the coupling lives in the backend applier + # so the core spawner stays backend-free). See :func:`~isaaclab_newton.sim.schemas.apply_mujoco_joint`. + func: Callable | str = "isaaclab_newton.sim.schemas:apply_mujoco_joint" + + 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:`MujocoRigidBodyCfg.gravcomp`. Written to ``mjc:actuatorgravcomp`` + via ``MjcJointAPI``. + """ + + @configclass class NewtonJointDrivePropertiesCfg(JointDriveBaseCfg): """Newton-targeted joint drive properties. diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-jointdrive.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-jointdrive.minor.rst new file mode 100644 index 00000000000..93ed43935d8 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-jointdrive.minor.rst @@ -0,0 +1,11 @@ +Added +^^^^^ + +* Added the :class:`~isaaclab_physx.sim.schemas.PhysxJointCfg` joint-drive fragment + (``physxJoint:*`` / ``PhysxJointAPI``), carrying ``max_joint_velocity`` (with the legacy + ``max_velocity`` deprecation alias). Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` via + :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. +* Added :func:`~isaaclab_physx.sim.schemas.apply_physx_joint`, the dedicated applier for + :class:`~isaaclab_physx.sim.schemas.PhysxJointCfg` that converts ``max_joint_velocity`` from + rad/s to deg/s for angular (revolute) joints, matching the legacy joint-drive unit convention. diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index 47f0b1e8ae9..8892ccc6b59 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -5,6 +5,25 @@ """Sub-module containing PhysX schema configuration exports.""" +from isaaclab.sim.schemas._backend_hooks import register_joint_drive_skip_predicate from isaaclab.utils.module import lazy_export lazy_export() + + +def _is_physx_tendon_child(prim) -> bool: + """Return whether ``prim`` is a non-root PhysX fixed-tendon member. + + A tendon-child joint carries the multi-instance ``PhysxTendonAxisAPI`` without the + ``PhysxTendonAxisRootAPI`` root instance. Applied-schema *type* names are compared exactly (the + part before the ``:`` instance suffix), so this is robust to schema-name changes. A drive must + not be authored on such joints -- the tendon controls them. + """ + types = {schema.split(":", 1)[0] for schema in prim.GetAppliedSchemas()} + return "PhysxTendonAxisAPI" in types and "PhysxTendonAxisRootAPI" not in types + + +# Keep PhysX tendon knowledge out of core: register the detector with the core joint-drive writers so +# they skip tendon-controlled joints. Registered on import of this package (which a caller does to +# construct PhysX schema fragments), inverting the dependency so core carries no PhysX schema name. +register_joint_drive_skip_predicate(_is_physx_tendon_child) diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi index 124abf09ada..754d009af3a 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.pyi @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "apply_physx_joint", "apply_fixed_tendon", "apply_spatial_tendon", "define_deformable_body_properties", @@ -28,6 +29,7 @@ __all__ = [ "PhysxDeformableCollisionPropertiesCfg", "PhysxFixedTendonCfg", "PhysxFixedTendonPropertiesCfg", + "PhysxJointCfg", "PhysxJointDrivePropertiesCfg", "PhysxRigidBodyCfg", "PhysxRigidBodyPropertiesCfg", @@ -47,6 +49,7 @@ __all__ = [ ] from .schemas import ( + apply_physx_joint, apply_fixed_tendon, apply_spatial_tendon, define_deformable_body_properties, @@ -73,6 +76,7 @@ from .schemas_cfg import ( PhysxDeformableCollisionPropertiesCfg, PhysxFixedTendonCfg, PhysxFixedTendonPropertiesCfg, + PhysxJointCfg, PhysxJointDrivePropertiesCfg, PhysxRigidBodyCfg, PhysxRigidBodyPropertiesCfg, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py index 7678e9ca9d3..24af90b2e50 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas.py @@ -3,19 +3,20 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Compatibility wrappers for deformable schema writers and PhysX fragment appliers. +"""PhysX schema-fragment appliers and compatibility wrappers. The deformable schema writers are backend-aware but remain unified in :mod:`isaaclab.sim.schemas`. This module additionally hosts the PhysX-specific fragment applier funcs that override :attr:`~isaaclab.sim.schemas.SchemaFragment.func` for the -multi-instance tendon schemas, keeping the backend func out of the core package. +joint-drive and multi-instance tendon schemas, keeping the backend func out of the core package. """ from __future__ import annotations import dataclasses +import math -from pxr import Usd +from pxr import Usd, UsdPhysics from isaaclab.sim.schemas.schemas import ( define_deformable_body_properties, @@ -29,12 +30,51 @@ __all__ = [ "apply_fixed_tendon", + "apply_physx_joint", "apply_spatial_tendon", "define_deformable_body_properties", "modify_deformable_body_properties", ] +def apply_physx_joint(cfg, prim_path: str, stage: Usd.Stage | None = None) -> bool: + """Apply a :class:`~isaaclab_physx.sim.schemas.PhysxJointCfg` fragment to a joint prim. + + Like :func:`~isaaclab.sim.schemas.apply_namespaced`, this applies ``PhysxJointAPI`` and writes + each non-``None`` field under the ``physxJoint:`` namespace. It additionally reproduces the + legacy joint-drive unit convention: for angular (revolute) joints, ``max_joint_velocity`` is + converted from rad/s to deg/s, since PhysX stores angular joint velocity limits in degrees. + + Args: + cfg: The :class:`~isaaclab_physx.sim.schemas.PhysxJointCfg` fragment. + prim_path: The prim path of the joint. + 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. + """ + if stage is None: + stage = get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + namespace = type(cfg)._usd_namespace + applied = type(cfg)._usd_applied_schema + if applied and applied not in prim.GetAppliedSchemas(): + prim.AddAppliedSchema(applied) + # angular (revolute) joints store velocity limits in degrees; linear (prismatic) in meters. + is_angular = prim.IsA(UsdPhysics.RevoluteJoint) + for f in dataclasses.fields(cfg): + if f.name == "func": + continue + value = getattr(cfg, f.name) + if value is None: + continue + if f.name == "max_joint_velocity" and is_angular: + value = value * 180.0 / math.pi # rad/s -> deg/s + safe_set_attribute_on_usd_prim(prim, f"{namespace}:{to_camel_case(f.name, 'cC')}", value, camel_case=False) + return True + + def _strip_fragment_fields(cfg) -> dict: """Collect a fragment's non-``None`` data fields, excluding the ``func`` plumbing field. 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 630fd37af2c..75c25c83826 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -16,11 +16,13 @@ DeformableBodyPropertiesBaseCfg, FixedTendonFragment, JointDriveBaseCfg, + JointDriveFragment, MeshCollisionBaseCfg, MeshCollisionFragment, RigidBodyBaseCfg, RigidBodyFragment, SpatialTendonFragment, + _deprecate_field_alias, ) from isaaclab.utils.configclass import configclass @@ -348,6 +350,56 @@ def __post_init__(self): super().__post_init__() +@configclass +class PhysxJointCfg(JointDriveFragment): + """``physxJoint:*`` joint attributes from `PhysxJointAPI`_. + + A single-namespace fragment (see :class:`~isaaclab.sim.schemas.SchemaFragment`) for the + PhysX joint add-on schema. Applied alongside :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` + via :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. Written with the dedicated + :func:`~isaaclab_physx.sim.schemas.apply_physx_joint` writer, which converts + :attr:`max_joint_velocity` from rad/s to deg/s for angular (revolute) joints. + + .. _PhysxJointAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_joint_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physxJoint" + _usd_applied_schema: ClassVar[str | None] = "PhysxJointAPI" + # Override the generic applier: ``max_joint_velocity`` needs joint-type-aware rad->deg + # conversion for angular joints, which ``apply_namespaced`` cannot do. + func: Callable | str = "isaaclab_physx.sim.schemas:apply_physx_joint" + + def __post_init__(self): + # Deprecation alias: ``max_velocity`` -> ``max_joint_velocity`` (the USD attr is + # ``maxJointVelocity``). Mirrors the legacy :class:`JointDriveBaseCfg` alias forwarding. + _deprecate_field_alias(self, "max_velocity", "max_joint_velocity") + + max_joint_velocity: float | None = None + """Maximum velocity of the joint [m/s for linear joints, rad/s for angular joints]. + + 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. + + .. note:: + Authored in rad/s; :func:`~isaaclab_physx.sim.schemas.apply_physx_joint` converts it to + deg/s for angular (revolute) joints (PhysX's angular convention) and writes linear + (prismatic) joints unchanged, matching the legacy + :func:`~isaaclab.sim.schemas.modify_joint_drive_properties`. + """ + + max_velocity: float | None = None + """Deprecated alias for :attr:`max_joint_velocity`. + + .. 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 4.0. + """ + + @configclass class PhysxJointDrivePropertiesCfg(JointDriveBaseCfg): """PhysX-specific joint drive properties. From 003b3344ea6e8276eb1543acd6e4b3d10bb8af9c Mon Sep 17 00:00:00 2001 From: ooctipus Date: Sat, 27 Jun 2026 22:55:55 -0700 Subject: [PATCH 046/139] Expose Same-Step terminal observations (#6219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Preserve Isaac Lab existing Same-Step autoreset behavior without adding autoreset mode config. - Declare `metadata["autoreset_mode"] = gym.vector.AutoresetMode.SAME_STEP` on RL envs. - Capture terminal observations before the Same-Step reset and expose them through `extras["final_obs"]` for Direct RL, Manager-based RL, and Direct MARL envs. The captured observation has the same observation noise applied as the returned observation. - Gate the capture behind an opt-in `compute_final_obs` flag (default `False`) on `DirectRLEnvCfg`, `DirectMARLEnvCfg`, and `ManagerBasedRLEnvCfg`. With the default the previous behavior is preserved: nothing is captured, `extras["final_obs"]` is not populated, and the extra observation computation is skipped. - Update `Sb3VecEnvWrapper` to use `extras["final_obs"]` as SB3 `terminal_observation` when envs reset on the same step (falls back to the previous behavior when `final_obs` is absent). ## Related - Addresses the Same-Step autoreset semantics and terminal-observation exposure required by the migration to `gymnasium.vector.VectorEnv` in #3136 (which fixes #3095). This PR declares the autoreset mode in environment metadata and surfaces the pre-reset terminal observation via `extras["final_obs"]` — the groundwork a `VectorEnv`-based implementation needs to report `final_obs` / `final_info` correctly under Same-Step autoreset. ## Validation - `./isaaclab.sh -f` (pre-commit) on all touched files. - `py_compile` on the touched env and wrapper modules. - `git diff --check`. --- .../changelog.d/support-autoreset-modes.rst | 10 +++++ .../isaaclab/isaaclab/envs/direct_marl_env.py | 12 ++++++ .../isaaclab/envs/direct_marl_env_cfg.py | 14 +++++++ .../isaaclab/isaaclab/envs/direct_rl_env.py | 9 +++++ .../isaaclab/envs/direct_rl_env_cfg.py | 17 +++++++++ .../isaaclab/envs/manager_based_rl_env.py | 4 ++ .../isaaclab/envs/manager_based_rl_env_cfg.py | 16 ++++++++ .../support-autoreset-final-obs.rst | 5 +++ source/isaaclab_rl/isaaclab_rl/sb3.py | 38 +++++++++++++------ .../changelog.d/support-autoreset-modes.rst | 7 ++++ .../core/dexsuite/dexsuite_env_cfg.py | 4 +- 11 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 source/isaaclab/changelog.d/support-autoreset-modes.rst create mode 100644 source/isaaclab_rl/changelog.d/support-autoreset-final-obs.rst create mode 100644 source/isaaclab_tasks/changelog.d/support-autoreset-modes.rst diff --git a/source/isaaclab/changelog.d/support-autoreset-modes.rst b/source/isaaclab/changelog.d/support-autoreset-modes.rst new file mode 100644 index 00000000000..b706282a769 --- /dev/null +++ b/source/isaaclab/changelog.d/support-autoreset-modes.rst @@ -0,0 +1,10 @@ +Added +^^^^^ + +* Added an opt-in ``compute_final_obs`` flag (default ``False``) to + :class:`~isaaclab.envs.DirectRLEnvCfg`, :class:`~isaaclab.envs.DirectMARLEnvCfg`, and + :class:`~isaaclab.envs.ManagerBasedRLEnvCfg` that captures the terminal observation before a + Same-Step autoreset and exposes it through ``extras["final_obs"]``. The captured observation has + the same observation noise applied as the returned observation. When the flag is ``False`` the + previous behavior is preserved (no capture, no extra observation computation). +* Declared the Same-Step autoreset mode in RL environment metadata. diff --git a/source/isaaclab/isaaclab/envs/direct_marl_env.py b/source/isaaclab/isaaclab/envs/direct_marl_env.py index 9ebc983c6da..c7ef5934490 100644 --- a/source/isaaclab/isaaclab/envs/direct_marl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_marl_env.py @@ -67,6 +67,7 @@ class DirectMARLEnv(gym.Env): metadata: ClassVar[dict[str, Any]] = { "render_modes": [None, "human", "rgb_array"], + "autoreset_mode": gym.vector.AutoresetMode.SAME_STEP, } """Metadata for the environment.""" @@ -480,6 +481,17 @@ def step(self, actions: dict[AgentID, ActionType]) -> EnvStepReturn: # -- reset envs that terminated/timed-out and log the episode information reset_env_ids = self.reset_buf.nonzero(as_tuple=False).squeeze(-1) if len(reset_env_ids) > 0: + # capture the per-agent terminal observation before reset and expose it for Same-Step + # autoreset. apply the same observation noise as the returned obs so the bootstrapped + # terminal value matches the distribution the policy is trained on. + if self.cfg.compute_final_obs: + terminal_obs = self._get_observations() + if self.cfg.observation_noise_model: + for agent, obs in terminal_obs.items(): + if agent in self._observation_noise_model: + terminal_obs[agent] = self._observation_noise_model[agent](obs) + for agent, obs in terminal_obs.items(): + self.extras[agent]["final_obs"] = obs self._reset_idx(reset_env_ids) # post-step: step interval event diff --git a/source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py b/source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py index de8381e1050..eeebfeab51c 100644 --- a/source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/direct_marl_env_cfg.py @@ -84,6 +84,20 @@ class DirectMARLEnvCfg: wrappers to determine what type of done signal to send to the corresponding learning agent. """ + compute_final_obs: bool = False + """Whether to capture the per-agent terminal observation before a Same-Step autoreset and expose it. + + Under Same-Step autoreset (see :attr:`~isaaclab.envs.DirectMARLEnv.metadata`), an agent whose + environment terminates is reset within the same :meth:`~isaaclab.envs.DirectMARLEnv.step` call, so + the returned observation belongs to the *new* episode. When this flag is True, the observation is + computed once more *before* the reset and stored per agent under ``extras[agent]["final_obs"]`` + (with the same observation noise as the returned observation applied), so wrappers can report it as + the true terminal observation for value bootstrapping. + + Defaults to False, which preserves the previous behavior: no terminal observation is captured, + ``extras[agent]["final_obs"]`` is not populated, and the extra observation computation is skipped. + """ + episode_length_s: float = MISSING """Duration of an episode (in seconds). diff --git a/source/isaaclab/isaaclab/envs/direct_rl_env.py b/source/isaaclab/isaaclab/envs/direct_rl_env.py index bfe97948ac4..ab04ee72457 100644 --- a/source/isaaclab/isaaclab/envs/direct_rl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_rl_env.py @@ -72,6 +72,7 @@ class DirectRLEnv(gym.Env): """Whether the environment is a vectorized environment.""" metadata: ClassVar[dict[str, Any]] = { "render_modes": [None, "human", "rgb_array"], + "autoreset_mode": gym.vector.AutoresetMode.SAME_STEP, } """Metadata for the environment.""" @@ -471,6 +472,14 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: # -- reset envs that terminated/timed-out and log the episode information reset_env_ids = self.reset_buf.nonzero(as_tuple=False).squeeze(-1).int() if len(reset_env_ids) > 0: + # capture the terminal observation before reset and expose it for Same-Step autoreset. + # apply the same observation noise as the returned obs (policy space only) so the + # bootstrapped terminal value matches the distribution the policy is trained on. + if self.cfg.compute_final_obs: + terminal_obs = self._get_observations() + if self.cfg.observation_noise_model: + terminal_obs["policy"] = self._observation_noise_model(terminal_obs["policy"]) + self.extras["final_obs"] = terminal_obs self._reset_idx(reset_env_ids) # if sensors are added to the scene, make sure we render to reflect changes in reset if self.render_enabled and is_rendering and self.has_rtx_sensors and self.cfg.num_rerenders_on_reset > 0: diff --git a/source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py b/source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py index 484338337e7..65aa0403dd0 100644 --- a/source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/direct_rl_env_cfg.py @@ -83,6 +83,23 @@ class DirectRLEnvCfg: wrappers to determine what type of done signal to send to the corresponding learning agent. """ + compute_final_obs: bool = False + """Whether to capture the terminal observation before a Same-Step autoreset and expose it. + + Under Same-Step autoreset (see :attr:`~isaaclab.envs.DirectRLEnv.metadata`), an environment that + terminates is reset within the same :meth:`~isaaclab.envs.DirectRLEnv.step` call, so the returned + observation belongs to the *new* episode. When this flag is True, the observation is computed once + more *before* the reset and stored under ``extras["final_obs"]`` (with the same observation noise + as the returned observation applied), so wrappers can report it as the true terminal observation + for value bootstrapping. + + Defaults to False, which preserves the previous behavior: no terminal observation is captured, + ``extras["final_obs"]`` is not populated, and the extra observation computation is skipped. + + Note: + Currently consumed by the :class:`~isaaclab_rl.sb3.Sb3VecEnvWrapper` wrapper. + """ + episode_length_s: float = MISSING """Duration of an episode (in seconds). diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index dfdbef5091f..d06c77a000b 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -56,6 +56,7 @@ class ManagerBasedRLEnv(ManagerBasedEnv, gym.Env): """Whether the environment is a vectorized environment.""" metadata: ClassVar[dict[str, Any]] = { "render_modes": [None, "human", "rgb_array"], + "autoreset_mode": gym.vector.AutoresetMode.SAME_STEP, } """Metadata for the environment.""" @@ -247,6 +248,9 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: # -- reset envs that terminated/timed-out and log the episode information reset_env_ids = self.reset_buf.nonzero(as_tuple=False).squeeze(-1).int() if len(reset_env_ids) > 0: + # capture the terminal observation before reset and expose it for Same-Step autoreset. + if self.cfg.compute_final_obs: + self.extras["final_obs"] = self.observation_manager.compute() # trigger recorder terms for pre-reset calls self.recorder_manager.record_pre_reset(reset_env_ids) 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 8e85a3bd3ab..1f30784d5f7 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py @@ -42,6 +42,22 @@ class ManagerBasedRLEnvCfg(ManagerBasedEnvCfg): wrappers to determine what type of done signal to send to the corresponding learning agent. """ + compute_final_obs: bool = False + """Whether to capture the terminal observation before a Same-Step autoreset and expose it. + + Under Same-Step autoreset (see :attr:`~isaaclab.envs.ManagerBasedRLEnv.metadata`), an environment + that terminates is reset within the same :meth:`~isaaclab.envs.ManagerBasedRLEnv.step` call, so the + returned observation belongs to the *new* episode. When this flag is True, the observation is + computed once more *before* the reset and stored under ``extras["final_obs"]``, so wrappers can + report it as the true terminal observation for value bootstrapping. + + Defaults to False, which preserves the previous behavior: no terminal observation is captured, + ``extras["final_obs"]`` is not populated, and the extra observation computation is skipped. + + Note: + Currently consumed by the :class:`~isaaclab_rl.sb3.Sb3VecEnvWrapper` wrapper. + """ + episode_length_s: float = MISSING """Duration of an episode (in seconds). diff --git a/source/isaaclab_rl/changelog.d/support-autoreset-final-obs.rst b/source/isaaclab_rl/changelog.d/support-autoreset-final-obs.rst new file mode 100644 index 00000000000..4ca487fbbc7 --- /dev/null +++ b/source/isaaclab_rl/changelog.d/support-autoreset-final-obs.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Updated ``Sb3VecEnvWrapper`` to use Isaac Lab Same-Step ``extras["final_obs"]`` + as SB3 ``terminal_observation`` when it is available. diff --git a/source/isaaclab_rl/isaaclab_rl/sb3.py b/source/isaaclab_rl/isaaclab_rl/sb3.py index 90ca0fd3b85..92a4d2ed602 100644 --- a/source/isaaclab_rl/isaaclab_rl/sb3.py +++ b/source/isaaclab_rl/isaaclab_rl/sb3.py @@ -267,7 +267,8 @@ def step_wait(self) -> VecEnvStepReturn: # noqa: D102 self._ep_rew_buf += rewards self._ep_len_buf += 1 # convert extra information to list of dicts - infos = self._process_extras(obs, terminated, truncated, extras, reset_ids) + final_obs = self._process_obs(extras["final_obs"]) if len(reset_ids) > 0 and "final_obs" in extras else None + infos = self._process_extras(obs, terminated, truncated, extras, reset_ids, final_obs=final_obs) # reset info for terminated environments self._ep_rew_buf[reset_ids] = 0.0 @@ -375,10 +376,12 @@ def _process_obs(self, obs_dict: torch.Tensor | dict[str, torch.Tensor]) -> np.n obs = obs_dict["policy"] # note: ManagerBasedRLEnv uses torch backend (by default). if isinstance(obs, dict): + processed_obs = {} for key, value in obs.items(): if key in self.observation_processors: - obs[key] = self.observation_processors[key](value) - obs[key] = obs[key].detach().cpu().numpy() + value = self.observation_processors[key](value) + processed_obs[key] = value.detach().cpu().numpy() + obs = processed_obs elif isinstance(obs, torch.Tensor): obs = obs.detach().cpu().numpy() else: @@ -386,7 +389,13 @@ def _process_obs(self, obs_dict: torch.Tensor | dict[str, torch.Tensor]) -> np.n return obs def _process_extras( - self, obs: np.ndarray, terminated: np.ndarray, truncated: np.ndarray, extras: dict, reset_ids: np.ndarray + self, + obs: np.ndarray, + terminated: np.ndarray, + truncated: np.ndarray, + extras: dict, + reset_ids: np.ndarray, + final_obs: np.ndarray | dict[str, np.ndarray] | None = None, ) -> list[dict[str, Any]]: """Convert miscellaneous information into dictionary for each sub-environment.""" # faster version: only process env that terminated and add bootstrapping info @@ -404,16 +413,18 @@ def _process_extras( infos[idx]["TimeLimit.truncated"] = truncated[idx] and not terminated[idx] # add information about terminal observation separately - if isinstance(obs, dict): - terminal_obs = {key: value[idx] for key, value in obs.items()} + terminal_obs_source = final_obs if final_obs is not None else obs + if isinstance(terminal_obs_source, dict): + terminal_obs = {key: value[idx] for key, value in terminal_obs_source.items()} else: - terminal_obs = obs[idx] + terminal_obs = terminal_obs_source[idx] infos[idx]["terminal_observation"] = terminal_obs return infos # create empty list of dictionaries to fill - infos: list[dict[str, Any]] = [dict.fromkeys(extras.keys()) for _ in range(self.num_envs)] + extra_keys = [key for key in extras.keys() if key != "final_obs"] + infos: list[dict[str, Any]] = [dict.fromkeys(extra_keys) for _ in range(self.num_envs)] # fill-in information for each sub-environment # note: This loop becomes slow when number of environments is large. for idx in range(self.num_envs): @@ -428,6 +439,8 @@ def _process_extras( infos[idx]["TimeLimit.truncated"] = truncated[idx] and not terminated[idx] # fill-in information from extras for key, value in extras.items(): + if key == "final_obs": + continue # 1. remap extra episodes information safely # 2. for others just store their values if key == "log": @@ -440,12 +453,13 @@ def _process_extras( # add information about terminal observation separately if idx in reset_ids: # extract terminal observations - if isinstance(obs, dict): - terminal_obs = dict.fromkeys(obs.keys()) - for key, value in obs.items(): + terminal_obs_source = final_obs if final_obs is not None else obs + if isinstance(terminal_obs_source, dict): + terminal_obs = dict.fromkeys(terminal_obs_source.keys()) + for key, value in terminal_obs_source.items(): terminal_obs[key] = value[idx] else: - terminal_obs = obs[idx] + terminal_obs = terminal_obs_source[idx] # add info to dict infos[idx]["terminal_observation"] = terminal_obs else: diff --git a/source/isaaclab_tasks/changelog.d/support-autoreset-modes.rst b/source/isaaclab_tasks/changelog.d/support-autoreset-modes.rst new file mode 100644 index 00000000000..71fe8800371 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/support-autoreset-modes.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Changed :class:`~isaaclab_tasks.core.dexsuite.dexsuite_env_cfg.DexsuiteReorientEnvCfg` to derive + from :class:`~isaaclab.envs.ManagerBasedRLEnvCfg` instead of + :class:`~isaaclab.envs.ManagerBasedEnvCfg`, so the RL-specific configuration fields are inherited + rather than set ad hoc in ``__post_init__``. diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/dexsuite/dexsuite_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/dexsuite/dexsuite_env_cfg.py index 9330f3b225b..6b047bd656e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/dexsuite/dexsuite_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/dexsuite/dexsuite_env_cfg.py @@ -10,7 +10,7 @@ import isaaclab.sim as sim_utils from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg -from isaaclab.envs import ManagerBasedEnvCfg, ViewerCfg +from isaaclab.envs import ManagerBasedRLEnvCfg, ViewerCfg from isaaclab.managers import EventTermCfg as EventTerm from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm @@ -451,7 +451,7 @@ class PhysicsCfg(PresetCfg): @configclass -class DexsuiteReorientEnvCfg(ManagerBasedEnvCfg): +class DexsuiteReorientEnvCfg(ManagerBasedRLEnvCfg): """Dexsuite reorientation task definition, also the base definition for derivative Lift task and evaluation task""" # Scene settings From f53c9ec5df4ab88f7f19ed8dc0fd9ff4aca5738d Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 06:31:55 +0000 Subject: [PATCH 047/139] [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: 7.4.0 → 7.5.0 - isaaclab_newton: 1.4.0 → 1.5.0 - isaaclab_physx: 2.4.0 → 2.5.0 - isaaclab_rl: 0.6.1 → 0.6.2 - isaaclab_tasks: 8.1.0 → 8.1.1 --- .../changelog.d/support-autoreset-modes.rst | 10 ---- .../vidurv-schema-frag-jointdrive.minor.rst | 22 --------- ...vidurv-schema-frag-meshcollision.minor.rst | 20 -------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 47 +++++++++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../vidurv-schema-frag-jointdrive.minor.rst | 8 ---- ...vidurv-schema-frag-meshcollision.minor.rst | 9 ---- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 19 ++++++++ source/isaaclab_newton/pyproject.toml | 2 +- .../vidurv-schema-frag-jointdrive.minor.rst | 11 ----- ...vidurv-schema-frag-meshcollision.minor.rst | 11 ----- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 24 ++++++++++ source/isaaclab_physx/pyproject.toml | 2 +- .../support-autoreset-final-obs.rst | 5 -- source/isaaclab_rl/config/extension.toml | 2 +- source/isaaclab_rl/docs/CHANGELOG.rst | 10 ++++ source/isaaclab_rl/pyproject.toml | 2 +- .../changelog.d/support-autoreset-modes.rst | 7 --- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 12 +++++ source/isaaclab_tasks/pyproject.toml | 2 +- 24 files changed, 122 insertions(+), 113 deletions(-) delete mode 100644 source/isaaclab/changelog.d/support-autoreset-modes.rst delete mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-jointdrive.minor.rst delete mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-meshcollision.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-jointdrive.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-meshcollision.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-jointdrive.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-meshcollision.minor.rst delete mode 100644 source/isaaclab_rl/changelog.d/support-autoreset-final-obs.rst delete mode 100644 source/isaaclab_tasks/changelog.d/support-autoreset-modes.rst diff --git a/source/isaaclab/changelog.d/support-autoreset-modes.rst b/source/isaaclab/changelog.d/support-autoreset-modes.rst deleted file mode 100644 index b706282a769..00000000000 --- a/source/isaaclab/changelog.d/support-autoreset-modes.rst +++ /dev/null @@ -1,10 +0,0 @@ -Added -^^^^^ - -* Added an opt-in ``compute_final_obs`` flag (default ``False``) to - :class:`~isaaclab.envs.DirectRLEnvCfg`, :class:`~isaaclab.envs.DirectMARLEnvCfg`, and - :class:`~isaaclab.envs.ManagerBasedRLEnvCfg` that captures the terminal observation before a - Same-Step autoreset and exposes it through ``extras["final_obs"]``. The captured observation has - the same observation noise applied as the returned observation. When the flag is ``False`` the - previous behavior is preserved (no capture, no extra observation computation). -* Declared the Same-Step autoreset mode in RL environment metadata. diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-jointdrive.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-jointdrive.minor.rst deleted file mode 100644 index 03873c2b0ef..00000000000 --- a/source/isaaclab/changelog.d/vidurv-schema-frag-jointdrive.minor.rst +++ /dev/null @@ -1,22 +0,0 @@ -Added -^^^^^ - -* Added the joint-drive schema-fragment API: the - :class:`~isaaclab.sim.schemas.JointDriveFragment` marker and - :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` (writing the typed multi-instance - ``UsdPhysics.DriveAPI`` attributes). The drive fragment overrides its ``func`` with - :func:`~isaaclab.sim.schemas.apply_drive`, which selects the angular/linear instance, performs - the radian-to-degree conversion for angular drives, and skips tendon child prims. -* Added :func:`~isaaclab.sim.schemas.apply_joint_drive_properties` (applies a list of joint-drive - fragments to all joint prims under a path; ``UsdPhysics.DriveAPI`` is presence-gated and applied - only when a :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment is present). - -Changed -^^^^^^^ - -* Changed the spawner ``joint_drive_props`` slot - (:attr:`~isaaclab.sim.spawners.FileCfg.joint_drive_props`) to also accept a list of - :class:`~isaaclab.sim.schemas.JointDriveFragment` fragments. Legacy single cfgs continue to work - through a transition bridge at the from-files spawn site. Added the spawner-level - ``ensure_drives_exist`` flag to reproduce the legacy minimal-stiffness behaviour for the fragment - path. diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-meshcollision.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-meshcollision.minor.rst deleted file mode 100644 index 3532b8f68ff..00000000000 --- a/source/isaaclab/changelog.d/vidurv-schema-frag-meshcollision.minor.rst +++ /dev/null @@ -1,20 +0,0 @@ -Added -^^^^^ - -* Added the mesh-collision schema-fragment API: the - :class:`~isaaclab.sim.schemas.MeshCollisionFragment` marker and - :class:`~isaaclab.sim.schemas.UsdPhysicsMeshCollisionCfg` (carrying the standard - ``physics:approximation`` token via ``UsdPhysics.MeshCollisionAPI``). -* Added :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`, which applies - ``UsdPhysics.MeshCollisionAPI`` as the implicit anchor, resolves the - ``physics:approximation`` token from whichever cooking fragment is present (validated against - :const:`~isaaclab.sim.schemas.MESH_APPROXIMATION_TOKENS`), and dispatches each fragment via its - ``func``. - -Changed -^^^^^^^ - -* Changed the mesh-converter ``mesh_collision_props`` slot - (:attr:`~isaaclab.sim.converters.MeshConverterCfg.mesh_collision_props`) to also accept a list of - :class:`~isaaclab.sim.schemas.MeshCollisionFragment` fragments. Legacy single cfgs continue to - work through a transition bridge in the converter. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index bad36771b56..8393814f2fe 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 = "7.4.0" +version = "7.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 a229b694a3a..acac134b6c2 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,53 @@ Changelog --------- +7.5.0 (2026-06-28) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the mesh-collision schema-fragment API: the + :class:`~isaaclab.sim.schemas.MeshCollisionFragment` marker and + :class:`~isaaclab.sim.schemas.UsdPhysicsMeshCollisionCfg` (carrying the standard + ``physics:approximation`` token via ``UsdPhysics.MeshCollisionAPI``). +* Added :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`, which applies + ``UsdPhysics.MeshCollisionAPI`` as the implicit anchor, resolves the + ``physics:approximation`` token from whichever cooking fragment is present (validated against + :const:`~isaaclab.sim.schemas.MESH_APPROXIMATION_TOKENS`), and dispatches each fragment via its + ``func``. +* Added the joint-drive schema-fragment API: the + :class:`~isaaclab.sim.schemas.JointDriveFragment` marker and + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` (writing the typed multi-instance + ``UsdPhysics.DriveAPI`` attributes). The drive fragment overrides its ``func`` with + :func:`~isaaclab.sim.schemas.apply_drive`, which selects the angular/linear instance, performs + the radian-to-degree conversion for angular drives, and skips tendon child prims. +* Added :func:`~isaaclab.sim.schemas.apply_joint_drive_properties` (applies a list of joint-drive + fragments to all joint prims under a path; ``UsdPhysics.DriveAPI`` is presence-gated and applied + only when a :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` fragment is present). +* Added an opt-in ``compute_final_obs`` flag (default ``False``) to + :class:`~isaaclab.envs.DirectRLEnvCfg`, :class:`~isaaclab.envs.DirectMARLEnvCfg`, and + :class:`~isaaclab.envs.ManagerBasedRLEnvCfg` that captures the terminal observation before a + Same-Step autoreset and exposes it through ``extras["final_obs"]``. The captured observation has + the same observation noise applied as the returned observation. When the flag is ``False`` the + previous behavior is preserved (no capture, no extra observation computation). +* Declared the Same-Step autoreset mode in RL environment metadata. + +Changed +^^^^^^^ + +* Changed the mesh-converter ``mesh_collision_props`` slot + (:attr:`~isaaclab.sim.converters.MeshConverterCfg.mesh_collision_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.MeshCollisionFragment` fragments. Legacy single cfgs continue to + work through a transition bridge in the converter. +* Changed the spawner ``joint_drive_props`` slot + (:attr:`~isaaclab.sim.spawners.FileCfg.joint_drive_props`) to also accept a list of + :class:`~isaaclab.sim.schemas.JointDriveFragment` fragments. Legacy single cfgs continue to work + through a transition bridge at the from-files spawn site. Added the spawner-level + ``ensure_drives_exist`` flag to reproduce the legacy minimal-stiffness behaviour for the fragment + path. + + 7.4.0 (2026-06-27) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index f78b9933241..74d328498f0 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "7.4.0" +version = "7.5.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-jointdrive.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-jointdrive.minor.rst deleted file mode 100644 index 52ff628dcbd..00000000000 --- a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-jointdrive.minor.rst +++ /dev/null @@ -1,8 +0,0 @@ -Added -^^^^^ - -* Added the :class:`~isaaclab_newton.sim.schemas.MujocoJointCfg` joint-drive fragment - (``mjc:*`` / ``MjcJointAPI``), carrying joint-level ``actuatorgravcomp``. Applied alongside - :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` via - :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. The from-files spawn site continues - to auto-enable body-level gravcomp for the fragment path. diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-meshcollision.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-meshcollision.minor.rst deleted file mode 100644 index 4246cfd9a1f..00000000000 --- a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-meshcollision.minor.rst +++ /dev/null @@ -1,9 +0,0 @@ -Added -^^^^^ - -* Added the Newton mesh-collision cooking fragments: - :class:`~isaaclab_newton.sim.schemas.NewtonMeshCollisionCfg` (``newton:maxHullVertices`` via - ``NewtonMeshCollisionAPI``) and :class:`~isaaclab_newton.sim.schemas.NewtonSDFCollisionCfg` - (Newton SDF generation and hydroelastic-contact attributes via ``NewtonSDFCollisionAPI``). Each is - a single-namespace :class:`~isaaclab.sim.schemas.MeshCollisionFragment` dispatched via - :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 9c7f58d42c9..fc9607b6c18 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 = "1.4.0" +version = "1.5.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 1a752a3a217..6a49ddce9da 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,25 @@ Changelog --------- +1.5.0 (2026-06-28) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the Newton mesh-collision cooking fragments: + :class:`~isaaclab_newton.sim.schemas.NewtonMeshCollisionCfg` (``newton:maxHullVertices`` via + ``NewtonMeshCollisionAPI``) and :class:`~isaaclab_newton.sim.schemas.NewtonSDFCollisionCfg` + (Newton SDF generation and hydroelastic-contact attributes via ``NewtonSDFCollisionAPI``). Each is + a single-namespace :class:`~isaaclab.sim.schemas.MeshCollisionFragment` dispatched via + :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. +* Added the :class:`~isaaclab_newton.sim.schemas.MujocoJointCfg` joint-drive fragment + (``mjc:*`` / ``MjcJointAPI``), carrying joint-level ``actuatorgravcomp``. Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` via + :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. The from-files spawn site continues + to auto-enable body-level gravcomp for the fragment path. + + 1.4.0 (2026-06-27) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index 579b86480c4..2c3a9b09a81 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_newton" -version = "1.4.0" +version = "1.5.0" description = "Extension providing IsaacLab with Newton specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-jointdrive.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-jointdrive.minor.rst deleted file mode 100644 index 93ed43935d8..00000000000 --- a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-jointdrive.minor.rst +++ /dev/null @@ -1,11 +0,0 @@ -Added -^^^^^ - -* Added the :class:`~isaaclab_physx.sim.schemas.PhysxJointCfg` joint-drive fragment - (``physxJoint:*`` / ``PhysxJointAPI``), carrying ``max_joint_velocity`` (with the legacy - ``max_velocity`` deprecation alias). Applied alongside - :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` via - :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. -* Added :func:`~isaaclab_physx.sim.schemas.apply_physx_joint`, the dedicated applier for - :class:`~isaaclab_physx.sim.schemas.PhysxJointCfg` that converts ``max_joint_velocity`` from - rad/s to deg/s for angular (revolute) joints, matching the legacy joint-drive unit convention. diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-meshcollision.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-meshcollision.minor.rst deleted file mode 100644 index 8ced9b2e5d2..00000000000 --- a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-meshcollision.minor.rst +++ /dev/null @@ -1,11 +0,0 @@ -Added -^^^^^ - -* Added the PhysX mesh-collision cooking fragments: - :class:`~isaaclab_physx.sim.schemas.PhysxConvexHullCfg`, - :class:`~isaaclab_physx.sim.schemas.PhysxConvexDecompositionCfg`, - :class:`~isaaclab_physx.sim.schemas.PhysxTriangleMeshCfg`, - :class:`~isaaclab_physx.sim.schemas.PhysxTriangleMeshSimplificationCfg`, and - :class:`~isaaclab_physx.sim.schemas.PhysxSDFMeshCfg`. Each is a single-namespace - :class:`~isaaclab.sim.schemas.MeshCollisionFragment` owning one ``physx*Collision:*`` namespace and - applied schema, dispatched via :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index b9a934f8fe3..e910ed12d7c 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 = "2.4.0" +version = "2.5.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 2a6e262c9d8..2ae6f3199a8 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,30 @@ Changelog --------- +2.5.0 (2026-06-28) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the PhysX mesh-collision cooking fragments: + :class:`~isaaclab_physx.sim.schemas.PhysxConvexHullCfg`, + :class:`~isaaclab_physx.sim.schemas.PhysxConvexDecompositionCfg`, + :class:`~isaaclab_physx.sim.schemas.PhysxTriangleMeshCfg`, + :class:`~isaaclab_physx.sim.schemas.PhysxTriangleMeshSimplificationCfg`, and + :class:`~isaaclab_physx.sim.schemas.PhysxSDFMeshCfg`. Each is a single-namespace + :class:`~isaaclab.sim.schemas.MeshCollisionFragment` owning one ``physx*Collision:*`` namespace and + applied schema, dispatched via :func:`~isaaclab.sim.schemas.apply_mesh_collision_properties`. +* Added the :class:`~isaaclab_physx.sim.schemas.PhysxJointCfg` joint-drive fragment + (``physxJoint:*`` / ``PhysxJointAPI``), carrying ``max_joint_velocity`` (with the legacy + ``max_velocity`` deprecation alias). Applied alongside + :class:`~isaaclab.sim.schemas.UsdPhysicsDriveCfg` via + :func:`~isaaclab.sim.schemas.apply_joint_drive_properties`. +* Added :func:`~isaaclab_physx.sim.schemas.apply_physx_joint`, the dedicated applier for + :class:`~isaaclab_physx.sim.schemas.PhysxJointCfg` that converts ``max_joint_velocity`` from + rad/s to deg/s for angular (revolute) joints, matching the legacy joint-drive unit convention. + + 2.4.0 (2026-06-27) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index 938bc4079cd..5b9cd8cdaa2 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "2.4.0" +version = "2.5.0" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_rl/changelog.d/support-autoreset-final-obs.rst b/source/isaaclab_rl/changelog.d/support-autoreset-final-obs.rst deleted file mode 100644 index 4ca487fbbc7..00000000000 --- a/source/isaaclab_rl/changelog.d/support-autoreset-final-obs.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Updated ``Sb3VecEnvWrapper`` to use Isaac Lab Same-Step ``extras["final_obs"]`` - as SB3 ``terminal_observation`` when it is available. diff --git a/source/isaaclab_rl/config/extension.toml b/source/isaaclab_rl/config/extension.toml index b023dc84c31..01fd9d01f40 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.6.1" +version = "0.6.2" # Description title = "Isaac Lab RL" diff --git a/source/isaaclab_rl/docs/CHANGELOG.rst b/source/isaaclab_rl/docs/CHANGELOG.rst index cd9a3f934b5..b27c41f5294 100644 --- a/source/isaaclab_rl/docs/CHANGELOG.rst +++ b/source/isaaclab_rl/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.6.2 (2026-06-28) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Updated ``Sb3VecEnvWrapper`` to use Isaac Lab Same-Step ``extras["final_obs"]`` + as SB3 ``terminal_observation`` when it is available. + + 0.6.1 (2026-06-09) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_rl/pyproject.toml b/source/isaaclab_rl/pyproject.toml index 348278bd75b..fdae2be6e2e 100644 --- a/source/isaaclab_rl/pyproject.toml +++ b/source/isaaclab_rl/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_rl" -version = "0.6.1" +version = "0.6.2" description = "Extension containing reinforcement learning related utilities." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/support-autoreset-modes.rst b/source/isaaclab_tasks/changelog.d/support-autoreset-modes.rst deleted file mode 100644 index 71fe8800371..00000000000 --- a/source/isaaclab_tasks/changelog.d/support-autoreset-modes.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Changed :class:`~isaaclab_tasks.core.dexsuite.dexsuite_env_cfg.DexsuiteReorientEnvCfg` to derive - from :class:`~isaaclab.envs.ManagerBasedRLEnvCfg` instead of - :class:`~isaaclab.envs.ManagerBasedEnvCfg`, so the RL-specific configuration fields are inherited - rather than set ad hoc in ``__post_init__``. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index aa7d9ee1374..7bf74b12903 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 = "8.1.0" +version = "8.1.1" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 669832006f3..d221795d63c 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +8.1.1 (2026-06-28) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Changed :class:`~isaaclab_tasks.core.dexsuite.dexsuite_env_cfg.DexsuiteReorientEnvCfg` to derive + from :class:`~isaaclab.envs.ManagerBasedRLEnvCfg` instead of + :class:`~isaaclab.envs.ManagerBasedEnvCfg`, so the RL-specific configuration fields are inherited + rather than set ad hoc in ``__post_init__``. + + 8.1.0 (2026-06-27) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index 4e1ebfd167b..abb2c4248a9 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.1.0" +version = "8.1.1" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 327a2b6d756e009598848a9bdca687e1d5c338e4 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Mon, 29 Jun 2026 07:36:57 +0200 Subject: [PATCH 048/139] Migrate OVPhysX frame-transformer sensor onto OvPhysxView (view series, part 5) (#6231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ### 🔗 Stacked on top of #6224 (Part 1 — `OvPhysxView`) > Branched off #6224 as a sibling of the asset migrations (#6225/#6226/#6227). Targets `develop`; until #6224 merges, the diff below includes the Part 1 `OvPhysxView` commits. **Please review #6224 first.** # Description Part 5 of the OVPhysX view-migration series — route the **frame-transformer** sensor's tensor-binding management through `OvPhysxView`. Internal refactor; **no public API change** (sensors do not expose a `root_view`). * `_initialize_impl` builds one `OvPhysxView` instead of separate `create_tensor_binding` calls; binding handles come from `binding_for`. * Pose/state reads route through `read_into` (cached `float32` reinterpret, typed off the binding shape), replacing the manual `wp.array(ptr=...)` reinterpret views. * All pose bindings are GPU-resident state; no CPU staging. One view per tracked body. * Numeric behavior unchanged. ## Type of change - [x] This change requires no public API change (internal refactor) ## Checklist - [x] I have run the `pre-commit` checks with `./isaaclab.sh --format` - [x] My changes generate no new warnings - [x] I have added a changelog fragment (`.skip` — internal refactor, no user-facing change / no bump) ## Testing `test_frame_transformer.py` — cpu: **12 passed**, cuda: **12 passed**. --------- Co-authored-by: Kelly Guo Co-authored-by: Kelly Guo --- ...r-feat-ovphysx_frame_transformer_view.skip | 0 .../frame_transformer/frame_transformer.py | 36 ++++++++++--------- .../sensors/frame_transformer/kernels.py | 9 +++-- 3 files changed, 23 insertions(+), 22 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_frame_transformer_view.skip diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_frame_transformer_view.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_frame_transformer_view.skip new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/frame_transformer/frame_transformer.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/frame_transformer/frame_transformer.py index bdc4ec8e045..42980e8eb7a 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/frame_transformer/frame_transformer.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/frame_transformer/frame_transformer.py @@ -23,6 +23,7 @@ import isaaclab_ovphysx.tensor_types as TT from isaaclab_ovphysx.physics import OvPhysxManager as SimulationManager +from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView from .frame_transformer_data import FrameTransformerData from .kernels import frame_transformer_update_kernel, gather_body_pose_kernel @@ -61,7 +62,7 @@ def __init__(self, cfg: FrameTransformerCfg): super().__init__(cfg) self._data: FrameTransformerData = FrameTransformerData() self._physx_instance: Any = None - self._body_bindings: list[Any] = [] + self._body_views: list[OvPhysxView] = [] self._body_read_bufs: list[wp.array] = [] self._body_dst_flat_indices: list[wp.array] = [] self._raw_transforms: wp.array | None = None @@ -228,7 +229,7 @@ def has_rigid_body_api(prim) -> bool: tracked_prim_paths = [body_names_to_frames[body_name]["prim_path"] for body_name in body_names_to_frames.keys()] tracked_body_names = [body_name for body_name in body_names_to_frames.keys()] - # --- OVPhysX: create one TT.RIGID_BODY_POSE binding per unique tracked body --- + # --- OVPhysX: create one TT.RIGID_BODY_POSE view per unique tracked body --- physx_instance = SimulationManager.get_physx_instance() if physx_instance is None: raise RuntimeError( @@ -236,20 +237,22 @@ def has_rigid_body_api(prim) -> bool: " Reset the simulation context before adding the FrameTransformer." ) self._physx_instance = physx_instance - self._body_bindings = [] - self._body_read_bufs = [] # one (num_envs, 7) float32 buffer per body + self._body_views = [] + self._body_read_bufs = [] # one (num_envs,) wp.transformf buffer per body self._body_dst_flat_indices = [] # (num_envs,) int32 destination slots per body num_unique_bodies = len(tracked_body_names) for body_slot, tracked_path in enumerate(tracked_prim_paths): pattern = self._env_wildcardify(tracked_path) - binding = physx_instance.create_tensor_binding(pattern=pattern, tensor_type=TT.RIGID_BODY_POSE) - if binding.count == 0: + view = OvPhysxView(physx_instance, pattern=pattern, device=self._device) + try: + binding = view.binding_for(TT.RIGID_BODY_POSE) + except OvPhysxView.AttributeUnavailable as exc: raise RuntimeError( f"FrameTransformer: TT.RIGID_BODY_POSE binding for pattern {pattern!r} matched zero bodies." " Verify the prim has UsdPhysics.RigidBodyAPI." - ) + ) from exc if binding.count != self._num_envs: # OVPhysX's InteractiveScene defaults to clone_usd=True on develop, so this branch is @@ -270,13 +273,13 @@ def has_rigid_body_api(prim) -> bool: self._timestamp = wp.zeros(self._num_envs, dtype=wp.float32, device=self._device) self._timestamp_last_update = wp.zeros_like(self._timestamp) - read_buf = wp.zeros((self._num_envs, 7), dtype=wp.float32, device=self._device) + read_buf = wp.zeros(self._num_envs, dtype=wp.transformf, device=self._device) dst_torch = torch.tensor( [env_id * num_unique_bodies + body_slot for env_id in range(self._num_envs)], dtype=torch.int32, device=self._device, ) - self._body_bindings.append(binding) + self._body_views.append(view) self._body_read_bufs.append(read_buf) self._body_dst_flat_indices.append(wp.from_torch(dst_torch.contiguous(), dtype=wp.int32)) @@ -423,16 +426,15 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None) -> None: " Access sensor data only after sim.reset() has been called." ) - # Step 1: refresh each per-body RIGID_BODY_POSE binding and gather rows into _raw_transforms. - for binding, read_buf, dst_indices in zip( - self._body_bindings, self._body_read_bufs, self._body_dst_flat_indices - ): - binding.read(read_buf) - pose_buf_tf = read_buf.view(wp.transformf) # (num_envs, 7) float32 -> (num_envs,) transformf + # Step 1: refresh each per-body RIGID_BODY_POSE view and gather rows into _raw_transforms. + # read_into fills the structured (num_envs,) wp.transformf buffer directly via a cached + # float32 reinterpret, so no manual .view(wp.transformf) is needed here. + for view, read_buf, dst_indices in zip(self._body_views, self._body_read_bufs, self._body_dst_flat_indices): + view.read_into(TT.RIGID_BODY_POSE, read_buf) wp.launch( gather_body_pose_kernel, dim=self._num_envs, - inputs=[env_mask, pose_buf_tf, dst_indices, self._raw_transforms], + inputs=[env_mask, read_buf, dst_indices, self._raw_transforms], device=self._device, ) @@ -519,7 +521,7 @@ def _invalidate_initialize_callback(self, event) -> None: super()._invalidate_initialize_callback(event) self._body_read_bufs = [] self._body_dst_flat_indices = [] - self._body_bindings = [] + self._body_views = [] self._physx_instance = None self._raw_transforms = None self._source_raw_indices = None diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/frame_transformer/kernels.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/frame_transformer/kernels.py index d7a32c534ca..1e790f792ea 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/frame_transformer/kernels.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/frame_transformer/kernels.py @@ -97,11 +97,10 @@ def gather_body_pose_kernel( For each env in the launch, copies ``pose_buffer[env]`` into ``raw_transforms[dst_flat_indices[env]]``. Skips envs whose ``env_mask`` is False. - The pose buffer is a view (``wp.array.view(wp.transformf)``) over a - ``(num_envs, 7)`` ``float32`` array populated by - ``binding.read(...)`` for a single ``RIGID_BODY_POSE`` tensor binding, - so it has shape ``(num_envs,)``. One launch per tracked body fills the - body's slot column in the flat ``raw_transforms`` buffer. + The pose buffer is a ``(num_envs,)`` ``wp.transformf`` array filled in place + from a single ``RIGID_BODY_POSE`` view via ``OvPhysxView.read_into``. One + launch per tracked body fills the body's slot column in the flat + ``raw_transforms`` buffer. Args: env_mask: Active environment mask, shape ``(num_envs,)``. From f7576f1dcddad3fa1e174a4fc8621c5ffe201001 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 06:36:25 +0000 Subject: [PATCH 049/139] [CI][Auto Version Bump] Compile changelog fragments (schedule) Bumped packages: --- .../changelog.d/antoiner-feat-ovphysx_frame_transformer_view.skip | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_frame_transformer_view.skip diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_frame_transformer_view.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_frame_transformer_view.skip deleted file mode 100644 index e69de29bb2d..00000000000 From f5c80a8bad5bc02d83e7943e847b03d52d2a6a5a Mon Sep 17 00:00:00 2001 From: hujc Date: Mon, 29 Jun 2026 11:57:23 -0700 Subject: [PATCH 050/139] [BUG] Fix Isaac Sim 6.0 streaming crash from prebundle torch shadowing (#6288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes a crash (nvbugs 6343978) when launching the Isaac Sim streaming app (`isaac-sim.streaming.sh` / `runheadless.sh`) from an Isaac Lab install against Isaac Sim 6.0: ``` [Error] [isaacsim.core.deprecation_manager.api] Import error: .../omni.isaac.ml_archive/pip_prebundle/torch/lib/libtorch_cuda.so: undefined symbol: ncclDevCommCreate ``` ## Root cause (corrected after investigation) Not a broken Isaac Sim package — a **cross-stack NCCL collision**: - Isaac Sim 6.0's deprecated `omni.isaac.ml_archive` prebundle ships a *self-consistent* torch + NCCL (torch 2.11 + nccl 2.28.9, which **does** export `ncclDevCommCreate`). - Isaac Lab installs its own pinned torch (2.10) + nccl (2.27.5) into site-packages. - torch's Python-side CUDA preload resolves `libnccl.so.2` by `sys.path`/namespace order (`RTLD_GLOBAL`), *before* `libtorch_cuda.so`'s RPATH. On launch paths that **don't import `isaaclab`** (the streaming app — `isaaclab/__init__.py` deprioritizes the prebundle only on import), the older pip nccl 2.27.5 loads, the prebundle torch 2.11 binds to it, and `ncclDevCommCreate` is missing → crash. LD_DEBUG confirms the pip nccl wins the load. `_repoint_prebundle_packages()` already exists to prevent this (it symlinks the prebundle to the active env, collapsing to one stack), but it **silently no-op'd inside the Docker image build**: the prebundle is on an overlayfs lower layer where `os.rename`→`.bak` raises `EXDEV` and `shutil.rmtree` raises `EINVAL` (its `dir_fd` walk), both swallowed as warnings. It works on the host (single filesystem), which is why only the Docker image was affected. ## Fix Make `_repoint_prebundle_packages()` overlayfs-safe via a single uniform path: - Replace rename-to-`.bak` + `shutil.rmtree` with `_force_remove` (plain-path `os.unlink`/`os.rmdir`, which create the proper overlayfs whiteout) + symlink. No `.bak` (the env copy is the symlink target), no subprocess, no fallback. - Add a fail-loud post-check: raise if a real prebundle `torch` still shadows the pip torch, so the broken state can't ship silently again. This collapses the two torch/nccl stacks into one on every launch path, host and Docker. ## Validation (x86 RTX A6000, exact `isaac-lab` container) - Before: streaming crashes at ~19s with `undefined symbol: ncclDevCommCreate`. - After (repoint runs at build): prebundle torch → symlink to env 2.10; streaming boots to `Isaac Sim Full Streaming Version: 6.0.0-rc.47`, 0 `ncclDevCommCreate`. ## Tests - `test_no_shadowing_prebundled_torch_in_isaac_sim` (filesystem invariant): RED on the un-fixed image (real prebundle torch), GREEN on the fixed image / host (symlink). - Updated `_repoint_prebundle_packages` unit tests: no `.bak`; new fail-loud test. Fixes nvbugs 6343978. --- ...anh-fix-sim6-prebundle-torch-streaming.rst | 13 +++++ .../isaaclab/isaaclab/cli/commands/install.py | 55 +++++++++++++++---- source/isaaclab/test/cli/test_install.py | 33 +++++++++++ .../test/cli/test_install_commands.py | 23 ++++---- 4 files changed, 101 insertions(+), 23 deletions(-) create mode 100644 source/isaaclab/changelog.d/jichuanh-fix-sim6-prebundle-torch-streaming.rst diff --git a/source/isaaclab/changelog.d/jichuanh-fix-sim6-prebundle-torch-streaming.rst b/source/isaaclab/changelog.d/jichuanh-fix-sim6-prebundle-torch-streaming.rst new file mode 100644 index 00000000000..c971f5f6fa8 --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-fix-sim6-prebundle-torch-streaming.rst @@ -0,0 +1,13 @@ +Fixed +^^^^^ + +* Fixed a crash (``undefined symbol: ncclDevCommCreate``) when launching the Isaac Sim + streaming app (e.g. ``isaac-sim.streaming.sh`` / ``runheadless.sh``) from an Isaac Lab + install against Isaac Sim 6.0. Isaac Sim's deprecated ``omni.isaac.ml_archive`` prebundle + ships its own PyTorch and NCCL while Isaac Lab installs a different pinned PyTorch; on + launch paths that do not import Isaac Lab (which otherwise deprioritizes the prebundle on + ``sys.path``), the two NCCL copies collide and the prebundled torch binds to the wrong + one. The install step that repoints the prebundle to the active environment now uses + overlayfs-safe filesystem operations, so it works inside the Docker image build (where it + previously failed silently with ``EXDEV`` / ``EINVAL``) and fails loudly if a shadowing + copy remains. diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index 6154d19b0ea..fe975f076c6 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -754,6 +754,24 @@ def _install_extra_feature(feature_name: str, selector: str = "") -> None: """ +def _force_remove(path: Path) -> None: + """Recursively remove a file, directory, or symlink. A missing path is a no-op. + + Uses absolute-path :func:`os.unlink` / :func:`os.rmdir` rather than the + ``dir_fd``-relative operations :func:`shutil.rmtree` performs internally. On + an overlayfs *lower* layer (e.g. inside a Docker image build) the ``dir_fd`` + variant raises ``EINVAL``, whereas the plain ``unlink(2)`` / ``rmdir(2)`` + syscalls create the proper whiteout. This makes prebundle neutralization + behave identically on a normal filesystem and on an overlayfs lower layer. + """ + if path.is_symlink() or path.is_file(): + os.unlink(path) + elif path.is_dir(): + for child in path.iterdir(): + _force_remove(child) + os.rmdir(path) + + def _repoint_prebundle_packages() -> None: """Replace prebundled packages in Isaac Sim with symlinks to the active environment. @@ -833,16 +851,15 @@ def _repoint_prebundle_packages() -> None: continue try: - if prebundled.is_symlink(): - if prebundled.resolve() == venv_pkg.resolve(): - continue - prebundled.unlink() - else: - backup = prebundle_dir / f"{pkg_name}.bak" - if backup.exists() or backup.is_symlink(): - shutil.rmtree(backup) if backup.is_dir() else backup.unlink() - prebundled.rename(backup) - + # Already repointed to the right place — nothing to do. + if prebundled.is_symlink() and prebundled.resolve() == venv_pkg.resolve(): + continue + # Replace the prebundled copy (a stale symlink or a real directory) + # with a symlink to the active environment. We remove rather than + # rename-to-``.bak``: the env copy is the symlink target, so the + # prebundle content is redundant, and renaming a directory on an + # overlayfs lower layer (Docker image build) fails with ``EXDEV``. + _force_remove(prebundled) if use_symlinks: prebundled.symlink_to(venv_pkg) else: @@ -859,6 +876,24 @@ def _repoint_prebundle_packages() -> None: else: print_debug("All prebundled packages already up-to-date — nothing to repoint.") + # Fail loud: a real (non-symlink) prebundled ``torch`` left behind shadows the + # pip-installed torch on launch paths that do not import ``isaaclab`` (e.g. + # ``isaac-sim.streaming.sh``), pulling a mismatched NCCL and crashing with + # ``undefined symbol: ncclDevCommCreate``. Never let that state ship silently. + # Only relevant when symlinking (Linux); the Windows branch deliberately copies the + # env package into the prebundle, which is a real directory by design. + if use_symlinks and (site_packages / "torch").exists(): + shadowing = [ + prebundle_dir / "torch" + for prebundle_dir in prebundle_dirs + if (prebundle_dir / "torch").is_dir() and not (prebundle_dir / "torch").is_symlink() + ] + if shadowing: + raise RuntimeError( + "Failed to neutralize prebundled torch under Isaac Sim; the following would shadow the " + "pip-installed torch and crash non-isaaclab launches:\n " + "\n ".join(str(p) for p in shadowing) + ) + def command_install(install_type: str = "all") -> None: """Install Isaac Lab extensions and optional extras. diff --git a/source/isaaclab/test/cli/test_install.py b/source/isaaclab/test/cli/test_install.py index 13494cec696..9be554c9b11 100644 --- a/source/isaaclab/test/cli/test_install.py +++ b/source/isaaclab/test/cli/test_install.py @@ -222,3 +222,36 @@ def test_uses_package_metadata_when_no_version_file(self, tmp_path): ): result = determine_python_version() assert result == "3.11" + + +# --------------------------------------------------------------------------- +# Prebundled-torch shadowing invariant (regression: nvbugs 6343978) +# --------------------------------------------------------------------------- + + +def test_no_shadowing_prebundled_torch_in_isaac_sim(): + """A prebundled torch must not shadow the pip-installed torch. + + Regression test for nvbugs 6343978: Isaac Sim 6.0 ships a prebundled PyTorch under + the deprecated ``omni.isaac.ml_archive`` extension whose ``libtorch_cuda.so`` + requires an NCCL symbol the co-bundled NCCL does not export. Launch paths that do + not import :mod:`isaaclab` (e.g. ``isaac-sim.streaming.sh`` / ``runheadless.sh``) + bypass the ``sys.path`` deprioritization and import this broken copy, crashing with + ``undefined symbol: ncclDevCommCreate``. After install/build, every + ``pip_prebundle/torch`` under Isaac Sim must therefore be either removed or a + symlink into the active environment, never a real shadowing directory. + """ + isaacsim_path = extract_isaacsim_path(required=False) + if isaacsim_path is None or not isaacsim_path.exists(): + pytest.skip("Isaac Sim installation not found; skipping prebundle-shadow invariant check") + + shadowing = [ + prebundled_torch + for prebundled_torch in isaacsim_path.rglob("pip_prebundle/torch") + if prebundled_torch.is_dir() and not prebundled_torch.is_symlink() + ] + assert not shadowing, ( + "Found prebundled torch directories that shadow the pip-installed torch (nvbugs 6343978). " + "They must be removed at image build time or repointed to the active environment:\n " + + "\n ".join(str(p) for p in shadowing) + ) diff --git a/source/isaaclab/test/cli/test_install_commands.py b/source/isaaclab/test/cli/test_install_commands.py index f773d9f026c..af1ba751c2a 100644 --- a/source/isaaclab/test/cli/test_install_commands.py +++ b/source/isaaclab/test/cli/test_install_commands.py @@ -643,7 +643,7 @@ def test_local_build_symlinks_torch_to_venv_site_packages(self, tmp_path): symlink = prebundle / "torch" assert symlink.is_symlink(), "torch should be a symlink after repoint" assert symlink.resolve() == (site_pkgs / "torch").resolve() - assert (prebundle / "torch.bak").is_dir(), "Original torch should be backed up" + assert not (prebundle / "torch.bak").exists(), "repoint replaces in place — no .bak (env copy is the target)" def test_local_build_skips_nvidia_when_cudnn_absent_kit_python(self, tmp_path): """Local build + kit Python: site-packages/nvidia has only 'srl' (no cudnn) → nvidia NOT repointed. @@ -717,23 +717,20 @@ def test_updates_stale_symlink_pointing_to_old_env(self, tmp_path): assert (prebundle / "torch").resolve() == (site_pkgs / "torch").resolve(), "Stale symlink must be updated" - def test_removes_old_backup_before_renaming(self, tmp_path): - """A pre-existing .bak directory is removed before the current package is backed up.""" + def test_raises_when_prebundled_torch_not_neutralized(self, tmp_path): + """Fail loud: a real prebundled torch surviving repoint would shadow the pip torch + on launch paths that do not import isaaclab (nvbugs 6343978), so repoint raises + instead of silently leaving the broken state in place.""" isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["torch"]) site_pkgs = _make_site_packages(tmp_path / "env", ["torch"]) py = str(tmp_path / "env" / "bin" / "python") - # Simulate leftover backup from a previous partial install. - old_backup = prebundle / "torch.bak" - old_backup.mkdir() - (old_backup / "stale_file.py").touch() - + # Simulate the removal not taking effect (e.g. an unhandled filesystem quirk): the + # prebundled torch stays a real directory rather than becoming a symlink. with self._patch(isaacsim_path, site_pkgs, py): - _repoint_prebundle_packages() - - assert (prebundle / "torch").is_symlink(), "torch must be repointed" - # The old backup was replaced by the fresh backup. - assert (prebundle / "torch.bak").is_dir() + with mock.patch("isaaclab.cli.commands.install._force_remove"): + with pytest.raises(RuntimeError, match="neutralize"): + _repoint_prebundle_packages() # ---- pip-installed isaacsim (path found via import probe) ---------------- From a5290373f6a555d4bb814701ffbb59263ccb2663 Mon Sep 17 00:00:00 2001 From: Michal Hapala <8554610+mhapala@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:49:15 +0200 Subject: [PATCH 051/139] Fix PhysxManager crash from stale omni.physx interface cache (#6276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description `PhysxManager` cached the `omni.physx` `IPhysx` / `IPhysxSimulation` interfaces as class variables acquired at **module-import time** and reused them for the whole process lifetime. The `omni.physx` accessors (`get_physx_interface()` / `get_physx_simulation_interface()`) already memoize the acquired interface for the process, so this was a redundant *second* cache — and a stale one. If the `omni.physx` plugin is (re)loaded any time after this module is imported, the cached pointers dangle. The next `_warmup_and_create_views()` then calls `attach_stage()` on the freed interface and the indirect call faults under Windows Control Flow Guard (`EXCEPTION_ACCESS_VIOLATION` in `_guard_dispatch_icall_nop`). This was hit on a Blackwell GPU CI box running IsaacLab tasks back-to-back (`attach_stage` is only invoked on the GPU pipeline, consistent with the crash). **Fix:** drop the `_physx` / `_physx_sim` class variables and resolve the interfaces on each use via `omni.physx.get_physx_interface()` / `omni.physx.get_physx_simulation_interface()`, letting the single, correct `omni.physx` cache own the lifetime. The existing `test_warmup_attach_stage_not_called_for_cpu` regression tests are updated to patch the accessor instead of the removed class variable. Fixes NVBugs 6289174. ## 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` - [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 - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there Note: the reload-induced dangling-interface crash only reproduces under runtime extension toggling (an unsupported flow on a specific GPU box), so a deterministic unit test isn't feasible; the existing CPU `attach_stage` regression tests were updated to the new accessor path instead. --------- Signed-off-by: Michal Hapala Co-authored-by: Claude Opus 4.8 (1M context) --- CONTRIBUTORS.md | 1 + .../changelog.d/physx-iface-cache.rst | 4 +++ .../isaaclab_physx/physics/physx_manager.py | 34 +++++++++++-------- .../test/assets/test_rigid_object.py | 20 +++++------ 4 files changed, 34 insertions(+), 25 deletions(-) create mode 100644 source/isaaclab_physx/changelog.d/physx-iface-cache.rst diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 51c86b8af98..1aa1b57cc4e 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -131,6 +131,7 @@ Guidelines for modifications: * Michael Gussert * Michael Lin * Michael Noseworthy +* Michal Hapala * Miguel Alonso Jr * Mihir Kulkarni * Mingxue Gu diff --git a/source/isaaclab_physx/changelog.d/physx-iface-cache.rst b/source/isaaclab_physx/changelog.d/physx-iface-cache.rst new file mode 100644 index 00000000000..a8a21770a80 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/physx-iface-cache.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed a crash in :class:`~isaaclab_physx.physics.PhysxManager` when ``omni.physx`` is reloaded during a session. diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index d399d95546b..5059b4f0b8a 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -247,8 +247,6 @@ class PhysxManager(PhysicsManager): _timeline: ClassVar[omni.timeline.ITimeline] = omni.timeline.get_timeline_interface() _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 @@ -351,8 +349,9 @@ def step(cls) -> None: omni.kit.app.get_app().shutdown() return - cls._physx_sim.simulate(sim.cfg.dt, 0.0) - cls._physx_sim.fetch_results() + physx_sim = omni.physx.get_physx_simulation_interface() + physx_sim.simulate(sim.cfg.dt, 0.0) + physx_sim.fetch_results() device = PhysicsManager._device if "cuda" in device: torch.cuda.set_device(device) @@ -415,8 +414,8 @@ def close(cls) -> None: """Clean up physics resources.""" # Detach PhysX from the stage FIRST to prevent shape/actor cleanup errors # This disconnects PhysX from USD before any deletion events are fired - if cls._physx_sim is not None: - cls._physx_sim.detach_stage() + if physx_sim := omni.physx.get_physx_simulation_interface(): + physx_sim.detach_stage() # Pump the app to flush pending PhysX cleanup operations omni.kit.app.get_app().update() @@ -526,9 +525,13 @@ def wrapper(dt: float) -> Any: ): return cls._event_bus.observe_event(event_name=event.value, order=order, on_event=callback) elif event == IsaacEvents.POST_PHYSICS_STEP: - return cls._physx.subscribe_physics_on_step_events(guarded(callback), pre_step=False, order=order) + return omni.physx.get_physx_interface().subscribe_physics_on_step_events( + guarded(callback), pre_step=False, order=order + ) elif event == IsaacEvents.PRE_PHYSICS_STEP: - return cls._physx.subscribe_physics_on_step_events(guarded(callback), pre_step=True, order=order) + return omni.physx.get_physx_interface().subscribe_physics_on_step_events( + guarded(callback), pre_step=True, order=order + ) elif event == IsaacEvents.TIMELINE_STOP: return cls._timeline.get_timeline_event_stream().create_subscription_to_pop_by_type( int(omni.timeline.TimelineEventType.STOP), callback, order=order, name=name @@ -754,19 +757,22 @@ def _warmup_and_create_views(cls) -> None: is_gpu = "cuda" in PhysicsManager.get_device() + physx = omni.physx.get_physx_interface() + physx_sim = omni.physx.get_physx_simulation_interface() + # Attach stage to PhysX BEFORE loading/starting - only needed for GPU pipeline. # For CPU, the old SimulationManager never called attach_stage() explicitly. # Calling attach_stage() + force_load_physics_from_usd() together causes a # double-initialization that corrupts the CPU broadphase (MBP) collision setup, # causing objects to fall through surfaces non-deterministically. if is_gpu: - cls._physx_sim.attach_stage(stage_id) + physx_sim.attach_stage(stage_id) # warmup physx - cls._physx.force_load_physics_from_usd() - cls._physx.start_simulation() - cls._physx.update_simulation(cls.get_physics_dt(), 0.0) - cls._physx_sim.fetch_results() + physx.force_load_physics_from_usd() + physx.start_simulation() + physx.update_simulation(cls.get_physics_dt(), 0.0) + physx_sim.fetch_results() cls._event_bus.dispatch_event(IsaacEvents.PHYSICS_WARMUP.value, payload={}) cls._warmup_needed = False @@ -783,7 +789,7 @@ def _warmup_and_create_views(cls) -> None: cls._view_warp.set_subspace_roots("/") # Final update after view creation - cls._physx.update_simulation(cls.get_physics_dt(), 0.0) + physx.update_simulation(cls.get_physics_dt(), 0.0) cls._view_created = True cls._scene_data_backend.simulation_view = cls._view diff --git a/source/isaaclab_physx/test/assets/test_rigid_object.py b/source/isaaclab_physx/test/assets/test_rigid_object.py index b7f422c4f2f..6454c6b61c5 100644 --- a/source/isaaclab_physx/test/assets/test_rigid_object.py +++ b/source/isaaclab_physx/test/assets/test_rigid_object.py @@ -1273,25 +1273,23 @@ def test_warmup_attach_stage_not_called_for_cpu(): complexity (multiple dynamic actors on a mesh collider), making it unreliable as a unit test assertion. """ - from unittest.mock import MagicMock + from unittest.mock import MagicMock, patch - from isaaclab_physx.physics import PhysxManager + import omni.physx with build_simulation_context(device="cpu", add_ground_plane=True, dt=0.01, auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None generate_cubes_scene(num_cubes=1, height=1.0, device="cpu") + # PhysxManager no longer caches the simulation interface; it resolves it on each use + # via ``omni.physx.get_physx_simulation_interface()`` (the accessor memoizes it). # IPhysxSimulation is a C++ binding whose attributes are read-only, so we cannot - # assign to _physx_sim.attach_stage directly. Instead, replace the class-level - # reference with a MagicMock that wraps the real object so all other calls still - # work, then restore it in the finally block. - original_physx_sim = PhysxManager._physx_sim - spy = MagicMock(wraps=original_physx_sim) - PhysxManager._physx_sim = spy - try: + # assign to ``attach_stage`` directly. Instead, patch the accessor to return a + # MagicMock that wraps the real interface so all other calls still work, and spy on + # ``attach_stage``. + spy = MagicMock(wraps=omni.physx.get_physx_simulation_interface()) + with patch("omni.physx.get_physx_simulation_interface", return_value=spy): sim.reset() - finally: - PhysxManager._physx_sim = original_physx_sim assert spy.attach_stage.call_count == 0, ( f"attach_stage() was called {spy.attach_stage.call_count} time(s) during CPU warmup. " From 503b6a3faf3c7489391880d7e0d23e67b2716319 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Mon, 29 Jun 2026 22:00:06 -0700 Subject: [PATCH 052/139] Updates documentation to mention benign package version conflict errors (#6297) # Description Isaac Lab has some package dependencies that use conflicting versions to what Isaac Sim ships with. Due to the tight pinning of package versions in Isaac Sim, we may not always be able to use the same versions in Isaac Lab as some versions cause issues in our workflows. In most cases, these version conflicts do not impact installation or usage and are safe to ignore if observed during installation. Fixes #6200 ## 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 --- docs/source/refs/troubleshooting.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/source/refs/troubleshooting.rst b/docs/source/refs/troubleshooting.rst index 8fc69767c37..ac916d23894 100644 --- a/docs/source/refs/troubleshooting.rst +++ b/docs/source/refs/troubleshooting.rst @@ -41,6 +41,24 @@ packages. Include ``assets`` in your install command, or use ``./isaaclab.sh -i`` to install everything. +`` requires , but requires `` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +During pip or uv installs, the package manager may print dependency warnings +where an Isaac Lab package, Isaac Sim package, or third-party package declares +an incompatible dependency constraint. Common examples include ``coverage``, +``packaging``, ``numpy``, or ``Pillow`` constraints reported between +``isaaclab``, ``isaacsim-kernel``, ``isaacsim-core``, ``nvidia-srl-usd``, and +``moviepy``. + +These messages are generally benign when the install command completes +successfully. They usually reflect package metadata that is stricter or older +than the versions bundled and tested with Isaac Sim. Prefer starting from a +fresh virtual environment and using the installation commands in the Isaac Lab +docs. If the resolver aborts with ``No solution found`` or installation leaves +missing modules at runtime, recreate the environment and install the documented +Isaac Sim version before installing Isaac Lab. + ``ModuleNotFoundError: No module named 'rsl_rl'`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 18f1963ecfdbd517ce95332351cbf82cc1403e95 Mon Sep 17 00:00:00 2001 From: Piotr Barejko Date: Mon, 29 Jun 2026 23:02:05 -0700 Subject: [PATCH 053/139] Remove RenderCfg from SimulationCfg and rely on new Global Settings (#6279) --- docs/source/api/lab/isaaclab.sim.rst | 6 - docs/source/how-to/configure_rendering.rst | 44 +- .../teleoperation/teleop_replay_agent.py | 8 +- .../teleoperation/teleop_se3_agent.py | 8 +- scripts/tools/record_demos.py | 8 +- .../tutorials/00_sim/set_rendering_mode.py | 18 +- ...arejko-isaac-rtx-global-settings.major.rst | 7 + source/isaaclab/isaaclab/app/app_launcher.py | 3 +- .../isaaclab/renderers/render_context.py | 15 + source/isaaclab/isaaclab/sim/__init__.pyi | 3 +- .../isaaclab/isaaclab/sim/simulation_cfg.py | 209 +--------- .../isaaclab/sim/simulation_context.py | 103 ----- .../test/sim/test_simulation_render_config.py | 392 +++++++----------- ...arejko-isaac-rtx-global-settings.minor.rst | 6 + .../renderers/isaac_rtx_renderer.py | 10 +- .../renderers/isaac_rtx_renderer_cfg.py | 87 +++- .../renderers/isaac_rtx_renderer_utils.py | 121 ++++++ .../pbarejko-isaac-rtx-global-settings.skip | 1 + .../assemble_trocar/g129_dex3_env_cfg.py | 19 +- .../exhaustpipe_gr1t2_base_env_cfg.py | 4 +- .../pick_place/nutpour_gr1t2_base_env_cfg.py | 4 +- .../stack_ik_rel_visuomotor_cosmos_env_cfg.py | 11 +- .../franka/stack_ik_rel_visuomotor_env_cfg.py | 4 +- .../config/galbot/stack_rmp_rel_env_cfg.py | 13 +- .../isaaclab_tasks/utils/presets.py | 19 + 25 files changed, 516 insertions(+), 607 deletions(-) create mode 100644 source/isaaclab/changelog.d/pbarejko-isaac-rtx-global-settings.major.rst create mode 100644 source/isaaclab_physx/changelog.d/pbarejko-isaac-rtx-global-settings.minor.rst create mode 100644 source/isaaclab_tasks/changelog.d/pbarejko-isaac-rtx-global-settings.skip diff --git a/docs/source/api/lab/isaaclab.sim.rst b/docs/source/api/lab/isaaclab.sim.rst index 96816b6a522..54a505845a1 100644 --- a/docs/source/api/lab/isaaclab.sim.rst +++ b/docs/source/api/lab/isaaclab.sim.rst @@ -18,7 +18,6 @@ isaaclab.sim SimulationContext SimulationCfg - RenderCfg .. rubric:: Functions @@ -41,11 +40,6 @@ Simulation Configuration :show-inheritance: :exclude-members: __init__ -.. autoclass:: RenderCfg - :members: - :show-inheritance: - :exclude-members: __init__ - Simulation Context Builder -------------------------- diff --git a/docs/source/how-to/configure_rendering.rst b/docs/source/how-to/configure_rendering.rst index 78b8cdea9da..f1cfd41ad59 100644 --- a/docs/source/how-to/configure_rendering.rst +++ b/docs/source/how-to/configure_rendering.rst @@ -19,15 +19,27 @@ Selecting a Rendering Mode Rendering modes can be selected in 2 ways. -1. using the ``rendering_mode`` input class argument in :class:`~sim.RenderCfg` +1. using the ``rendering_mode`` field in + :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` .. code-block:: python # for an example of how this can be used, checkout the tutorial script # scripts/tutorials/00_sim/set_rendering_mode.py - render_cfg = sim_utils.RenderCfg(rendering_mode="performance") + from isaaclab_physx.renderers import IsaacRtxRendererGlobalSettingsCfg -2. using the ``--rendering_mode`` CLI argument, which takes precedence over the ``rendering_mode`` argument in :class:`~sim.RenderCfg`. + global_settings = IsaacRtxRendererGlobalSettingsCfg(rendering_mode="performance") + + .. note:: + + Existing users of ``sim.RenderCfg`` and ``SimulationCfg.render`` should + move Isaac RTX quality settings to + :attr:`~isaaclab_physx.renderers.IsaacRtxRendererCfg.global_settings`. + These settings are process-global and apply only when cameras use + :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg`; Newton Warp and + OVRTX use their own renderer-specific configuration instead. + +2. using the ``--rendering_mode`` CLI argument. .. code-block:: bash @@ -63,16 +75,19 @@ To help assess rendering, the example scene includes some reflections, transluce Overwriting Specific Rendering Settings --------------------------------------- -Preset rendering settings can be overwritten via the :class:`~sim.RenderCfg` class. +Preset rendering settings can be overwritten via +:class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg`. There are 2 ways to provide settings that overwrite presets. -1. :class:`~sim.RenderCfg` supports overwriting specific settings via user-friendly setting names that map to underlying RTX settings. +1. :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` + supports overwriting specific settings via user-friendly setting names that + map to underlying RTX settings. For example: .. code-block:: python - render_cfg = sim_utils.RenderCfg( + global_settings = IsaacRtxRendererGlobalSettingsCfg( rendering_mode="performance", # user friendly setting overwrites enable_translucency=True, # defaults to False in performance mode @@ -128,7 +143,10 @@ There are 2 ways to provide settings that overwrite presets. +----------------------------+--------------------------------------------------------------------------+ -2. For more control, :class:`~sim.RenderCfg` allows you to overwrite any RTX setting by using the ``carb_settings`` argument. +2. For more control, + :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` + allows you to overwrite any RTX setting by using the ``carb_settings`` + argument. Examples of RTX settings can be found from within the repo, in the render mode preset files located in ``apps/rendering_modes``. @@ -139,7 +157,7 @@ There are 2 ways to provide settings that overwrite presets. .. code-block:: python - render_cfg = sim_utils.RenderCfg( + global_settings = IsaacRtxRendererGlobalSettingsCfg( rendering_mode="quality", # carb setting overwrites carb_settings={ @@ -156,9 +174,11 @@ Current Limitations For performance reasons, we default to using DLSS for denoising, which generally provides better performance. This may result in renders of lower quality, which may be especially evident at lower resolutions. Due to this, we recommend using per-tile or per-camera resolution of at least 100 x 100. -For renders at lower resolutions, we advice setting the ``antialiasing_mode`` attribute in :class:`~sim.RenderCfg` to +For renders at lower resolutions, we advice setting the ``antialiasing_mode`` attribute in +:class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` to ``DLAA``, and also potentially enabling ``enable_dl_denoiser``. Both of these settings should help improve render -quality, but also comes at a cost of performance. Additional rendering parameters can also be specified in :class:`~sim.RenderCfg`. +quality, but also comes at a cost of performance. Additional rendering parameters can also be specified in +:class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg`. If you observe visual artifacts such as ghosting or disocclusion issues when using tiled rendering, you can try @@ -167,7 +187,7 @@ areas that become newly visible between frames: .. code-block:: python - render_cfg = sim_utils.RenderCfg( + global_settings = IsaacRtxRendererGlobalSettingsCfg( carb_settings={ "/rtx/aovConverter/disocclusionScale": 10000, } @@ -189,7 +209,7 @@ in **multiple environments**, you must set the following so the renderer uses th .. code-block:: python - render_cfg = sim_utils.RenderCfg( + global_settings = IsaacRtxRendererGlobalSettingsCfg( carb_settings={ "omni.rtx.nre.compositing.rendererHints": 3, } diff --git a/scripts/environments/teleoperation/teleop_replay_agent.py b/scripts/environments/teleoperation/teleop_replay_agent.py index d650e271404..8dac111c4b9 100644 --- a/scripts/environments/teleoperation/teleop_replay_agent.py +++ b/scripts/environments/teleoperation/teleop_replay_agent.py @@ -378,6 +378,10 @@ import gymnasium as gym import torch +from isaaclab_physx.renderers import IsaacRtxRendererGlobalSettingsCfg +from isaaclab_physx.renderers.isaac_rtx_renderer_utils import ( + apply_isaac_rtx_global_settings, +) from isaaclab_teleop import IsaacTeleopDevice, create_isaac_teleop_device, poll_control_events from isaaclab.devices.openxr import remove_camera_configs @@ -1038,7 +1042,9 @@ def _prepare_env_cfg(task: str, num_envs: int, device: str) -> tuple[ManagerBase 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" + apply_isaac_rtx_global_settings( + IsaacRtxRendererGlobalSettingsCfg(antialiasing_mode="DLSS"), + ) return env_cfg, success_term diff --git a/scripts/environments/teleoperation/teleop_se3_agent.py b/scripts/environments/teleoperation/teleop_se3_agent.py index f7db0a0fbd4..df738a02bd5 100644 --- a/scripts/environments/teleoperation/teleop_se3_agent.py +++ b/scripts/environments/teleoperation/teleop_se3_agent.py @@ -89,6 +89,10 @@ import gymnasium as gym import torch +from isaaclab_physx.renderers import IsaacRtxRendererGlobalSettingsCfg +from isaaclab_physx.renderers.isaac_rtx_renderer_utils import ( + apply_isaac_rtx_global_settings, +) from isaaclab.devices import Se3Gamepad, Se3GamepadCfg, Se3Keyboard, Se3KeyboardCfg, Se3SpaceMouse, Se3SpaceMouseCfg from isaaclab.devices.openxr import remove_camera_configs @@ -168,7 +172,9 @@ def main() -> None: if use_isaac_teleop or args_cli.xr: env_cfg = remove_camera_configs(env_cfg) - env_cfg.sim.render.antialiasing_mode = "DLSS" + apply_isaac_rtx_global_settings( + IsaacRtxRendererGlobalSettingsCfg(antialiasing_mode="DLSS"), + ) try: # create environment diff --git a/scripts/tools/record_demos.py b/scripts/tools/record_demos.py index dbfdec95c30..48932746fd8 100644 --- a/scripts/tools/record_demos.py +++ b/scripts/tools/record_demos.py @@ -131,6 +131,10 @@ import gymnasium as gym import torch +from isaaclab_physx.renderers import IsaacRtxRendererGlobalSettingsCfg +from isaaclab_physx.renderers.isaac_rtx_renderer_utils import ( + apply_isaac_rtx_global_settings, +) import omni.ui as ui @@ -276,7 +280,9 @@ def create_environment_config( # If cameras are not enabled and XR is enabled, remove camera configs if not args_cli.enable_cameras: env_cfg = remove_camera_configs(env_cfg) - env_cfg.sim.render.antialiasing_mode = "DLSS" + apply_isaac_rtx_global_settings( + IsaacRtxRendererGlobalSettingsCfg(antialiasing_mode="DLSS"), + ) # modify configuration such that the environment runs indefinitely until # the goal is reached or other termination conditions are met diff --git a/scripts/tutorials/00_sim/set_rendering_mode.py b/scripts/tutorials/00_sim/set_rendering_mode.py index d0feb87dee3..9e34011549b 100644 --- a/scripts/tutorials/00_sim/set_rendering_mode.py +++ b/scripts/tutorials/00_sim/set_rendering_mode.py @@ -35,7 +35,13 @@ """Rest everything follows.""" +from isaaclab_physx.renderers import IsaacRtxRendererGlobalSettingsCfg +from isaaclab_physx.renderers.isaac_rtx_renderer_utils import ( + apply_isaac_rtx_global_settings, +) + import isaaclab.sim as sim_utils +from isaaclab.app.settings_manager import get_settings_manager from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR @@ -58,14 +64,16 @@ def main(): # carb setting dictionary can include any rtx carb setting which will overwrite the native preset setting carb_settings = {"rtx.reflections.enabled": True} - # Initialize render config - render_cfg = sim_utils.RenderCfg( - rendering_mode=rendering_mode, + settings = get_settings_manager() + # Initialize and apply Isaac RTX global settings. + render_cfg = IsaacRtxRendererGlobalSettingsCfg( + rendering_mode=settings.get("/isaaclab/rendering/rendering_mode") or rendering_mode, carb_settings=carb_settings, ) + apply_isaac_rtx_global_settings(render_cfg, settings) - # Initialize the simulation context with render coofig - sim_cfg = sim_utils.SimulationCfg(render=render_cfg) + # Initialize the simulation context. + sim_cfg = sim_utils.SimulationCfg() sim = sim_utils.SimulationContext(sim_cfg) # Pose camera in the hospital lobby area diff --git a/source/isaaclab/changelog.d/pbarejko-isaac-rtx-global-settings.major.rst b/source/isaaclab/changelog.d/pbarejko-isaac-rtx-global-settings.major.rst new file mode 100644 index 00000000000..5e407688cac --- /dev/null +++ b/source/isaaclab/changelog.d/pbarejko-isaac-rtx-global-settings.major.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* **Breaking:** Removed ``RenderCfg`` and ``SimulationCfg.render``. Configure + Isaac RTX quality settings through + :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` on + :attr:`~isaaclab_physx.renderers.IsaacRtxRendererCfg.global_settings` instead. diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index ab70e000f42..99215b696af 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -567,7 +567,8 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: help=( "Sets the rendering mode. Preset settings files can be found in apps/rendering_modes." ' Can be "performance", "balanced", or "quality".' - " Individual settings can be overwritten by using the RenderCfg class." + " Individual Isaac RTX settings can be overwritten through " + "IsaacRtxRendererCfg.global_settings." ), ) arg_group.add_argument( diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py index a6e49883350..fecea42d2a6 100644 --- a/source/isaaclab/isaaclab/renderers/render_context.py +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -46,6 +46,20 @@ def __init__(self) -> None: self._prepared_num_envs: int | None = None self._last_transforms_step: int | None = None + def _check_global_settings_compatible(self, cfg: RendererCfg) -> None: + """Reject conflicting process-global renderer settings.""" + if getattr(cfg, "renderer_type", None) != "isaac_rtx" or not hasattr(cfg, "global_settings"): + return + for stored_cfg, _renderer in self._renderer_entries: + if getattr(stored_cfg, "renderer_type", None) != "isaac_rtx" or not hasattr(stored_cfg, "global_settings"): + continue + if stored_cfg.global_settings != cfg.global_settings: + raise ValueError( + "Isaac RTX global settings differ across camera renderer configs. " + "These settings are process-global; configure the same " + "IsaacRtxRendererCfg.global_settings for every Isaac RTX camera." + ) + def get_renderer(self, cfg: RendererCfg) -> BaseRenderer: """Return a backend for this configuration, reusing a matching instance if present. @@ -58,6 +72,7 @@ def get_renderer(self, cfg: RendererCfg) -> BaseRenderer: Returns: A shared or newly created renderer backend. """ + self._check_global_settings_compatible(cfg) for stored_cfg, r in self._renderer_entries: if type(stored_cfg) is type(cfg) and stored_cfg == cfg: return r diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index d8d4e1beda2..38eef8c6f5e 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -4,7 +4,6 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ - "RenderCfg", "SimulationCfg", "SimulationContext", "build_simulation_context", @@ -288,7 +287,7 @@ from .schemas import ( modify_rigid_body_properties, modify_spatial_tendon_properties, ) -from .simulation_cfg import RenderCfg, SimulationCfg +from .simulation_cfg import SimulationCfg from .simulation_context import SimulationContext, build_simulation_context # Forwarded to isaaclab_newton.sim.schemas via __getattr__ shim diff --git a/source/isaaclab/isaaclab/sim/simulation_cfg.py b/source/isaaclab/isaaclab/sim/simulation_cfg.py index 2c71c724ad3..8fb93eae989 100644 --- a/source/isaaclab/isaaclab/sim/simulation_cfg.py +++ b/source/isaaclab/isaaclab/sim/simulation_cfg.py @@ -11,7 +11,7 @@ from __future__ import annotations -from typing import Any, Literal # Literal used by RenderCfg +from typing import Literal from isaaclab.physics import PhysicsCfg from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg @@ -19,210 +19,6 @@ from isaaclab.visualizers import VisualizerCfg -@configclass -class RenderCfg: - """Configuration for Omniverse RTX Renderer. - - These parameters are used to configure the Omniverse RTX Renderer. - - The defaults for IsaacLab are set in the experience files: - - * ``apps/isaaclab.python.rendering.kit``: Setting used when running the simulation with the GUI enabled. - * ``apps/isaaclab.python.headless.rendering.kit``: Setting used when running the simulation in headless mode. - - Setting any value here will override the defaults of the experience files. - - For more information, see the `Omniverse RTX Renderer documentation`_. - - .. _Omniverse RTX Renderer documentation: https://docs.omniverse.nvidia.com/materials-and-rendering/latest/rtx-renderer.html - """ - - enable_translucency: bool | None = None - """Enables translucency for specular transmissive surfaces such as glass. - - This comes at the cost of some performance. Default is False. - This is set by the variable: ``/rtx/translucency/enabled``. - """ - - enable_reflections: bool | None = None - """Enables reflections at the cost of some performance. Default is False. - - This is set by the variable: ``/rtx/reflections/enabled``. - """ - - enable_global_illumination: bool | None = None - """Enables Diffused Global Illumination at the cost of some performance. Default is False. - - This is set by the variable: ``/rtx/indirectDiffuse/enabled``. - """ - - antialiasing_mode: Literal["Off", "FXAA", "DLSS", "TAA", "DLAA"] | None = None - """Selects the anti-aliasing mode to use. Defaults to DLSS. - - - **DLSS**: Boosts performance by using AI to output higher resolution frames from a lower resolution input. - DLSS samples multiple lower resolution images and uses motion data and feedback from prior frames to reconstruct - native quality images. - - **DLAA**: Provides higher image quality with an AI-based anti-aliasing technique. DLAA uses the same - Super Resolution technology developed for DLSS, reconstructing a native resolution image to maximize - image quality. - - This is set by the variable: ``/rtx/post/dlss/execMode``. - """ - - enable_dlssg: bool | None = None - """"Enables the use of DLSS-G. Default is False. - - DLSS Frame Generation boosts performance by using AI to generate more frames. DLSS analyzes sequential frames - and motion data to create additional high quality frames. - - .. note:: - - This feature requires an Ada Lovelace architecture GPU. Enabling this feature also enables additional - thread-related activities, which can hurt performance. - - This is set by the variable: ``/rtx-transient/dlssg/enabled``. - """ - - enable_dl_denoiser: bool | None = None - """Enables the use of a DL denoiser. - - The DL denoiser can help improve the quality of renders, but comes at a cost of performance. - - This is set by the variable: ``/rtx-transient/dldenoiser/enabled``. - """ - - dlss_mode: Literal[0, 1, 2, 3] | None = None - """For DLSS anti-aliasing, selects the performance/quality tradeoff mode. Default is 0. - - Valid values are: - - * 0 (Performance) - * 1 (Balanced) - * 2 (Quality) - * 3 (Auto) - - This is set by the variable: ``/rtx/post/dlss/execMode``. - """ - - enable_direct_lighting: bool | None = None - """Enable direct light contributions from lights. Default is False. - - This is set by the variable: ``/rtx/directLighting/enabled``. - """ - - samples_per_pixel: int | None = None - """Defines the Direct Lighting samples per pixel. Default is 1. - - A higher value increases the direct lighting quality at the cost of performance. - - This is set by the variable: ``/rtx/directLighting/sampledLighting/samplesPerPixel``. - """ - - enable_shadows: bool | None = None - """Enables shadows at the cost of performance. Defaults to True. - - When disabled, lights will not cast shadows. - - This is set by the variable: ``/rtx/shadows/enabled``. - """ - - enable_ambient_occlusion: bool | None = None - """Enables ambient occlusion at the cost of some performance. Default is False. - - This is set by the variable: ``/rtx/ambientOcclusion/enabled``. - """ - - dome_light_upper_lower_strategy: Literal[0, 3, 4] | None = None - """Selects how to sample the Dome Light. Default is 0. - For more information, refer to the `documentation`_. - - .. _documentation: https://docs.omniverse.nvidia.com/materials-and-rendering/latest/rtx-renderer_common.html#dome-light - - Valid values are: - - * 0: **Image-Based Lighting (IBL)** - Most accurate even for high-frequency Dome Light textures. - Can introduce sampling artifacts in real-time mode. - * 3: **Limited Image-Based Lighting** - Only sampled for reflection and refraction. Fastest, but least - accurate. Good for cases where the Dome Light contributes less than other light sources. - * 4: **Approximated Image-Based Lighting** - Fast and artifacts-free sampling in real-time mode but only - works well with a low-frequency texture (e.g., a sky with no sun disc where the sun is instead a separate - Distant Light). Requires enabling Direct Lighting denoiser. - - 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. - - The keys of the dictionary can be formatted like a carb setting, .kit file setting, or python variable. - For instance, a key value pair can be ``/rtx/translucency/enabled: False`` (carb), - ``rtx.translucency.enabled: False`` (.kit), or ``rtx_translucency_enabled: False`` (python). - """ - - rendering_mode: Literal["performance", "balanced", "quality"] | None = None - """The rendering mode. - - This behaves the same as the passing the CLI arg ``--rendering_mode`` to an executable script. - """ - - @configclass class SimulationCfg: """Configuration for simulation physics. @@ -311,9 +107,6 @@ class SimulationCfg: a different config (e.g., NewtonManagerCfg) to use a different physics backend. """ - render: RenderCfg = RenderCfg() - """Render settings. Default is RenderCfg().""" - create_stage_in_memory: bool = False """If stage is first created in memory. Default is False. diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index bf069b6a14a..ef0a43d77cc 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -7,14 +7,12 @@ import gc import logging -import os import traceback from collections.abc import Iterator from contextlib import contextmanager from dataclasses import fields from typing import TYPE_CHECKING, Any -import tomllib import torch import isaaclab.sim as sim_utils @@ -175,7 +173,6 @@ def __init__(self, cfg: SimulationCfg | None = None): self.cfg.physics = self._physics self.physics_manager: type[PhysicsManager] = self._physics.class_type self.physics_manager.initialize(self) - self._apply_render_cfg_settings() # Initialize visualizer state (visualizers are created lazily during initialize_visualizers()). self._scene_data_provider = SceneDataProvider(self.physics_manager.get_scene_data_backend()) @@ -222,106 +219,6 @@ def __init__(self, cfg: SimulationCfg | None = None): type(self)._instance = self # Mark as valid singleton only after successful init - def _apply_render_cfg_settings(self) -> None: - """Apply render preset and overrides from SimulationCfg.render.""" - # TODO: Refactor render preset + override handling to a dedicated RenderingQualityCfg - # (name subject to change) to keep quality profiles and carb mappings centralized. - render_cfg = getattr(self.cfg, "render", None) - if render_cfg is None: - return - - # Priority: - # 1) CLI/AppLauncher setting if present, 2) SimulationCfg.render.rendering_mode. - rendering_mode = self.get_setting("/isaaclab/rendering/rendering_mode") - if not rendering_mode: - rendering_mode = getattr(render_cfg, "rendering_mode", None) - - if rendering_mode: - supported_rendering_modes = {"performance", "balanced", "quality"} - if rendering_mode not in supported_rendering_modes: - raise ValueError( - f"RenderCfg rendering mode '{rendering_mode}' not in supported modes " - f"{sorted(supported_rendering_modes)}." - ) - - isaaclab_app_exp_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), *[".."] * 4, "apps") - from isaaclab.utils.version import get_isaac_sim_version - - if get_isaac_sim_version().major < 6: - isaaclab_app_exp_path = os.path.join(isaaclab_app_exp_path, "isaacsim_5") - - preset_filename = os.path.join(isaaclab_app_exp_path, f"rendering_modes/{rendering_mode}.kit") - if os.path.exists(preset_filename): - with open(preset_filename, "rb") as file: - preset_dict = tomllib.load(file) - - def _apply_nested(data: dict[str, Any], path: str = "") -> None: - for key, value in data.items(): - key_path = f"{path}/{key}" if path else f"/{key}" - if isinstance(value, dict): - _apply_nested(value, key_path) - else: - self.set_setting(key_path.replace(".", "/"), value) - - _apply_nested(preset_dict) - else: - logger.warning("[SimulationContext] Render preset file not found: %s", preset_filename) - - # RenderCfg fields mapped to setting paths (stored via SettingsManager) - field_to_setting = { - "enable_translucency": "/rtx/translucency/enabled", - "enable_reflections": "/rtx/reflections/enabled", - "enable_global_illumination": "/rtx/indirectDiffuse/enabled", - "enable_dlssg": "/rtx-transient/dlssg/enabled", - "enable_dl_denoiser": "/rtx-transient/dldenoiser/enabled", - "dlss_mode": "/rtx/post/dlss/execMode", - "enable_direct_lighting": "/rtx/directLighting/enabled", - "samples_per_pixel": "/rtx/directLighting/sampledLighting/samplesPerPixel", - "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(): - if value is None or key in {"rendering_mode", "carb_settings", "antialiasing_mode"}: - continue - setting_path = field_to_setting.get(key) - if setting_path is not None: - self.set_setting(setting_path, value) - - # Raw overrides from render_cfg (stored via SettingsManager) - extra_settings = getattr(render_cfg, "carb_settings", None) - if extra_settings: - for key, value in extra_settings.items(): - if "_" in key: - path = "/" + key.replace("_", "/") - elif "." in key: - path = "/" + key.replace(".", "/") - else: - path = key - self.set_setting(path, value) - - # Optional anti-aliasing mode via Replicator (best-effort, may use Omniverse APIs) - antialiasing_mode = getattr(render_cfg, "antialiasing_mode", None) - if antialiasing_mode is not None: - try: - import omni.replicator.core as rep - - rep.settings.set_render_rtx_realtime(antialiasing=antialiasing_mode) - except Exception: - pass - def _init_usd_physics_scene(self) -> None: """Create and configure the USD physics scene.""" from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 diff --git a/source/isaaclab/test/sim/test_simulation_render_config.py b/source/isaaclab/test/sim/test_simulation_render_config.py index 8699a874da9..9cc4c503347 100644 --- a/source/isaaclab/test/sim/test_simulation_render_config.py +++ b/source/isaaclab/test/sim/test_simulation_render_config.py @@ -3,174 +3,172 @@ # # SPDX-License-Identifier: BSD-3-Clause +"""Tests for Isaac RTX global render settings.""" -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True, enable_cameras=True).app - - -"""Rest everything follows.""" +from __future__ import annotations +import importlib import os +import sys +import types -import flatdict -import pytest import tomllib - -from isaaclab.app.settings_manager import get_settings_manager -from isaaclab.sim.simulation_cfg import RenderCfg, SimulationCfg -from isaaclab.sim.simulation_context import SimulationContext -from isaaclab.utils.version import get_isaac_sim_version - - -@pytest.mark.skip(reason="Timeline not stopped") -@pytest.mark.isaacsim_ci -def test_render_cfg(): - """Test that the simulation context is created with the correct render cfg.""" - enable_translucency = True - enable_reflections = True - enable_global_illumination = True - antialiasing_mode = "DLAA" - enable_dlssg = True - enable_dl_denoiser = True - dlss_mode = 0 - enable_direct_lighting = True - 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, - enable_reflections=enable_reflections, - enable_global_illumination=enable_global_illumination, - antialiasing_mode=antialiasing_mode, - enable_dlssg=enable_dlssg, - dlss_mode=dlss_mode, - enable_dl_denoiser=enable_dl_denoiser, - enable_direct_lighting=enable_direct_lighting, - 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, +from isaaclab_physx.renderers.isaac_rtx_renderer_cfg import ( + IsaacRtxRendererGlobalSettingsCfg, +) +from packaging.version import Version + + +class _FakeSettings: + """Small settings manager double used by the Isaac RTX settings helper.""" + + def __init__(self): + self.values = {} + + def get(self, name: str): + return self.values.get(name) + + def set(self, name: str, value): + self.values[name] = value + + +class _FakeReplicatorSettings: + """Replicator settings double for anti-aliasing calls.""" + + def __init__(self): + self.antialiasing = None + + def set_render_rtx_realtime(self, antialiasing): + self.antialiasing = antialiasing + + +def _install_omni_stubs(monkeypatch) -> _FakeReplicatorSettings: + """Install the Omniverse modules imported by the Isaac RTX utility.""" + omni_mod = types.ModuleType("omni") + omni_mod.__path__ = [] + usd_mod = types.ModuleType("omni.usd") + replicator_mod = types.ModuleType("omni.replicator") + core_mod = types.ModuleType("omni.replicator.core") + + rep_settings = _FakeReplicatorSettings() + setattr(core_mod, "settings", rep_settings) + setattr(omni_mod, "usd", usd_mod) + setattr(omni_mod, "replicator", replicator_mod) + setattr(replicator_mod, "core", core_mod) + + monkeypatch.setitem(sys.modules, "omni", omni_mod) + monkeypatch.setitem(sys.modules, "omni.usd", usd_mod) + monkeypatch.setitem(sys.modules, "omni.replicator", replicator_mod) + monkeypatch.setitem(sys.modules, "omni.replicator.core", core_mod) + return rep_settings + + +def _import_isaac_rtx_utils(monkeypatch): + """Import Isaac RTX utilities after installing Kit/Replicator stubs.""" + _install_omni_stubs(monkeypatch) + return importlib.import_module("isaaclab_physx.renderers.isaac_rtx_renderer_utils") + + +def _flatten_preset(data: dict, prefix: str = "") -> dict[str, object]: + """Flatten nested preset dictionaries using dot-separated keys.""" + flattened = {} + for key, value in data.items(): + key_path = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + flattened.update(_flatten_preset(value, key_path)) + else: + flattened[key_path] = value + return flattened + + +def test_isaac_rtx_global_settings(monkeypatch): + """Test that Isaac RTX global settings are applied by the helper.""" + rep_settings = _install_omni_stubs(monkeypatch) + utils = importlib.import_module("isaaclab_physx.renderers.isaac_rtx_renderer_utils") + settings = _FakeSettings() + monkeypatch.setattr(utils, "get_settings_manager", lambda: settings) + global_settings = IsaacRtxRendererGlobalSettingsCfg( + enable_translucency=True, + enable_reflections=True, + enable_global_illumination=True, + antialiasing_mode="DLAA", + enable_dlssg=True, + enable_dl_denoiser=True, + dlss_mode=0, + enable_direct_lighting=True, + samples_per_pixel=4, + enable_shadows=True, + enable_ambient_occlusion=True, + 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, ) - cfg = SimulationCfg(render=render_cfg) - - # FIXME: when running all tests, the timeline is not stopped, force stop it here but also that does not the timeline - # omni.timeline.get_timeline_interface().stop() - - sim = SimulationContext(cfg) - - assert sim.cfg.render.enable_translucency == enable_translucency - assert sim.cfg.render.enable_reflections == enable_reflections - assert sim.cfg.render.enable_global_illumination == enable_global_illumination - assert sim.cfg.render.antialiasing_mode == antialiasing_mode - assert sim.cfg.render.enable_dlssg == enable_dlssg - assert sim.cfg.render.dlss_mode == dlss_mode - assert sim.cfg.render.enable_dl_denoiser == enable_dl_denoiser - assert sim.cfg.render.enable_direct_lighting == enable_direct_lighting - 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 - assert sim.get_setting("/rtx/indirectDiffuse/enabled") == sim.cfg.render.enable_global_illumination - assert sim.get_setting("/rtx-transient/dlssg/enabled") == sim.cfg.render.enable_dlssg - assert sim.get_setting("/rtx-transient/dldenoiser/enabled") == sim.cfg.render.enable_dl_denoiser - assert sim.get_setting("/rtx/post/dlss/execMode") == sim.cfg.render.dlss_mode - assert sim.get_setting("/rtx/directLighting/enabled") == sim.cfg.render.enable_direct_lighting - 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 - - -@pytest.mark.isaacsim_ci -def test_render_cfg_presets(): - """Test that the simulation context is created with the correct render cfg preset with overrides.""" - - # carb setting dictionary overrides - carb_settings = {"/rtx/raytracing/subpixel/mode": 3, "/rtx/pathtracing/maxSamplesPerLaunch": 999999} - # user-friendly setting overrides + utils.apply_isaac_rtx_global_settings(global_settings) + + assert settings.get("/rtx/translucency/enabled") is True + assert settings.get("/rtx/reflections/enabled") is True + assert settings.get("/rtx/indirectDiffuse/enabled") is True + assert settings.get("/rtx-transient/dlssg/enabled") is True + assert settings.get("/rtx-transient/dldenoiser/enabled") is True + assert settings.get("/rtx/post/dlss/execMode") == 0 + assert settings.get("/rtx/directLighting/enabled") is True + assert settings.get("/rtx/directLighting/sampledLighting/samplesPerPixel") == 4 + assert settings.get("/rtx/shadows/enabled") is True + assert settings.get("/rtx/ambientOcclusion/enabled") is True + assert settings.get("/rtx/rtpt/maxBounces") == 4 + assert settings.get("/rtx/rtpt/splitGlass") is True + assert settings.get("/rtx/rtpt/splitClearcoat") is True + assert settings.get("/rtx/rtpt/splitRoughReflection") is True + assert settings.get("/rtx/sceneDb/ambientLightIntensity") == 0.5 + assert settings.get("/rtx/ambientOcclusion/denoiserMode") == 0 + assert settings.get("/rtx/raytracing/subpixel/mode") == 1 + assert settings.get("/rtx/raytracing/cached/enabled") is True + assert settings.get("/rtx/pathtracing/maxSamplesPerLaunch") == 500000 + assert settings.get("/rtx/viewTile/limit") == 500000 + assert rep_settings.antialiasing == "DLAA" + + +def test_isaac_rtx_global_settings_presets(monkeypatch): + """Test that Isaac RTX rendering-mode presets apply before overrides.""" + utils = _import_isaac_rtx_utils(monkeypatch) + import isaaclab.utils.version as version_utils + + monkeypatch.setattr(version_utils, "get_isaac_sim_version", lambda: Version("6.0.0")) + + carb_settings = { + "/rtx/raytracing/subpixel/mode": 3, + "/rtx/pathtracing/maxSamplesPerLaunch": 999999, + } dlss_mode = ("/rtx/post/dlss/execMode", 5) rendering_modes = ["performance", "balanced", "quality"] for rendering_mode in rendering_modes: - # Clear any existing simulation context before creating a new one - SimulationContext.clear_instance() - - # grab isaac lab apps path + settings = _FakeSettings() + monkeypatch.setattr(utils, "get_settings_manager", lambda: settings) isaaclab_app_exp_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), *[".."] * 4, "apps") - # for Isaac Sim 5 compatibility, we use the 5 rendering mode app files in a different folder - if get_isaac_sim_version().major < 6: - isaaclab_app_exp_path = os.path.join(isaaclab_app_exp_path, "isaacsim_5") - - # grab preset settings preset_filename = os.path.join(isaaclab_app_exp_path, f"rendering_modes/{rendering_mode}.kit") with open(preset_filename, "rb") as file: preset_dict = tomllib.load(file) - preset_dict = dict(flatdict.FlatDict(preset_dict, delimiter=".")) + preset_dict = _flatten_preset(preset_dict) - render_cfg = RenderCfg( + global_settings = IsaacRtxRendererGlobalSettingsCfg( rendering_mode=rendering_mode, dlss_mode=dlss_mode[1], carb_settings=carb_settings, ) + utils.apply_isaac_rtx_global_settings(global_settings) - cfg = SimulationCfg(render=render_cfg) - - SimulationContext(cfg) - - settings = get_settings_manager() for key, val in preset_dict.items(): - setting_name = "/" + key.replace(".", "/") # convert to setting path format - + setting_name = "/" + key.replace(".", "/") if setting_name in carb_settings: setting_gt = carb_settings[setting_name] elif setting_name == dlss_mode[0]: @@ -184,107 +182,3 @@ def test_render_cfg_presets(): f"Mismatch for '{setting_name}' in mode '{rendering_mode}': " f"expected {setting_gt!r}, got {setting_val!r}" ) - - # Clean up after the test - SimulationContext.clear_instance() - - -@pytest.mark.skip(reason="Timeline not stopped") -@pytest.mark.isaacsim_ci -def test_render_cfg_defaults(): - """Test that the simulation context is created with the correct render cfg.""" - enable_translucency = False - enable_reflections = False - enable_global_illumination = False - antialiasing_mode = "DLSS" - enable_dlssg = False - enable_dl_denoiser = False - dlss_mode = 2 - enable_direct_lighting = False - 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, - enable_reflections=enable_reflections, - enable_global_illumination=enable_global_illumination, - antialiasing_mode=antialiasing_mode, - enable_dlssg=enable_dlssg, - enable_dl_denoiser=enable_dl_denoiser, - dlss_mode=dlss_mode, - enable_direct_lighting=enable_direct_lighting, - 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) - - sim = SimulationContext(cfg) - - assert sim.cfg.render.enable_translucency == enable_translucency - assert sim.cfg.render.enable_reflections == enable_reflections - assert sim.cfg.render.enable_global_illumination == enable_global_illumination - assert sim.cfg.render.antialiasing_mode == antialiasing_mode - assert sim.cfg.render.enable_dlssg == enable_dlssg - assert sim.cfg.render.enable_dl_denoiser == enable_dl_denoiser - assert sim.cfg.render.dlss_mode == dlss_mode - assert sim.cfg.render.enable_direct_lighting == enable_direct_lighting - 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 - assert sim.get_setting("/rtx/indirectDiffuse/enabled") == sim.cfg.render.enable_global_illumination - assert sim.get_setting("/rtx-transient/dlssg/enabled") == sim.cfg.render.enable_dlssg - assert sim.get_setting("/rtx-transient/dldenoiser/enabled") == sim.cfg.render.enable_dl_denoiser - assert sim.get_setting("/rtx/post/dlss/execMode") == sim.cfg.render.dlss_mode - assert sim.get_setting("/rtx/directLighting/enabled") == sim.cfg.render.enable_direct_lighting - 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 diff --git a/source/isaaclab_physx/changelog.d/pbarejko-isaac-rtx-global-settings.minor.rst b/source/isaaclab_physx/changelog.d/pbarejko-isaac-rtx-global-settings.minor.rst new file mode 100644 index 00000000000..d2136b06e3f --- /dev/null +++ b/source/isaaclab_physx/changelog.d/pbarejko-isaac-rtx-global-settings.minor.rst @@ -0,0 +1,6 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` + to configure process-global Isaac RTX quality settings from + :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg`. 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 5eecd87242b..921a04ff353 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -27,7 +27,11 @@ 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 +from .isaac_rtx_renderer_utils import ( + apply_isaac_rtx_global_settings, + ensure_isaac_rtx_render_update, + ensure_rtx_hydra_engine_attached, +) logger = logging.getLogger(__name__) @@ -115,8 +119,10 @@ def provides_temporal_camera_data(cls, data_type: str) -> bool: def __init__(self, cfg: IsaacRtxRendererCfg): self.cfg = cfg + settings = get_settings_manager() + apply_isaac_rtx_global_settings(self.cfg.global_settings, settings) # RTX rendering requires the app to be launched with ``--enable_cameras``. - if not get_settings_manager().get("/isaaclab/cameras_enabled"): + if not settings.get("/isaaclab/cameras_enabled"): raise RuntimeError( "A camera was spawned without the --enable_cameras flag. Please use --enable_cameras to enable" " rendering." 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 2f765546baf..aac25eecee9 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 @@ -5,12 +5,94 @@ """Configuration for Isaac RTX (Replicator) Renderer.""" -from typing import Literal +from typing import Any, Literal from isaaclab.renderers.renderer_cfg import RendererCfg from isaaclab.utils.configclass import configclass +@configclass +class IsaacRtxRendererGlobalSettingsCfg: + """Global Isaac RTX renderer settings. + + These settings are applied to Kit/RTX carb settings. They are carried by + :class:`IsaacRtxRendererCfg` so they are owned by the Isaac RTX backend, but + they affect the process-global RTX renderer rather than a single camera. + """ + + enable_translucency: bool | None = None + """Enable translucency for specular transmissive surfaces such as glass.""" + + enable_reflections: bool | None = None + """Enable reflections.""" + + enable_global_illumination: bool | None = None + """Enable diffuse global illumination.""" + + antialiasing_mode: Literal["Off", "FXAA", "DLSS", "TAA", "DLAA"] | None = None + """Anti-aliasing mode selected through Replicator.""" + + enable_dlssg: bool | None = None + """Enable DLSS frame generation.""" + + enable_dl_denoiser: bool | None = None + """Enable DL denoiser.""" + + dlss_mode: Literal[0, 1, 2, 3] | None = None + """DLSS performance/quality mode.""" + + enable_direct_lighting: bool | None = None + """Enable direct light contributions from lights.""" + + samples_per_pixel: int | None = None + """Direct lighting samples per pixel.""" + + enable_shadows: bool | None = None + """Enable shadow rendering.""" + + enable_ambient_occlusion: bool | None = None + """Enable ambient occlusion.""" + + dome_light_upper_lower_strategy: Literal[0, 3, 4] | None = None + """Dome light sampling strategy.""" + + max_bounces: int | None = None + """Maximum number of ray bounces for RTPT.""" + + split_glass: bool | None = None + """Enable separate glass ray splitting.""" + + split_clearcoat: bool | None = None + """Enable separate clearcoat ray splitting.""" + + split_rough_reflection: bool | None = None + """Enable separate rough-reflection ray splitting.""" + + ambient_light_intensity: float | None = None + """Scene ambient light intensity.""" + + ambient_occlusion_denoiser_mode: Literal[0, 1] | None = None + """Ambient occlusion denoiser mode.""" + + subpixel_mode: int | None = None + """RTX subpixel mode.""" + + enable_cached_raytracing: bool | None = None + """Enable cached ray tracing.""" + + max_samples_per_launch: int | None = None + """Path tracing maximum samples per launch.""" + + view_tile_limit: int | None = None + """Maximum number of view tiles.""" + + carb_settings: dict[str, Any] | None = None + """Raw carb settings applied after named fields.""" + + rendering_mode: Literal["performance", "balanced", "quality"] | None = None + """Legacy rendering-mode preset to apply before field overrides.""" + + @configclass class IsaacRtxRendererCfg(RendererCfg): """Configuration for Isaac RTX renderer using Omniverse Replicator. @@ -23,6 +105,9 @@ class IsaacRtxRendererCfg(RendererCfg): renderer_type: str = "isaac_rtx" """Type identifier for Isaac RTX renderer.""" + global_settings: IsaacRtxRendererGlobalSettingsCfg = IsaacRtxRendererGlobalSettingsCfg() + """Global Kit/RTX quality settings applied before RTX Hydra attach.""" + semantic_filter: str | list[str] = "*:*" """A string or a list specifying a semantic filter predicate. Defaults to ``"*:*"``. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py index bdc5596b717..ee828ea2060 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py @@ -8,15 +8,46 @@ from __future__ import annotations import logging +import os import time from typing import Any +import tomllib + import omni.usd import isaaclab.sim as sim_utils +from isaaclab.app.settings_manager import SettingsManager, get_settings_manager + +from .isaac_rtx_renderer_cfg import IsaacRtxRendererGlobalSettingsCfg logger = logging.getLogger(__name__) +_RTX_FIELD_TO_SETTING = { + "enable_translucency": "/rtx/translucency/enabled", + "enable_reflections": "/rtx/reflections/enabled", + "enable_global_illumination": "/rtx/indirectDiffuse/enabled", + "enable_dlssg": "/rtx-transient/dlssg/enabled", + "enable_dl_denoiser": "/rtx-transient/dldenoiser/enabled", + "dlss_mode": "/rtx/post/dlss/execMode", + "enable_direct_lighting": "/rtx/directLighting/enabled", + "samples_per_pixel": "/rtx/directLighting/sampledLighting/samplesPerPixel", + "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", +} + # Module-level dedup stamp: tracks the last (sim instance, physics step, render generation) at # which Kit's ``app.update()`` was pumped. Keyed on ``id(sim)`` so that a # new ``SimulationContext`` (e.g. in a new test) automatically invalidates @@ -26,6 +57,96 @@ _STREAMING_WAIT_TIMEOUT_S: float = 30.0 +def _setting_path_from_key(key: str) -> str: + """Convert a user-friendly carb setting key to a carb path.""" + if key.startswith("/"): + return key + if "_" in key: + return "/" + key.replace("_", "/") + if "." in key: + return "/" + key.replace(".", "/") + return key + + +def _apply_nested_preset(settings: SettingsManager, data: dict[str, Any], path: str = "") -> None: + """Apply nested preset dictionaries loaded from a .kit file.""" + for key, value in data.items(): + key_path = f"{path}/{key}" if path else f"/{key}" + if isinstance(value, dict): + _apply_nested_preset(settings, value, key_path) + else: + settings.set(key_path.replace(".", "/"), value) + + +def _apply_rendering_mode_preset(settings: SettingsManager, rendering_mode: str) -> None: + """Apply an Isaac Lab rendering-mode preset.""" + supported_rendering_modes = {"performance", "balanced", "quality"} + if rendering_mode not in supported_rendering_modes: + raise ValueError( + f"IsaacRtxRendererCfg rendering mode '{rendering_mode}' not in " + "supported modes " + f"{sorted(supported_rendering_modes)}." + ) + + isaaclab_app_exp_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), *[".."] * 4, "apps") + from isaaclab.utils.version import get_isaac_sim_version + + if get_isaac_sim_version().major < 6: + isaaclab_app_exp_path = os.path.join(isaaclab_app_exp_path, "isaacsim_5") + + preset_filename = os.path.join(isaaclab_app_exp_path, f"rendering_modes/{rendering_mode}.kit") + if os.path.exists(preset_filename): + with open(preset_filename, "rb") as file: + _apply_nested_preset(settings, tomllib.load(file)) + else: + logger.warning("[isaac_rtx] Render preset file not found: %s", preset_filename) + + +def apply_isaac_rtx_global_settings( + global_settings: IsaacRtxRendererGlobalSettingsCfg, + settings: SettingsManager | None = None, +) -> None: + """Apply global Isaac RTX settings before renderer initialization. + + Args: + global_settings: Global Isaac RTX settings to apply. + settings: Settings manager to apply settings through. If None, the global settings manager is used. + """ + if settings is None: + settings = get_settings_manager() + _apply_isaac_rtx_global_settings(global_settings, settings) + + +def _apply_isaac_rtx_global_settings( + global_settings: IsaacRtxRendererGlobalSettingsCfg, + settings: SettingsManager, +) -> None: + """Apply global Isaac RTX settings to the provided settings manager.""" + + rendering_mode = getattr(global_settings, "rendering_mode", None) + if rendering_mode: + _apply_rendering_mode_preset(settings, rendering_mode) + + for field_name, setting_path in _RTX_FIELD_TO_SETTING.items(): + value = getattr(global_settings, field_name, None) + if value is not None: + settings.set(setting_path, value) + + extra_settings = getattr(global_settings, "carb_settings", None) + if extra_settings: + for key, value in extra_settings.items(): + settings.set(_setting_path_from_key(key), value) + + antialiasing_mode = getattr(global_settings, "antialiasing_mode", None) + if antialiasing_mode is not None: + try: + import omni.replicator.core as rep + + rep.settings.set_render_rtx_realtime(antialiasing=antialiasing_mode) + except Exception: + pass + + def _get_stage_streaming_busy() -> bool: """Synchronously query whether RTX stage streaming is still in progress.""" import omni.usd diff --git a/source/isaaclab_tasks/changelog.d/pbarejko-isaac-rtx-global-settings.skip b/source/isaaclab_tasks/changelog.d/pbarejko-isaac-rtx-global-settings.skip new file mode 100644 index 00000000000..fdca8a4132f --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/pbarejko-isaac-rtx-global-settings.skip @@ -0,0 +1 @@ +Skipped changelog entry for internal task config migrations to Isaac RTX renderer global settings. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/assemble_trocar/g129_dex3_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/assemble_trocar/g129_dex3_env_cfg.py index f42568fdc62..72b21a0c6dd 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/assemble_trocar/g129_dex3_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/assemble_trocar/g129_dex3_env_cfg.py @@ -24,6 +24,7 @@ CameraPresets, G1RobotPresets, ) +from isaaclab_tasks.utils.presets import set_isaac_rtx_global_settings joint_names = [ "left_hip_pitch_joint", @@ -396,12 +397,18 @@ def __post_init__(self): 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" + for camera_cfg in ( + self.scene.front_camera, + self.scene.left_wrist_camera, + self.scene.right_wrist_camera, + ): + set_isaac_rtx_global_settings( + camera_cfg.renderer_cfg, + enable_translucency=True, + carb_settings={"rtx.raytracing.fractionalCutoutOpacity": True}, + rendering_mode="quality", + antialiasing_mode="DLAA", + ) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/pick_place/exhaustpipe_gr1t2_base_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/pick_place/exhaustpipe_gr1t2_base_env_cfg.py index c8ccb5c99f3..24aa4e6c9a1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/pick_place/exhaustpipe_gr1t2_base_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/pick_place/exhaustpipe_gr1t2_base_env_cfg.py @@ -29,6 +29,8 @@ from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR from isaaclab.utils.configclass import configclass +from isaaclab_tasks.utils.presets import set_isaac_rtx_global_settings + from . import mdp from isaaclab_assets.robots.fourier import GR1T2_CFG # isort: skip @@ -320,7 +322,7 @@ def __post_init__(self): # Set settings for camera rendering self.num_rerenders_on_reset = 3 - self.sim.render.antialiasing_mode = "DLAA" # Use DLAA for higher quality rendering + set_isaac_rtx_global_settings(self.scene.robot_pov_cam.renderer_cfg, antialiasing_mode="DLAA") # List of image observations in policy observations self.image_obs_list = ["robot_pov_cam"] diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/pick_place/nutpour_gr1t2_base_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/pick_place/nutpour_gr1t2_base_env_cfg.py index db7ecd0d098..fa4eeeaaf53 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/pick_place/nutpour_gr1t2_base_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/pick_place/nutpour_gr1t2_base_env_cfg.py @@ -29,6 +29,8 @@ from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR from isaaclab.utils.configclass import configclass +from isaaclab_tasks.utils.presets import set_isaac_rtx_global_settings + from . import mdp from isaaclab_assets.robots.fourier import GR1T2_CFG # isort: skip @@ -355,7 +357,7 @@ def __post_init__(self): # Set settings for camera rendering self.num_rerenders_on_reset = 3 - self.sim.render.antialiasing_mode = "DLAA" # Use DLAA for higher quality rendering + set_isaac_rtx_global_settings(self.scene.robot_pov_cam.renderer_cfg, antialiasing_mode="DLAA") # List of image observations in policy observations self.image_obs_list = ["robot_pov_cam"] diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_ik_rel_visuomotor_cosmos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_ik_rel_visuomotor_cosmos_env_cfg.py index 7ca33a78adb..14ddc0da9f1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_ik_rel_visuomotor_cosmos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_ik_rel_visuomotor_cosmos_env_cfg.py @@ -11,6 +11,7 @@ from isaaclab.utils.configclass import configclass from isaaclab_tasks.contrib.stack import mdp +from isaaclab_tasks.utils.presets import set_isaac_rtx_global_settings from . import stack_ik_rel_visuomotor_env_cfg @@ -105,9 +106,6 @@ def __post_init__(self): # post init of parent super().__post_init__() - # set domeLight.upperLowerStrategy to 4 to remove rendering noise - self.sim.render.dome_light_upper_lower_strategy = 4 - SEMANTIC_MAPPING = { "class:cube_1": (120, 230, 255, 255), "class:cube_2": (255, 36, 66, 255), @@ -154,7 +152,12 @@ def __post_init__(self): # Set settings for camera rendering self.num_rerenders_on_reset = 1 - self.sim.render.antialiasing_mode = "OFF" + for camera_cfg in (self.scene.table_cam, self.scene.wrist_cam): + set_isaac_rtx_global_settings( + camera_cfg.renderer_cfg, + dome_light_upper_lower_strategy=4, + antialiasing_mode="Off", + ) # List of image observations in policy observations self.image_obs_list = ["table_cam", "wrist_cam"] diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_ik_rel_visuomotor_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_ik_rel_visuomotor_env_cfg.py index 15f872c90fb..bc2d0fae344 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_ik_rel_visuomotor_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/franka/stack_ik_rel_visuomotor_env_cfg.py @@ -21,6 +21,7 @@ from isaaclab_tasks.contrib.stack import mdp from isaaclab_tasks.contrib.stack.mdp import franka_stack_events from isaaclab_tasks.contrib.stack.stack_env_cfg import StackEnvCfg +from isaaclab_tasks.utils.presets import set_isaac_rtx_global_settings ## # Pre-defined configs @@ -364,7 +365,8 @@ def __post_init__(self): # Set settings for camera rendering self.num_rerenders_on_reset = 3 - self.sim.render.antialiasing_mode = "DLAA" # Use DLAA for higher quality rendering + for camera_cfg in (self.scene.table_cam, self.scene.wrist_cam): + set_isaac_rtx_global_settings(camera_cfg.renderer_cfg, antialiasing_mode="DLAA") # List of image observations in policy observations self.image_obs_list = ["table_cam", "wrist_cam"] diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/galbot/stack_rmp_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/galbot/stack_rmp_rel_env_cfg.py index de96e9120f4..897f12c954a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/galbot/stack_rmp_rel_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/galbot/stack_rmp_rel_env_cfg.py @@ -18,7 +18,10 @@ from isaaclab.utils.configclass import configclass from isaaclab_tasks.contrib.stack import mdp -from isaaclab_tasks.utils.presets import MultiBackendRendererCfg +from isaaclab_tasks.utils.presets import ( + MultiBackendRendererCfg, + set_isaac_rtx_global_settings, +) from . import stack_joint_pos_env_cfg @@ -261,7 +264,13 @@ def __post_init__(self): # Set settings for camera rendering self.num_rerenders_on_reset = 3 - self.sim.render.antialiasing_mode = "DLAA" # Use DLAA for higher quality rendering + for camera_cfg in ( + self.scene.right_wrist_cam, + self.scene.left_wrist_cam, + self.scene.ego_cam, + self.scene.front_cam, + ): + set_isaac_rtx_global_settings(camera_cfg.renderer_cfg, antialiasing_mode="DLAA") # List of image observations in policy observations self.image_obs_list = ["ego_cam", "left_wrist_cam", "right_wrist_cam"] diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py b/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py index 3d79ce5286e..a362752900d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause +from typing import Any + from isaaclab_newton.renderers import NewtonWarpRendererCfg from isaaclab_ov.renderers import OVRTXRendererCfg from isaaclab_physx.renderers import IsaacRtxRendererCfg @@ -25,3 +27,20 @@ class MultiBackendRendererCfg(PresetCfg): newton_renderer: NewtonWarpRendererCfg = NewtonWarpRendererCfg() ovrtx_renderer: OVRTXRendererCfg = OVRTXRendererCfg() isaacsim_rtx_renderer = default + + +def set_isaac_rtx_global_settings(renderer_cfg: Any, **settings: Any) -> None: + """Set Isaac RTX settings on direct or preset-wrapped renderer configs.""" + visited: set[int] = set() + + def _visit(cfg: Any) -> None: + if cfg is None or id(cfg) in visited: + return + visited.add(id(cfg)) + if getattr(cfg, "renderer_type", None) == "isaac_rtx" and hasattr(cfg, "global_settings"): + for key, value in settings.items(): + setattr(cfg.global_settings, key, value) + for attr_name in ("default", "isaacsim_rtx_renderer"): + _visit(getattr(cfg, attr_name, None)) + + _visit(renderer_cfg) From 111b16e7dacb567fce5285b49ac1147a26d18ec5 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 06:30:09 +0000 Subject: [PATCH 054/139] [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: 7.5.0 → 8.0.0 - isaaclab_physx: 2.5.0 → 2.6.0 --- ...anh-fix-sim6-prebundle-torch-streaming.rst | 13 ---------- ...arejko-isaac-rtx-global-settings.major.rst | 7 ----- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 26 +++++++++++++++++++ source/isaaclab/pyproject.toml | 2 +- ...arejko-isaac-rtx-global-settings.minor.rst | 6 ----- .../changelog.d/physx-iface-cache.rst | 4 --- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 16 ++++++++++++ source/isaaclab_physx/pyproject.toml | 2 +- .../pbarejko-isaac-rtx-global-settings.skip | 1 - 11 files changed, 46 insertions(+), 35 deletions(-) delete mode 100644 source/isaaclab/changelog.d/jichuanh-fix-sim6-prebundle-torch-streaming.rst delete mode 100644 source/isaaclab/changelog.d/pbarejko-isaac-rtx-global-settings.major.rst delete mode 100644 source/isaaclab_physx/changelog.d/pbarejko-isaac-rtx-global-settings.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/physx-iface-cache.rst delete mode 100644 source/isaaclab_tasks/changelog.d/pbarejko-isaac-rtx-global-settings.skip diff --git a/source/isaaclab/changelog.d/jichuanh-fix-sim6-prebundle-torch-streaming.rst b/source/isaaclab/changelog.d/jichuanh-fix-sim6-prebundle-torch-streaming.rst deleted file mode 100644 index c971f5f6fa8..00000000000 --- a/source/isaaclab/changelog.d/jichuanh-fix-sim6-prebundle-torch-streaming.rst +++ /dev/null @@ -1,13 +0,0 @@ -Fixed -^^^^^ - -* Fixed a crash (``undefined symbol: ncclDevCommCreate``) when launching the Isaac Sim - streaming app (e.g. ``isaac-sim.streaming.sh`` / ``runheadless.sh``) from an Isaac Lab - install against Isaac Sim 6.0. Isaac Sim's deprecated ``omni.isaac.ml_archive`` prebundle - ships its own PyTorch and NCCL while Isaac Lab installs a different pinned PyTorch; on - launch paths that do not import Isaac Lab (which otherwise deprioritizes the prebundle on - ``sys.path``), the two NCCL copies collide and the prebundled torch binds to the wrong - one. The install step that repoints the prebundle to the active environment now uses - overlayfs-safe filesystem operations, so it works inside the Docker image build (where it - previously failed silently with ``EXDEV`` / ``EINVAL``) and fails loudly if a shadowing - copy remains. diff --git a/source/isaaclab/changelog.d/pbarejko-isaac-rtx-global-settings.major.rst b/source/isaaclab/changelog.d/pbarejko-isaac-rtx-global-settings.major.rst deleted file mode 100644 index 5e407688cac..00000000000 --- a/source/isaaclab/changelog.d/pbarejko-isaac-rtx-global-settings.major.rst +++ /dev/null @@ -1,7 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** Removed ``RenderCfg`` and ``SimulationCfg.render``. Configure - Isaac RTX quality settings through - :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` on - :attr:`~isaaclab_physx.renderers.IsaacRtxRendererCfg.global_settings` instead. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 8393814f2fe..3e5a72eaec2 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 = "7.5.0" +version = "8.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 acac134b6c2..11e7bf892b0 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,32 @@ Changelog --------- +8.0.0 (2026-06-30) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* **Breaking:** Removed ``RenderCfg`` and ``SimulationCfg.render``. Configure + Isaac RTX quality settings through + :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` on + :attr:`~isaaclab_physx.renderers.IsaacRtxRendererCfg.global_settings` instead. + +Fixed +^^^^^ + +* Fixed a crash (``undefined symbol: ncclDevCommCreate``) when launching the Isaac Sim + streaming app (e.g. ``isaac-sim.streaming.sh`` / ``runheadless.sh``) from an Isaac Lab + install against Isaac Sim 6.0. Isaac Sim's deprecated ``omni.isaac.ml_archive`` prebundle + ships its own PyTorch and NCCL while Isaac Lab installs a different pinned PyTorch; on + launch paths that do not import Isaac Lab (which otherwise deprioritizes the prebundle on + ``sys.path``), the two NCCL copies collide and the prebundled torch binds to the wrong + one. The install step that repoints the prebundle to the active environment now uses + overlayfs-safe filesystem operations, so it works inside the Docker image build (where it + previously failed silently with ``EXDEV`` / ``EINVAL``) and fails loudly if a shadowing + copy remains. + + 7.5.0 (2026-06-28) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 74d328498f0..2e063e8415a 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "7.5.0" +version = "8.0.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/pbarejko-isaac-rtx-global-settings.minor.rst b/source/isaaclab_physx/changelog.d/pbarejko-isaac-rtx-global-settings.minor.rst deleted file mode 100644 index d2136b06e3f..00000000000 --- a/source/isaaclab_physx/changelog.d/pbarejko-isaac-rtx-global-settings.minor.rst +++ /dev/null @@ -1,6 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` - to configure process-global Isaac RTX quality settings from - :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg`. diff --git a/source/isaaclab_physx/changelog.d/physx-iface-cache.rst b/source/isaaclab_physx/changelog.d/physx-iface-cache.rst deleted file mode 100644 index a8a21770a80..00000000000 --- a/source/isaaclab_physx/changelog.d/physx-iface-cache.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Fixed a crash in :class:`~isaaclab_physx.physics.PhysxManager` when ``omni.physx`` is reloaded during a session. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index e910ed12d7c..2750173a9c2 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 = "2.5.0" +version = "2.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 2ae6f3199a8..eea798d1fba 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,22 @@ Changelog --------- +2.6.0 (2026-06-30) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg` + to configure process-global Isaac RTX quality settings from + :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg`. + +Fixed +^^^^^ + +* Fixed a crash in :class:`~isaaclab_physx.physics.PhysxManager` when ``omni.physx`` is reloaded during a session. + + 2.5.0 (2026-06-28) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index 5b9cd8cdaa2..e6eb574b1d1 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "2.5.0" +version = "2.6.0" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/pbarejko-isaac-rtx-global-settings.skip b/source/isaaclab_tasks/changelog.d/pbarejko-isaac-rtx-global-settings.skip deleted file mode 100644 index fdca8a4132f..00000000000 --- a/source/isaaclab_tasks/changelog.d/pbarejko-isaac-rtx-global-settings.skip +++ /dev/null @@ -1 +0,0 @@ -Skipped changelog entry for internal task config migrations to Isaac RTX renderer global settings. From b64ab3b07907ec3beb5c14fb183cef3eef4c2faa Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Tue, 30 Jun 2026 13:54:18 -0400 Subject: [PATCH 055/139] Allow for automatic switching between ovrtx and isaac sim RTX docs (#6302) # Description Update auto rtx docs to be more concise Fixes # (OMPE-92354) ## 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 --- docs/source/setup/quickstart_details.rst | 44 ++++++++++++++++++++---- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/source/setup/quickstart_details.rst b/docs/source/setup/quickstart_details.rst index 7e3e8f504ce..b1ed191865c 100644 --- a/docs/source/setup/quickstart_details.rst +++ b/docs/source/setup/quickstart_details.rst @@ -84,17 +84,47 @@ Available Presets - ``isaacsim_rtx_renderer`` — Isaac Sim RTX (default with Isaac Sim) - ``newton_renderer`` — Newton Warp renderer - ``ovrtx_renderer`` — OV RTX renderer (kit-less) -- ``rtx`` — automatic RTX renderer selection +- ``rtx`` — Automatic RTX renderer selection + +Automatic RTX selection is available only when the camera exposes the renderer +choices with :class:`~isaaclab_tasks.utils.presets.MultiBackendRendererCfg`: + +.. code-block:: python + + from isaaclab.sensors import CameraCfg + from isaaclab_tasks.utils.presets import MultiBackendRendererCfg + + camera = CameraCfg( + # Other camera settings... + renderer_cfg=MultiBackendRendererCfg(), + ) + +Then use ``renderer=rtx`` to select the RTX implementation required by the runtime: + +.. list-table:: + :widths: 55 45 + :header-rows: 1 + + * - Runtime + - Resolved renderer + * - Requires Isaac Sim/Kit, such as ``physics=physx``, ``--visualizer kit``, + livestreaming, or another Kit camera + - :class:`~isaaclab_physx.renderers.IsaacRtxRendererCfg` + * - Fully kit-less, such as ``physics=newton_mjwarp`` or ``physics=ovphysx`` + without a Kit visualizer or camera + - :class:`~isaaclab_ov.renderers.OVRTXRendererCfg` + +For example, ``physics=newton_mjwarp renderer=rtx`` selects OVRTX for a +fully kit-less run, but selects Isaac Sim RTX when combined with +``--visualizer kit``. + +A camera configured directly with ``renderer_cfg=IsaacRtxRendererCfg()`` does +not participate in automatic selection and is not overridden by +``renderer=rtx`` because it does not use the multi-backend renderer configuration. **Domain presets** (``presets=NAME[,NAME,...]``) are task-specific — run ``--task= --help`` to list them. -Use ``renderer=rtx`` for automatic RTX selection on tasks that expose the -multi-backend renderer selector. The selection follows the runtime: it uses -Isaac Sim RTX when the run needs Isaac Sim/Kit, such as ``physics=physx`` or -``--visualizer kit``, and uses OVRTX when the run is fully kit-less. For example, -``physics=ovphysx renderer=rtx`` selects OVRTX; - Common combinations: .. code-block:: bash From e5478465baf58f735ba308e40faf6b4eb2993d29 Mon Sep 17 00:00:00 2001 From: Richard Lei Date: Wed, 1 Jul 2026 07:36:41 +1200 Subject: [PATCH 056/139] Switch Cartpole lighting from DomeLight to DistantLight (#6289) --- .../cartpole-distant-light-update.rst | 10 ++++++++++ .../cartpole/cartpole_direct_camera_env.py | 11 ++++++++-- .../core/cartpole/cartpole_direct_env.py | 12 +++++++++-- .../core/cartpole/cartpole_manager_env_cfg.py | 12 ++++++++--- .../isaaclab_tasks/core/cartpole/constants.py | 20 +++++++++++++++++++ .../test/core/test_rendering_cartpole.py | 2 +- .../newton-isaacsim_rtx_renderer-rgb.png | 4 ++-- .../newton-isaacsim_rtx_renderer-rgba.png | 4 ++-- ...nderer-simple_shading_constant_diffuse.png | 4 ++-- ...tx_renderer-simple_shading_diffuse_mdl.png | 4 ++-- ...m_rtx_renderer-simple_shading_full_mdl.png | 4 ++-- .../cartpole/newton-ovrtx_renderer-rgb.png | 4 ++-- .../cartpole/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 ++-- .../cartpole/ovphysx-ovrtx_renderer-rgb.png | 4 ++-- .../cartpole/ovphysx-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 ++-- .../physx-isaacsim_rtx_renderer-rgb.png | 4 ++-- .../physx-isaacsim_rtx_renderer-rgba.png | 4 ++-- ...nderer-simple_shading_constant_diffuse.png | 4 ++-- ...tx_renderer-simple_shading_diffuse_mdl.png | 4 ++-- ...m_rtx_renderer-simple_shading_full_mdl.png | 4 ++-- .../default_physics-default_renderer-rgb.png | 4 ++-- .../default_physics-default_renderer-rgba.png | 4 ++-- ...nderer-simple_shading_constant_diffuse.png | 4 ++-- ...lt_renderer-simple_shading_diffuse_mdl.png | 4 ++-- ...fault_renderer-simple_shading_full_mdl.png | 4 ++-- 31 files changed, 109 insertions(+), 58 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/cartpole-distant-light-update.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/cartpole/constants.py diff --git a/source/isaaclab_tasks/changelog.d/cartpole-distant-light-update.rst b/source/isaaclab_tasks/changelog.d/cartpole-distant-light-update.rst new file mode 100644 index 00000000000..41a31f07ed3 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/cartpole-distant-light-update.rst @@ -0,0 +1,10 @@ +Changed +^^^^^^^ + +* Changed the Cartpole task lighting from :class:`~isaaclab.sim.DomeLightCfg` to + :class:`~isaaclab.sim.DistantLightCfg` with unified light + intensity (``CARTPOLE_DISTANT_LIGHT_INTENSITY = 2000.0``), color + (``CARTPOLE_DISTANT_LIGHT_COLOR = (1.0, 1.0, 1.0)``), and orientation + (``CARTPOLE_DISTANT_LIGHT_ORIENTATION``, -45° pitch and -45° yaw) across the direct, camera, + and manager-based variants via shared constants in ``constants.py``. Updated lighting-dependent + test golden images accordingly. diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py index 47ae2a5990a..8791d107154 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py @@ -19,6 +19,11 @@ from isaaclab.utils.string import string_to_callable from isaaclab_tasks.core.cartpole.cartpole_direct_env import CartpoleEnv +from isaaclab_tasks.core.cartpole.constants import ( + CARTPOLE_DISTANT_LIGHT_COLOR, + CARTPOLE_DISTANT_LIGHT_INTENSITY, + CARTPOLE_DISTANT_LIGHT_ORIENTATION, +) if TYPE_CHECKING: from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env_cfg import CartpoleCameraEnvCfg @@ -107,8 +112,10 @@ def _setup_scene(self): self.scene.articulations["cartpole"] = self.cartpole self.scene.sensors["tiled_camera"] = self._tiled_camera # add lights - light_cfg = sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)) - light_cfg.func("/World/Light", light_cfg) + light_cfg = sim_utils.DistantLightCfg( + intensity=CARTPOLE_DISTANT_LIGHT_INTENSITY, color=CARTPOLE_DISTANT_LIGHT_COLOR + ) + light_cfg.func("/World/Light", light_cfg, orientation=CARTPOLE_DISTANT_LIGHT_ORIENTATION) def _get_observations(self) -> dict: data_type = self.cfg.tiled_camera.data_types[0] diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py index b06701d04e5..e5598de6400 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py @@ -18,6 +18,12 @@ from isaaclab.sim.spawners.from_files import GroundPlaneCfg, spawn_ground_plane from isaaclab.utils.math import sample_uniform +from isaaclab_tasks.core.cartpole.constants import ( + CARTPOLE_DISTANT_LIGHT_COLOR, + CARTPOLE_DISTANT_LIGHT_INTENSITY, + CARTPOLE_DISTANT_LIGHT_ORIENTATION, +) + if TYPE_CHECKING: from isaaclab_tasks.core.cartpole.cartpole_direct_env_cfg import CartpoleEnvCfg @@ -52,8 +58,10 @@ def _setup_scene(self): self.scene.articulations["cartpole"] = self.cartpole # add lights - light_cfg = sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)) - light_cfg.func("/World/Light", light_cfg) + light_cfg = sim_utils.DistantLightCfg( + intensity=CARTPOLE_DISTANT_LIGHT_INTENSITY, color=CARTPOLE_DISTANT_LIGHT_COLOR + ) + light_cfg.func("/World/Light", light_cfg, orientation=CARTPOLE_DISTANT_LIGHT_ORIENTATION) def _pre_physics_step(self, actions: torch.Tensor) -> None: self.actions = self.action_scale * actions.clone() diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py index 5b674ba11bb..ad1658e0f8b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py @@ -21,6 +21,11 @@ from isaaclab.utils.configclass import configclass import isaaclab_tasks.core.cartpole.mdp as mdp +from isaaclab_tasks.core.cartpole.constants import ( + CARTPOLE_DISTANT_LIGHT_COLOR, + CARTPOLE_DISTANT_LIGHT_INTENSITY, + CARTPOLE_DISTANT_LIGHT_ORIENTATION, +) from isaaclab_tasks.utils import PresetCfg from isaaclab_assets.robots.cartpole import CARTPOLE_CFG # isort:skip @@ -91,9 +96,10 @@ class CartpoleSceneCfg(InteractiveSceneCfg): robot: ArticulationCfg = CARTPOLE_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") # lights - dome_light = AssetBaseCfg( - prim_path="/World/DomeLight", - spawn=sim_utils.DomeLightCfg(color=(0.9, 0.9, 0.9), intensity=500.0), + distant_light = AssetBaseCfg( + prim_path="/World/DistantLight", + init_state=AssetBaseCfg.InitialStateCfg(rot=CARTPOLE_DISTANT_LIGHT_ORIENTATION), + spawn=sim_utils.DistantLightCfg(color=CARTPOLE_DISTANT_LIGHT_COLOR, intensity=CARTPOLE_DISTANT_LIGHT_INTENSITY), ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/constants.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/constants.py new file mode 100644 index 00000000000..3404127edf5 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/constants.py @@ -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 + +import math + +import torch + +from isaaclab.utils.math import quat_from_euler_xyz + +CARTPOLE_DISTANT_LIGHT_INTENSITY: float = 2000.0 +CARTPOLE_DISTANT_LIGHT_COLOR: tuple[float, float, float] = (1.0, 1.0, 1.0) +CARTPOLE_DISTANT_LIGHT_ORIENTATION: tuple[float, float, float, float] = tuple( + quat_from_euler_xyz( + torch.tensor([0.0]), + torch.tensor([math.radians(-45.0)]), + torch.tensor([math.radians(-45.0)]), + )[0].tolist() +) diff --git a/source/isaaclab_tasks/test/core/test_rendering_cartpole.py b/source/isaaclab_tasks/test/core/test_rendering_cartpole.py index 5ac49d76df3..6f10a80a1d4 100644 --- a/source/isaaclab_tasks/test/core/test_rendering_cartpole.py +++ b/source/isaaclab_tasks/test/core/test_rendering_cartpole.py @@ -32,6 +32,6 @@ @pytest.mark.parametrize("physics_backend,renderer,data_type", PHYSICS_RENDERER_AOV_COMBINATIONS) -def test_rendering_cartpole(physics_backend, renderer, data_type, enable_scene_partition): +def test_rendering_cartpole(physics_backend, renderer, data_type): """Test cartpole environment rendering correctness.""" rendering_test_cartpole(physics_backend, renderer, data_type, _COMPARISON_SCORES) diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-rgb.png index 4c01a0c9d17..0cc23063d1e 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:98bc2b8d88abe0a989610f1354eac5b4b73c89bcf51140ff68f28879e90805a1 -size 3513 +oid sha256:908c5e81b4aa1abf2728ba557f1e3e00e65c7c994d32173f9dc23d735a4caa5c +size 4113 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-rgba.png index 7eae5eb4034..8883b92e1a3 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f3d0a29a288f6b987796c527e839618467042741e54f88df810f1ac76dc33950 -size 3927 +oid sha256:2ed7bd5056ca5f7c273f487a584ec8240512ba9b904cd4c37a05c267a9bc7ce8 +size 5077 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png index 87104cb8716..b71fab32569 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47b5b15d79d0b61d00c0538caa0012172753a481ad6efb45df2888402be2f407 -size 391 +oid sha256:e6e8bfb5a5cbbd882df2c114300ce607d92b45fd963f6d650b9a58d7f6d9c43f +size 431 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png index 7d05e4a7adb..35660587b41 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_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:7186c295f740f686648e96ab43f04d2e69d8e66b617fad0c52ae592a5b96f800 +size 474 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png index 6b4f8389da0..f76c2449aab 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_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:afae7b67addb9e7e9f8e140bf5a65b7a1dd727ba35c3627408fad79d955f47e2 +size 663 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 e47c06e2ca7..f4b6068679b 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:de05d580b99f1cbcfa28834a5713786a4355b658f216da9d89cd5c688c9d97a9 +size 2571 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 791497af827..26bcf340adb 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:0d4097a68ad1baec0d2c4fc8525409bb5e8535c7302d54bd38f79a70ed77d704 +size 3324 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 87104cb8716..b71fab32569 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 -size 391 +oid sha256:e6e8bfb5a5cbbd882df2c114300ce607d92b45fd963f6d650b9a58d7f6d9c43f +size 431 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 7d05e4a7adb..35660587b41 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:7186c295f740f686648e96ab43f04d2e69d8e66b617fad0c52ae592a5b96f800 +size 474 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 6b4f8389da0..f76c2449aab 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:afae7b67addb9e7e9f8e140bf5a65b7a1dd727ba35c3627408fad79d955f47e2 +size 663 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-rgb.png index 1da7f90b0f7..77cbb26721d 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f6602790e76c3448bbfae2dc2df961e2122ab90aa3e5a7e94fb143aa7f27525 -size 2576 +oid sha256:ae861919fe6db45bccbdc295237c7e647591f99da7288a5c4e9edd8ce9a57a66 +size 2501 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-rgba.png index 83507dfbc86..6ef554b6433 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c0f30114683915380110cbf4ed63023d38555358f09aa5c0a8810b83cdcc3a15 -size 2871 +oid sha256:f6c278751bb84db672c53c193d7e514d229995e6c46d615b088dd8b0b645d605 +size 3248 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png index 87104cb8716..b71fab32569 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47b5b15d79d0b61d00c0538caa0012172753a481ad6efb45df2888402be2f407 -size 391 +oid sha256:e6e8bfb5a5cbbd882df2c114300ce607d92b45fd963f6d650b9a58d7f6d9c43f +size 431 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png index 7d05e4a7adb..35660587b41 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-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:7186c295f740f686648e96ab43f04d2e69d8e66b617fad0c52ae592a5b96f800 +size 474 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png index 6b4f8389da0..f76c2449aab 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-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:afae7b67addb9e7e9f8e140bf5a65b7a1dd727ba35c3627408fad79d955f47e2 +size 663 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-rgb.png index 0397969c1af..22cfb57aef8 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0618f881648cc0039729df724f9776379378660bf1858223bf9ed65bb4204691 -size 3584 +oid sha256:1c519aa2c6e5db95af0fd72f156a9f23bba33d061f5aec1e981a8219679b3471 +size 4115 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-rgba.png index 74a8d4f725c..9e3cae3249d 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e1564f0b96e09346f33be82cbbd181d1e5e589d51c929e95a8884707be2c10e -size 3994 +oid sha256:70b6792ae02d68b5f076a2293cf5cdc02d35c249d29d3a7bcece6e44d47185b9 +size 5077 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png index 87104cb8716..b71fab32569 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47b5b15d79d0b61d00c0538caa0012172753a481ad6efb45df2888402be2f407 -size 391 +oid sha256:e6e8bfb5a5cbbd882df2c114300ce607d92b45fd963f6d650b9a58d7f6d9c43f +size 431 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png index 7d05e4a7adb..35660587b41 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2ba382c0804ea49b55fc5216c9f1e28c34d5cae95b33d9982fd55763df178cc -size 435 +oid sha256:7186c295f740f686648e96ab43f04d2e69d8e66b617fad0c52ae592a5b96f800 +size 474 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png index 6b4f8389da0..f76c2449aab 100644 --- a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d7af4ef2afca01d0bf4f9c069c5c3778fa07050bcd8091486541ab11f14e8227 -size 742 +oid sha256:afae7b67addb9e7e9f8e140bf5a65b7a1dd727ba35c3627408fad79d955f47e2 +size 663 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-rgb.png index fbfa3e28851..0a7621037bc 100644 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1388be146f03b4330570c77e7a809dabb8ba56ed45bb7fe606e40bd6e6027c40 -size 3582 +oid sha256:ba2457c5b6c20a7bf5440c24e5853073ea19b20f25f8ec4638b96cccceb6f0d1 +size 4104 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-rgba.png index 0791c1063ca..4c203241f2f 100644 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de65124177ae23872f1ad52d84a75b1c527c8e302df77df045006f7ac0ccf509 -size 3992 +oid sha256:fb7b636f06bd45988df69cef0e40a3ae841b757f8e4aef1258adf71540b8de29 +size 5058 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_constant_diffuse.png index 87104cb8716..b71fab32569 100644 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_constant_diffuse.png +++ b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_constant_diffuse.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47b5b15d79d0b61d00c0538caa0012172753a481ad6efb45df2888402be2f407 -size 391 +oid sha256:e6e8bfb5a5cbbd882df2c114300ce607d92b45fd963f6d650b9a58d7f6d9c43f +size 431 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_diffuse_mdl.png index 7d05e4a7adb..35660587b41 100644 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_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:7186c295f740f686648e96ab43f04d2e69d8e66b617fad0c52ae592a5b96f800 +size 474 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_full_mdl.png index 6b4f8389da0..f76c2449aab 100644 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_full_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_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:afae7b67addb9e7e9f8e140bf5a65b7a1dd727ba35c3627408fad79d955f47e2 +size 663 From 7bc70216d127fb61b6c432e26289867468adeb91 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Wed, 1 Jul 2026 06:39:24 +0200 Subject: [PATCH 057/139] Bump Newton to include torque routing (#6293) ## Description Pins every active `newton[sim]` dependency to Newton commit `2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee`, the squash-merge commit for [Newton PR #3134](https://github.com/newton-physics/newton/pull/3134). The update also changes the core IsaacLab Warp requirement from `1.14.0` to the prerelease build `1.15.0.dev20260626`. This branch is based on the reviewed `origin/develop` snapshot `e5478465baf58f735ba308e40faf6b4eb2993d29`. Upstream comparison from the prior pin to the new pin: https://github.com/newton-physics/newton/compare/49ca73b9dd088e48a853aa7033f928f3bd84aa78...2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee ## Compatibility updates - Reinterprets seven PhysX/OVPhysX COM staging sources as `wp.float32` views before copying, matching Warp 1.15 element-size validation. - Forwards `model.shape_margin` through the MJWarp/VBD and Featherstone/VBD reaction paths, with regression coverage for both solvers. - Migrates Kamino reset to `SolverKamino.ResetConfig.from_joints()` while keeping the reset migration minimal. IsaacLab adds no custom Kamino torque-routing path. Newton PR #3134 consumes the generic `Control.joint_f` effort input upstream. IsaacLab retains only its Kamino lifecycle, reset, forward-kinematics, collision, and graph specialization. ## Local verification Exact local environment: Newton `2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee`, Warp `1.15.0.dev20260626`. - Wheel metadata: 3 passed. - Newton manager abstraction: 72 passed across both Kamino modes. - Exact Kamino Cartpole integration smoke: 16 environments, 5 iterations, exit code 0, no traceback, and `Training time: 2.83 seconds`. This proves runtime/integration compatibility of the generic effort path; it does not establish numerical torque effectiveness. - Two-way coupling: MJWarp 1 passed; Featherstone 1 passed. - Soft/cloth: 4 passed, 122 deselected. - Full `./isaaclab.sh -f`: passed before and after the final commit. ### Local COM coverage gap The focused kitless COM fallback run produced 76 passed, 520 deselected, 28 setup errors, and 0 skipped. The 28 setup errors are a pre-existing OVPhysX fixture/API mismatch: `mock_bindings.bindings` is a dictionary where the fixture expects an object exposing `.view`. They are not a failure caused by this pin, but the affected COM assertions remain a disclosed local coverage gap. The final-head CI matrix is documented below. ## CI result Current head `6d7b40682a2117a28ff4fb2dff7a8aaadca75b2e`, based on reviewed `origin/develop` snapshot `e5478465baf58f735ba308e40faf6b4eb2993d29`: the final check set is terminal with **34 passed, 2 skipped, and 1 failed** across 37 checks. The two skipped checks were deployment-only documentation jobs. Both x86 and ARM installation jobs passed. The compatibility-focused jobs all passed, including `isaaclab_newton`, `isaaclab_physx`, `isaaclab_ov`, `isaaclab_contrib`, every core and task shard, cuRobo, and SkillGen. The [standard rendering check](https://github.com/isaac-sim/IsaacLab/actions/runs/28471558478/job/84391196725) also passed. The sole red check is `rendering-correctness-kitless`. The [initial job](https://github.com/isaac-sim/IsaacLab/actions/runs/28471558478/job/84391196722) failed only `test_rendering_cartpole_kitless[newton-ovrtx-normals]` after its three built-in attempts (`1.40%`, `2.39%`, and `1.87%` different pixels versus a `1.00%` threshold). The single authorized [unchanged rerun](https://github.com/isaac-sim/IsaacLab/actions/runs/28471558478/job/84416220990) reproduced the same case after its built-in attempts (`1.30%`, `2.39%`, and `1.40%`; SSIM `0.9762`, `0.9566`, and `0.9741`). It also hit a transient Shadow Hand process abort: `free(): invalid next size (fast)`, exit code 134, and no report. Shadow Hand passed in the initial run on this same head, while the other kitless suites passed in the rerun. This branch does not modify rendering test or baseline files. The same Cartpole normals case appeared on the pre-rebase head and then passed on an unchanged later attempt; Shadow Hand likewise changed from pass to crash with no code change. That evidence supports intermittent rendering/runtime behavior. Nevertheless, the current final check set remains red after the single authorized rerun, so this PR does not claim an all-green CI result. On pre-rebase head `5b004bd04e79d735324ae64c9d4210f79b77c519`, the compatibility-focused jobs passed, including `isaaclab_newton`, `isaaclab_physx`, `isaaclab_contrib`, all core and task shards, both installation jobs, and `isaaclab_ov` on unchanged rerun. ### Original and rerun evidence - [`isaaclab_ov` original](https://github.com/isaac-sim/IsaacLab/actions/runs/28453342925/job/84331802496) failed the GPU `test_body_root_state_properties[True-cuda:0-1]` numerical velocity comparison after its three built-in attempts: the final maximum absolute difference was `0.000190109` for a `0.0001` tolerance. The [unchanged rerun](https://github.com/isaac-sim/IsaacLab/actions/runs/28453342925/job/84357721185) passed, confirming this as known intermittent and non-blocking. - [`rendering-correctness-kitless` original](https://github.com/isaac-sim/IsaacLab/actions/runs/28453342925/job/84331802305) exhausted its built-in attempts for an OVPhysX Cartpole diffuse comparison (`2.38%` different pixels versus a `1.00%` limit) and a Newton Dexsuite full-MDL comparison (`10.37%` versus `8.00%`). Both original cases passed on the [unchanged rerun](https://github.com/isaac-sim/IsaacLab/actions/runs/28453342925/job/84357721031), while a different Newton Cartpole normals comparison exhausted its attempts (`2.39%` versus `1.00%`, final SSIM `0.9566`). An independently triggered [unchanged attempt 3](https://github.com/isaac-sim/IsaacLab/actions/runs/28453342925/job/84368444487) passed. The shifting failures followed by a clean rerun confirm this as a known intermittent, non-blocking rendering flake. - [`test-curobo` original](https://github.com/isaac-sim/IsaacLab/actions/runs/28453342925/job/84335211149) was cancelled when its runner received a shutdown signal during OCI export, followed by an RPC `Unavailable`/EOF. The [unchanged rerun](https://github.com/isaac-sim/IsaacLab/actions/runs/28453342925/job/84357721120) passed, confirming an infrastructure-only transient. The final branch delta from reviewed base `e5478465baf58f735ba308e40faf6b4eb2993d29` touches the OV/PhysX COM staging sources listed above but does not modify rendering test or baseline files. Because the reviewed base includes Cartpole lighting and rendering test/golden updates, final-head rendering CI is an important integration check. The CI matrix does not make the locally blocked COM assertions pass and does not close the clean-wheel `[newton]` installation coverage gap; both remain documented separately. ## Remaining installation coverage gap Current installation CI does not clean-install a built wheel with the `[newton]` extra. End-to-end resolution of the generated Git Newton URL plus the prerelease Warp build from the NVIDIA index remains untested by that path. --- source/isaaclab/changelog.d/pin-newton-2064e3b.rst | 8 ++++++++ source/isaaclab/pyproject.toml | 2 +- .../changelog.d/pin-newton-49ca73b.rst | 4 ++++ .../deformable/coupled_featherstone_vbd_manager.py | 1 + .../deformable/coupled_mjwarp_vbd_manager.py | 1 + .../isaaclab_contrib/deformable/kernels.py | 2 ++ .../deformable/test_rigid_deformable_coupling.py | 11 ++++++----- .../changelog.d/pin-newton-2064e3b.rst | 12 ++++++++++++ .../isaaclab_newton/physics/kamino_manager.py | 9 ++++----- source/isaaclab_newton/pyproject.toml | 2 +- .../changelog.d/pin-newton-49ca73b.rst | 7 +++++++ .../assets/articulation/articulation.py | 4 ++-- .../assets/rigid_object/rigid_object.py | 4 ++-- .../rigid_object_collection.py | 4 ++-- .../changelog.d/pin-newton-2064e3b.rst | 12 ++++++++++++ .../assets/rigid_object/rigid_object.py | 2 +- source/isaaclab_physx/pyproject.toml | 2 +- .../changelog.d/pin-newton-2064e3b.rst | 6 ++++++ source/isaaclab_visualizers/pyproject.toml | 8 ++++---- tools/wheel_builder/res/python_packages.toml | 6 +++--- 20 files changed, 80 insertions(+), 27 deletions(-) create mode 100644 source/isaaclab/changelog.d/pin-newton-2064e3b.rst create mode 100644 source/isaaclab_contrib/changelog.d/pin-newton-49ca73b.rst create mode 100644 source/isaaclab_newton/changelog.d/pin-newton-2064e3b.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/pin-newton-49ca73b.rst create mode 100644 source/isaaclab_physx/changelog.d/pin-newton-2064e3b.rst create mode 100644 source/isaaclab_visualizers/changelog.d/pin-newton-2064e3b.rst diff --git a/source/isaaclab/changelog.d/pin-newton-2064e3b.rst b/source/isaaclab/changelog.d/pin-newton-2064e3b.rst new file mode 100644 index 00000000000..d9fd65fb87c --- /dev/null +++ b/source/isaaclab/changelog.d/pin-newton-2064e3b.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Changed the Isaac Lab wheel's ``newton[sim]`` dependency pin to Newton + commit ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee`` and the core + ``warp-lang`` dependency pin from ``1.14.0`` to + ``1.15.0.dev20260626``. Projects that install these dependencies separately + should use the same versions. diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 2e063e8415a..6e1964c5c90 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ # image processing "transformers==4.57.6", "einops", - "warp-lang==1.14.0", + "warp-lang==1.15.0.dev20260626", "matplotlib>=3.10.3", # keep consistent with isaac sim version "pillow==12.1.1", diff --git a/source/isaaclab_contrib/changelog.d/pin-newton-49ca73b.rst b/source/isaaclab_contrib/changelog.d/pin-newton-49ca73b.rst new file mode 100644 index 00000000000..d787e9c3882 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/pin-newton-49ca73b.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed two-way rigid-deformable contact reactions with Newton shape margins. 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 index b47371b4ec6..d12ac49a241 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py @@ -472,6 +472,7 @@ def _apply_reactions(cls, state: State, state_prev: State, dt: float) -> None: model.body_com, model.shape_body, model.shape_material_mu, + model.shape_margin, float(model.soft_contact_ke), float(model.soft_contact_kd), float(model.soft_contact_mu), 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 index 8faf468dee8..e42166acfbb 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py @@ -415,6 +415,7 @@ def _apply_reactions(cls, state: State, state_prev: State, dt: float) -> None: model.body_com, model.shape_body, model.shape_material_mu, + model.shape_margin, float(model.soft_contact_ke), float(model.soft_contact_kd), float(model.soft_contact_mu), diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/kernels.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/kernels.py index 179f62e5336..54aaf2e57b5 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/kernels.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/kernels.py @@ -300,6 +300,7 @@ def _kernel_body_particle_reaction( body_com: wp.array(dtype=wp.vec3), shape_body: wp.array(dtype=wp.int32), shape_material_mu: wp.array(dtype=wp.float32), + shape_margin: wp.array(dtype=wp.float32), soft_contact_ke: float, soft_contact_kd: float, soft_contact_mu: float, @@ -359,6 +360,7 @@ def _kernel_body_particle_reaction( contact_body_pos, contact_body_vel, contact_normal, + shape_margin, dt, ) diff --git a/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py b/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py index 331e0dfc288..d835f8fb458 100644 --- a/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py +++ b/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py @@ -247,9 +247,9 @@ def test_smoke_featherstone_kinematic(sim): 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: +def _run_lateral_rigid_cube_response(coupling_mode: str, rigid_solver: 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: + with _coupled_sim_context(_make_coupled_cfg(coupling_mode, rigid_solver)) as sim: sim._app_control_on_stop_handle = None rigid_cube, deformable_cube = generate_lateral_rigid_and_deformable_cubes() sim.reset() @@ -267,10 +267,11 @@ def _run_lateral_rigid_cube_response(coupling_mode: str) -> float: return rigid_cube.data.root_pos_w.torch[0, 0].item() - initial_rigid_x -def test_two_way_coupling_applies_reaction_to_rigid_body(): +@pytest.mark.parametrize("rigid_solver", ["mjwarp", "featherstone"]) +def test_two_way_coupling_applies_reaction_to_rigid_body(rigid_solver: str): """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") + one_way_dx = _run_lateral_rigid_cube_response("one_way", rigid_solver) + two_way_dx = _run_lateral_rigid_cube_response("two_way", rigid_solver) assert abs(one_way_dx) < 1e-2 assert two_way_dx > one_way_dx + 1e-2 diff --git a/source/isaaclab_newton/changelog.d/pin-newton-2064e3b.rst b/source/isaaclab_newton/changelog.d/pin-newton-2064e3b.rst new file mode 100644 index 00000000000..c341c8b47d4 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/pin-newton-2064e3b.rst @@ -0,0 +1,12 @@ +Changed +^^^^^^^ + +* Changed the ``newton[sim]`` dependency pin to Newton commit + ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee``. Projects that install Newton + separately should use this commit with ``warp-lang==1.15.0.dev20260626``. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_newton.physics.NewtonKaminoManager` reset + compatibility with Newton's ``SolverKamino.ResetConfig`` API. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py index 88894b3915a..292c2c04714 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py @@ -34,18 +34,17 @@ class NewtonKaminoManager(NewtonManager): def _forward_kamino(cls, world_mask: wp.array | None = None) -> None: """Kamino-specific forward kinematics via ``solver.reset()``. - Kamino's ``joint_q`` / ``joint_u`` include coordinates for **all** joints - (including free joints), so we pass Newton's full state arrays directly. + ``SolverKamino.ResetConfig.from_joints()`` consumes Newton's full + canonical state arrays directly, including free-joint coordinates. Args: world_mask: Per-world mask indicating which worlds to reset. Shape ``(num_worlds,)``, dtype ``wp.bool``. If None, resets all worlds. """ cls._solver.reset( - cls._state_0, - joint_q=cls._state_0.joint_q, - joint_u=cls._state_0.joint_qd, + state=cls._state_0, world_mask=world_mask, + config=SolverKamino.ResetConfig.from_joints(), ) @classmethod diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index 2c3a9b09a81..b8ec9f9118e 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -27,7 +27,7 @@ all = [ "prettytable>=3.3.0", "PyOpenGL-accelerate>=3.1.0", "pyglet>=2.1.6,<3", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", ] [tool.setuptools] diff --git a/source/isaaclab_ovphysx/changelog.d/pin-newton-49ca73b.rst b/source/isaaclab_ovphysx/changelog.d/pin-newton-49ca73b.rst new file mode 100644 index 00000000000..d46fdead348 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/pin-newton-49ca73b.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed center-of-mass writes in :class:`~isaaclab_ovphysx.assets.RigidObject`, + :class:`~isaaclab_ovphysx.assets.RigidObjectCollection`, and + :class:`~isaaclab_ovphysx.assets.Articulation` for compatibility with Warp + 1.15. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py index 1c7566a55ae..1b5b75a47f8 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py @@ -2181,7 +2181,7 @@ def set_coms_index( self._data._body_com_pose_b.timestamp = self._data._sim_timestamp self._data._reset_body_com_pose_b_dependents() cpu_env_ids = self._get_cpu_env_ids(env_ids) - wp.copy(self.data._cpu_body_coms, self._data._body_com_pose_b.data) + wp.copy(self.data._cpu_body_coms, self._data._body_com_pose_b.data.view(wp.float32)) self._root_view.set_attribute(TT.BODY_COM_POSE, self.data._cpu_body_coms, indices=cpu_env_ids) def set_coms_mask( @@ -2224,7 +2224,7 @@ def set_coms_mask( ) self._data._body_com_pose_b.timestamp = self._data._sim_timestamp self._data._reset_body_com_pose_b_dependents() - wp.copy(self.data._cpu_body_coms, self._data._body_com_pose_b.data) + wp.copy(self.data._cpu_body_coms, self._data._body_com_pose_b.data.view(wp.float32)) self._root_view.set_attribute( TT.BODY_COM_POSE, self.data._cpu_body_coms, mask=self._get_cpu_env_mask(env_mask_wp) ) 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 index f0df0e11255..6b2c41f2bba 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/rigid_object.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/rigid_object/rigid_object.py @@ -782,7 +782,7 @@ def set_coms_index( 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) + wp.copy(self._cpu_body_coms, self.data._body_com_pose_b.data.view(wp.float32)) # Wheel binding shape is (N, 7); squeeze singleton body dim with a flat float32 view. self._root_view.set_attribute( TT.RIGID_BODY_COM_POSE, self._cpu_body_coms.reshape((self._num_instances, 7)), indices=cpu_env_ids @@ -820,7 +820,7 @@ def set_coms_mask( 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) + wp.copy(self._cpu_body_coms, self.data._body_com_pose_b.data.view(wp.float32)) self._root_view.set_attribute( TT.RIGID_BODY_COM_POSE, self._cpu_body_coms.reshape((self._num_instances, 7)), 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 index f82d7337be8..87876bc43eb 100644 --- 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 @@ -907,7 +907,7 @@ def set_coms_index( ) # 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) + wp.copy(self.data._cpu_body_coms, self.data._body_com_pose_b.data.view(wp.float32)) self._binding_write( TT.BODY_COM_POSE, self.data._cpu_body_coms, @@ -954,7 +954,7 @@ def set_coms_mask( ) # 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) + wp.copy(self.data._cpu_body_coms, self.data._body_com_pose_b.data.view(wp.float32)) self._binding_write( TT.BODY_COM_POSE, self.data._cpu_body_coms, diff --git a/source/isaaclab_physx/changelog.d/pin-newton-2064e3b.rst b/source/isaaclab_physx/changelog.d/pin-newton-2064e3b.rst new file mode 100644 index 00000000000..8ddb0e8041c --- /dev/null +++ b/source/isaaclab_physx/changelog.d/pin-newton-2064e3b.rst @@ -0,0 +1,12 @@ +Changed +^^^^^^^ + +* Changed the ``newton[sim]`` dependency pin to Newton commit + ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee``. Projects that install Newton + separately should use this commit with ``warp-lang==1.15.0.dev20260626``. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_physx.assets.RigidObject` center-of-mass writes for + compatibility with Warp 1.15. diff --git a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py index 09659e83eec..16c9801cb47 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py @@ -817,7 +817,7 @@ def set_coms_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_coms, self.data._body_com_pose_b.data) + wp.copy(self._cpu_body_coms, self.data._body_com_pose_b.data.view(wp.float32)) self.root_view.set_coms(self._cpu_body_coms, indices=cpu_env_ids) def set_coms_mask( diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index e6eb574b1d1..054588f698c 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -24,7 +24,7 @@ Repository = "https://github.com/isaac-sim/IsaacLab" [project.optional-dependencies] newton = [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", ] [tool.setuptools] diff --git a/source/isaaclab_visualizers/changelog.d/pin-newton-2064e3b.rst b/source/isaaclab_visualizers/changelog.d/pin-newton-2064e3b.rst new file mode 100644 index 00000000000..6068155824c --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/pin-newton-2064e3b.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Changed the ``newton[sim]`` dependency pin to Newton commit + ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee``. Projects that install Newton + separately should use this commit with ``warp-lang==1.15.0.dev20260626``. diff --git a/source/isaaclab_visualizers/pyproject.toml b/source/isaaclab_visualizers/pyproject.toml index 3f632d689f0..1c0ed0161fe 100644 --- a/source/isaaclab_visualizers/pyproject.toml +++ b/source/isaaclab_visualizers/pyproject.toml @@ -29,24 +29,24 @@ Repository = "https://github.com/isaac-sim/IsaacLab" kit = [] newton = [ "warp-lang", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", "PyOpenGL-accelerate", "pyglet>=2.1.6,<3", "imgui-bundle>=1.92.5", "typing-extensions>=4.15.0", ] rerun = [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", "rerun-sdk>=0.29.0", "pyarrow==22.0.0", ] viser = [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", "viser>=1.0.16", ] all = [ "imgui-bundle>=1.92.5", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", "PyOpenGL-accelerate", "pyglet>=2.1.6,<3", # Match rerun-sdk's supported Arrow stack and avoid resolver drift across environments. diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index 31ed6175059..3fecbfdc84b 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.14.0", + "warp-lang==1.15.0.dev20260626", "matplotlib>=3.10.3", # make sure this is consistent with isaac sim version "pillow==12.1.1", @@ -87,8 +87,8 @@ pyproject.optional-dependencies.all = [ # https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab_newton/setup.py # ================================================================================ { "newton" = [ - "warp-lang==1.14.0", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@79e95bf5571d70a0a46c8eaedc80644531d27368", + "warp-lang==1.15.0.dev20260626", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", "PyOpenGL-accelerate==3.1.10" ] }, # ================================================================================ From fce86c92bef221be791702cb14912bb99f079287 Mon Sep 17 00:00:00 2001 From: Piotr Barejko Date: Tue, 30 Jun 2026 22:40:27 -0700 Subject: [PATCH 058/139] =?UTF-8?q?Add=20ISAAC=5FLAB=5FOVRTX=5FREAD=5FGPU?= =?UTF-8?q?=5FTRANSFORMS=20to=20control=20GPU=20transforms=20for=E2=80=A6?= =?UTF-8?q?=20(#6311)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../changelog.d/ovrtx-read-gpu-transforms-env.rst | 5 +++++ .../isaaclab_ov/renderers/ovrtx_renderer.py | 13 ++++++++++++- .../ovrtx-read-gpu-transforms-correctness.skip | 1 + source/isaaclab_tasks/test/rendering_test_utils.py | 3 ++- 4 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/ovrtx-read-gpu-transforms-env.rst create mode 100644 source/isaaclab_tasks/changelog.d/ovrtx-read-gpu-transforms-correctness.skip diff --git a/source/isaaclab_ov/changelog.d/ovrtx-read-gpu-transforms-env.rst b/source/isaaclab_ov/changelog.d/ovrtx-read-gpu-transforms-env.rst new file mode 100644 index 00000000000..8bb3704a373 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovrtx-read-gpu-transforms-env.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added the ``ISAAC_LAB_OVRTX_READ_GPU_TRANSFORMS`` environment variable to control whether + :class:`~isaaclab_ov.renderers.OVRTXRenderer` enables OVRTX GPU transform reads. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 1a515075f52..0ed157fddc7 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -93,6 +93,7 @@ "Install Isaac Lab with the 'all' extra (`pip install isaaclab[all]`) or install the " "isaaclab-ppisp extension from the Isaac Lab source checkout." ) +_READ_GPU_TRANSFORMS_ENV = "ISAAC_LAB_OVRTX_READ_GPU_TRANSFORMS" def _raise_missing_ppisp_error(exc: ModuleNotFoundError) -> NoReturn: @@ -103,6 +104,16 @@ def _raise_missing_ppisp_error(exc: ModuleNotFoundError) -> NoReturn: raise ModuleNotFoundError(_PPISP_IMPORT_ERROR_MESSAGE, name="isaaclab_ppisp") from exc +def _read_gpu_transforms_enabled() -> bool: + """Return whether OVRTX should read GPU transforms from its internal transform cache.""" + value = os.environ.get(_READ_GPU_TRANSFORMS_ENV, "1").strip() + if value not in {"0", "1"}: + raise ValueError( + f"Invalid value for environment variable `{_READ_GPU_TRANSFORMS_ENV}`: {value}. Expected 0 or 1." + ) + return value == "1" + + def _resolve_rtx_minimal_mode(data_types: list[str]) -> int | None: """Resolve the RTX minimal mode from data types. @@ -272,7 +283,7 @@ def __init__(self, cfg: OVRTXRendererCfg): OVRTX_CONFIG = RendererConfig( log_file_path=self.cfg.log_file_path, log_level=self.cfg.log_level, - read_gpu_transforms=True, + read_gpu_transforms=_read_gpu_transforms_enabled(), keep_system_alive=True, ) self._renderer = Renderer(OVRTX_CONFIG) diff --git a/source/isaaclab_tasks/changelog.d/ovrtx-read-gpu-transforms-correctness.skip b/source/isaaclab_tasks/changelog.d/ovrtx-read-gpu-transforms-correctness.skip new file mode 100644 index 00000000000..b3885aff637 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/ovrtx-read-gpu-transforms-correctness.skip @@ -0,0 +1 @@ +Skipped changelog entry for kitless rendering-correctness test configuration changes. diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index 8c186b9d142..0e4a8433a84 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -432,12 +432,13 @@ def make_require_ovlibs_install_fixture(): """ @pytest.fixture(autouse=True) - def _require_ovlibs_install(request): + def _require_ovlibs_install(request, monkeypatch: pytest.MonkeyPatch): callspec = getattr(request.node, "callspec", None) if callspec is None: return if callspec.params.get("renderer") == "ovrtx_renderer": + monkeypatch.setenv("ISAAC_LAB_OVRTX_READ_GPU_TRANSFORMS", "0") try: import ovrtx From e954d006451cb12e7f388df0d859a288b02a98f6 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:34:03 +0000 Subject: [PATCH 059/139] [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: 8.0.0 → 8.0.1 - isaaclab_contrib: 0.4.5 → 0.4.6 - isaaclab_newton: 1.5.0 → 1.5.1 - isaaclab_ov: 0.5.2 → 0.5.3 - isaaclab_ovphysx: 5.0.0 → 5.0.1 - isaaclab_physx: 2.6.0 → 2.6.1 - isaaclab_tasks: 8.1.1 → 8.1.2 - isaaclab_visualizers: 1.0.3 → 1.0.4 --- .../isaaclab/changelog.d/pin-newton-2064e3b.rst | 8 -------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 13 +++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../changelog.d/pin-newton-49ca73b.rst | 4 ---- source/isaaclab_contrib/config/extension.toml | 2 +- source/isaaclab_contrib/docs/CHANGELOG.rst | 9 +++++++++ source/isaaclab_contrib/pyproject.toml | 2 +- .../changelog.d/pin-newton-2064e3b.rst | 12 ------------ source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 17 +++++++++++++++++ source/isaaclab_newton/pyproject.toml | 2 +- .../ovrtx-read-gpu-transforms-env.rst | 5 ----- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 10 ++++++++++ source/isaaclab_ov/pyproject.toml | 2 +- .../changelog.d/pin-newton-49ca73b.rst | 7 ------- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 12 ++++++++++++ source/isaaclab_ovphysx/pyproject.toml | 2 +- .../changelog.d/pin-newton-2064e3b.rst | 12 ------------ source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 17 +++++++++++++++++ source/isaaclab_physx/pyproject.toml | 2 +- .../cartpole-distant-light-update.rst | 10 ---------- .../ovrtx-read-gpu-transforms-correctness.skip | 1 - source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 15 +++++++++++++++ source/isaaclab_tasks/pyproject.toml | 2 +- .../changelog.d/pin-newton-2064e3b.rst | 6 ------ .../isaaclab_visualizers/config/extension.toml | 2 +- source/isaaclab_visualizers/docs/CHANGELOG.rst | 11 +++++++++++ source/isaaclab_visualizers/pyproject.toml | 2 +- 33 files changed, 120 insertions(+), 81 deletions(-) delete mode 100644 source/isaaclab/changelog.d/pin-newton-2064e3b.rst delete mode 100644 source/isaaclab_contrib/changelog.d/pin-newton-49ca73b.rst delete mode 100644 source/isaaclab_newton/changelog.d/pin-newton-2064e3b.rst delete mode 100644 source/isaaclab_ov/changelog.d/ovrtx-read-gpu-transforms-env.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/pin-newton-49ca73b.rst delete mode 100644 source/isaaclab_physx/changelog.d/pin-newton-2064e3b.rst delete mode 100644 source/isaaclab_tasks/changelog.d/cartpole-distant-light-update.rst delete mode 100644 source/isaaclab_tasks/changelog.d/ovrtx-read-gpu-transforms-correctness.skip delete mode 100644 source/isaaclab_visualizers/changelog.d/pin-newton-2064e3b.rst diff --git a/source/isaaclab/changelog.d/pin-newton-2064e3b.rst b/source/isaaclab/changelog.d/pin-newton-2064e3b.rst deleted file mode 100644 index d9fd65fb87c..00000000000 --- a/source/isaaclab/changelog.d/pin-newton-2064e3b.rst +++ /dev/null @@ -1,8 +0,0 @@ -Changed -^^^^^^^ - -* Changed the Isaac Lab wheel's ``newton[sim]`` dependency pin to Newton - commit ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee`` and the core - ``warp-lang`` dependency pin from ``1.14.0`` to - ``1.15.0.dev20260626``. Projects that install these dependencies separately - should use the same versions. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 3e5a72eaec2..d3465e49b32 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 = "8.0.0" +version = "8.0.1" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 11e7bf892b0..1c7b93de714 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,19 @@ Changelog --------- +8.0.1 (2026-07-01) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed the Isaac Lab wheel's ``newton[sim]`` dependency pin to Newton + commit ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee`` and the core + ``warp-lang`` dependency pin from ``1.14.0`` to + ``1.15.0.dev20260626``. Projects that install these dependencies separately + should use the same versions. + + 8.0.0 (2026-06-30) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 6e1964c5c90..68fb7978c89 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.0.0" +version = "8.0.1" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_contrib/changelog.d/pin-newton-49ca73b.rst b/source/isaaclab_contrib/changelog.d/pin-newton-49ca73b.rst deleted file mode 100644 index d787e9c3882..00000000000 --- a/source/isaaclab_contrib/changelog.d/pin-newton-49ca73b.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Fixed two-way rigid-deformable contact reactions with Newton shape margins. diff --git a/source/isaaclab_contrib/config/extension.toml b/source/isaaclab_contrib/config/extension.toml index 63cf890ace8..733f344c3de 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.4.5" +version = "0.4.6" # Description title = "Isaac Lab External Contributions" diff --git a/source/isaaclab_contrib/docs/CHANGELOG.rst b/source/isaaclab_contrib/docs/CHANGELOG.rst index 639fff17f6e..9ddd1a6d493 100644 --- a/source/isaaclab_contrib/docs/CHANGELOG.rst +++ b/source/isaaclab_contrib/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +0.4.6 (2026-07-01) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed two-way rigid-deformable contact reactions with Newton shape margins. + + 0.4.5 (2026-06-08) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_contrib/pyproject.toml b/source/isaaclab_contrib/pyproject.toml index 00dc87b8d4b..dff0341c6dd 100644 --- a/source/isaaclab_contrib/pyproject.toml +++ b/source/isaaclab_contrib/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_contrib" -version = "0.4.5" +version = "0.4.6" description = "An extension used to stage and integrate externally contributed features and implementations." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_newton/changelog.d/pin-newton-2064e3b.rst b/source/isaaclab_newton/changelog.d/pin-newton-2064e3b.rst deleted file mode 100644 index c341c8b47d4..00000000000 --- a/source/isaaclab_newton/changelog.d/pin-newton-2064e3b.rst +++ /dev/null @@ -1,12 +0,0 @@ -Changed -^^^^^^^ - -* Changed the ``newton[sim]`` dependency pin to Newton commit - ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee``. Projects that install Newton - separately should use this commit with ``warp-lang==1.15.0.dev20260626``. - -Fixed -^^^^^ - -* Fixed :class:`~isaaclab_newton.physics.NewtonKaminoManager` reset - compatibility with Newton's ``SolverKamino.ResetConfig`` API. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index fc9607b6c18..ebdec1167e1 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 = "1.5.0" +version = "1.5.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 6a49ddce9da..8a642cda939 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog --------- +1.5.1 (2026-07-01) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed the ``newton[sim]`` dependency pin to Newton commit + ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee``. Projects that install Newton + separately should use this commit with ``warp-lang==1.15.0.dev20260626``. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_newton.physics.NewtonKaminoManager` reset + compatibility with Newton's ``SolverKamino.ResetConfig`` API. + + 1.5.0 (2026-06-28) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index b8ec9f9118e..c01a6cc1eba 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_newton" -version = "1.5.0" +version = "1.5.1" description = "Extension providing IsaacLab with Newton specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ov/changelog.d/ovrtx-read-gpu-transforms-env.rst b/source/isaaclab_ov/changelog.d/ovrtx-read-gpu-transforms-env.rst deleted file mode 100644 index 8bb3704a373..00000000000 --- a/source/isaaclab_ov/changelog.d/ovrtx-read-gpu-transforms-env.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added the ``ISAAC_LAB_OVRTX_READ_GPU_TRANSFORMS`` environment variable to control whether - :class:`~isaaclab_ov.renderers.OVRTXRenderer` enables OVRTX GPU transform reads. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index e5170159518..5acd5995397 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.5.2" +version = "0.5.3" 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 aa17465a7ae..29a6576b951 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.5.3 (2026-07-01) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the ``ISAAC_LAB_OVRTX_READ_GPU_TRANSFORMS`` environment variable to control whether + :class:`~isaaclab_ov.renderers.OVRTXRenderer` enables OVRTX GPU transform reads. + + 0.5.2 (2026-06-26) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/pyproject.toml b/source/isaaclab_ov/pyproject.toml index 3831b4bdf91..022cc51d789 100644 --- a/source/isaaclab_ov/pyproject.toml +++ b/source/isaaclab_ov/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ov" -version = "0.5.2" +version = "0.5.3" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ovphysx/changelog.d/pin-newton-49ca73b.rst b/source/isaaclab_ovphysx/changelog.d/pin-newton-49ca73b.rst deleted file mode 100644 index d46fdead348..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/pin-newton-49ca73b.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed center-of-mass writes in :class:`~isaaclab_ovphysx.assets.RigidObject`, - :class:`~isaaclab_ovphysx.assets.RigidObjectCollection`, and - :class:`~isaaclab_ovphysx.assets.Articulation` for compatibility with Warp - 1.15. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index b820371d388..a3253f6b5fc 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 = "5.0.0" +version = "5.0.1" # Description title = "OvPhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_ovphysx/docs/CHANGELOG.rst b/source/isaaclab_ovphysx/docs/CHANGELOG.rst index a2e2148ebd2..9b8010163d1 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +5.0.1 (2026-07-01) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed center-of-mass writes in :class:`~isaaclab_ovphysx.assets.RigidObject`, + :class:`~isaaclab_ovphysx.assets.RigidObjectCollection`, and + :class:`~isaaclab_ovphysx.assets.Articulation` for compatibility with Warp + 1.15. + + 5.0.0 (2026-06-27) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index 46ba8f83b3a..e39a2a01bd9 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ovphysx" -version = "5.0.0" +version = "5.0.1" description = "Extension providing IsaacLab with ovphysx/TensorBindingsAPI specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/pin-newton-2064e3b.rst b/source/isaaclab_physx/changelog.d/pin-newton-2064e3b.rst deleted file mode 100644 index 8ddb0e8041c..00000000000 --- a/source/isaaclab_physx/changelog.d/pin-newton-2064e3b.rst +++ /dev/null @@ -1,12 +0,0 @@ -Changed -^^^^^^^ - -* Changed the ``newton[sim]`` dependency pin to Newton commit - ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee``. Projects that install Newton - separately should use this commit with ``warp-lang==1.15.0.dev20260626``. - -Fixed -^^^^^ - -* Fixed :class:`~isaaclab_physx.assets.RigidObject` center-of-mass writes for - compatibility with Warp 1.15. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 2750173a9c2..bdb57d6c746 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 = "2.6.0" +version = "2.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 eea798d1fba..473f5108836 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog --------- +2.6.1 (2026-07-01) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed the ``newton[sim]`` dependency pin to Newton commit + ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee``. Projects that install Newton + separately should use this commit with ``warp-lang==1.15.0.dev20260626``. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_physx.assets.RigidObject` center-of-mass writes for + compatibility with Warp 1.15. + + 2.6.0 (2026-06-30) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index 054588f698c..898c1ba20a7 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "2.6.0" +version = "2.6.1" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/cartpole-distant-light-update.rst b/source/isaaclab_tasks/changelog.d/cartpole-distant-light-update.rst deleted file mode 100644 index 41a31f07ed3..00000000000 --- a/source/isaaclab_tasks/changelog.d/cartpole-distant-light-update.rst +++ /dev/null @@ -1,10 +0,0 @@ -Changed -^^^^^^^ - -* Changed the Cartpole task lighting from :class:`~isaaclab.sim.DomeLightCfg` to - :class:`~isaaclab.sim.DistantLightCfg` with unified light - intensity (``CARTPOLE_DISTANT_LIGHT_INTENSITY = 2000.0``), color - (``CARTPOLE_DISTANT_LIGHT_COLOR = (1.0, 1.0, 1.0)``), and orientation - (``CARTPOLE_DISTANT_LIGHT_ORIENTATION``, -45° pitch and -45° yaw) across the direct, camera, - and manager-based variants via shared constants in ``constants.py``. Updated lighting-dependent - test golden images accordingly. diff --git a/source/isaaclab_tasks/changelog.d/ovrtx-read-gpu-transforms-correctness.skip b/source/isaaclab_tasks/changelog.d/ovrtx-read-gpu-transforms-correctness.skip deleted file mode 100644 index b3885aff637..00000000000 --- a/source/isaaclab_tasks/changelog.d/ovrtx-read-gpu-transforms-correctness.skip +++ /dev/null @@ -1 +0,0 @@ -Skipped changelog entry for kitless rendering-correctness test configuration changes. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 7bf74b12903..ccae571210e 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 = "8.1.1" +version = "8.1.2" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index d221795d63c..a101818c164 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,21 @@ Changelog --------- +8.1.2 (2026-07-01) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed the Cartpole task lighting from :class:`~isaaclab.sim.DomeLightCfg` to + :class:`~isaaclab.sim.DistantLightCfg` with unified light + intensity (``CARTPOLE_DISTANT_LIGHT_INTENSITY = 2000.0``), color + (``CARTPOLE_DISTANT_LIGHT_COLOR = (1.0, 1.0, 1.0)``), and orientation + (``CARTPOLE_DISTANT_LIGHT_ORIENTATION``, -45° pitch and -45° yaw) across the direct, camera, + and manager-based variants via shared constants in ``constants.py``. Updated lighting-dependent + test golden images accordingly. + + 8.1.1 (2026-06-28) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index abb2c4248a9..f09c5c86a8f 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.1.1" +version = "8.1.2" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_visualizers/changelog.d/pin-newton-2064e3b.rst b/source/isaaclab_visualizers/changelog.d/pin-newton-2064e3b.rst deleted file mode 100644 index 6068155824c..00000000000 --- a/source/isaaclab_visualizers/changelog.d/pin-newton-2064e3b.rst +++ /dev/null @@ -1,6 +0,0 @@ -Changed -^^^^^^^ - -* Changed the ``newton[sim]`` dependency pin to Newton commit - ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee``. Projects that install Newton - separately should use this commit with ``warp-lang==1.15.0.dev20260626``. diff --git a/source/isaaclab_visualizers/config/extension.toml b/source/isaaclab_visualizers/config/extension.toml index 9ae35ecda75..5f4716d57eb 100644 --- a/source/isaaclab_visualizers/config/extension.toml +++ b/source/isaaclab_visualizers/config/extension.toml @@ -1,7 +1,7 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "1.0.3" +version = "1.0.4" # Description category = "isaaclab" diff --git a/source/isaaclab_visualizers/docs/CHANGELOG.rst b/source/isaaclab_visualizers/docs/CHANGELOG.rst index 7b673de95fc..57ee6c175ca 100644 --- a/source/isaaclab_visualizers/docs/CHANGELOG.rst +++ b/source/isaaclab_visualizers/docs/CHANGELOG.rst @@ -1,6 +1,17 @@ Changelog --------- +1.0.4 (2026-07-01) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed the ``newton[sim]`` dependency pin to Newton commit + ``2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee``. Projects that install Newton + separately should use this commit with ``warp-lang==1.15.0.dev20260626``. + + 1.0.3 (2026-06-25) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_visualizers/pyproject.toml b/source/isaaclab_visualizers/pyproject.toml index 1c0ed0161fe..c3ba9d6f7d9 100644 --- a/source/isaaclab_visualizers/pyproject.toml +++ b/source/isaaclab_visualizers/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab-visualizers" -version = "1.0.3" +version = "1.0.4" description = "Visualizer backends for Isaac Lab (Kit, Newton, Rerun, Viser)." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 612306daea4628cc027108c146e4f2c3bbc5141a Mon Sep 17 00:00:00 2001 From: Piotr Barejko Date: Wed, 1 Jul 2026 01:10:14 -0700 Subject: [PATCH 060/139] Fix import for IsaacRtxRendererGlobalSettingsCfg (#6307) --- .../changelog.d/pbarejko-missing-imports.rst | 5 +++++ .../isaaclab_physx/renderers/__init__.pyi | 3 ++- .../test/renderers/test_isaac_rtx_renderer_cfg.py | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab_physx/changelog.d/pbarejko-missing-imports.rst create mode 100644 source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_cfg.py diff --git a/source/isaaclab_physx/changelog.d/pbarejko-missing-imports.rst b/source/isaaclab_physx/changelog.d/pbarejko-missing-imports.rst new file mode 100644 index 00000000000..9ea174c6131 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/pbarejko-missing-imports.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed the public export of + :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg`. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/renderers/__init__.pyi index 2b2097eb47b..343df8561a0 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/renderers/__init__.pyi @@ -6,7 +6,8 @@ __all__ = [ "IsaacRtxRenderer", "IsaacRtxRendererCfg", + "IsaacRtxRendererGlobalSettingsCfg", ] from .isaac_rtx_renderer import IsaacRtxRenderer -from .isaac_rtx_renderer_cfg import IsaacRtxRendererCfg +from .isaac_rtx_renderer_cfg import IsaacRtxRendererCfg, IsaacRtxRendererGlobalSettingsCfg diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_cfg.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_cfg.py new file mode 100644 index 00000000000..9401aeb3b06 --- /dev/null +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_cfg.py @@ -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 + +"""Tests for Isaac RTX renderer configuration.""" + +import isaaclab_physx.renderers as renderers +from isaaclab_physx.renderers.isaac_rtx_renderer_cfg import IsaacRtxRendererGlobalSettingsCfg + + +def test_global_settings_cfg_is_publicly_exported(): + """Test that the global settings config is exported from the renderer package.""" + assert renderers.IsaacRtxRendererGlobalSettingsCfg is IsaacRtxRendererGlobalSettingsCfg + assert "IsaacRtxRendererGlobalSettingsCfg" in renderers.__all__ From b0c8ab5d86ce8e3842296b8026d7f06eeab1b54a Mon Sep 17 00:00:00 2001 From: ooctipus Date: Wed, 1 Jul 2026 16:01:10 -0700 Subject: [PATCH 061/139] Reuse Newton visualizer frames in videos (#6282) ## Summary - reuse the active Newton visualizer framebuffer directly for `--video` - preserve its live camera, selected environments, and viewer-side scene markers without a second render pass - keep Kit and renderer-selected standalone Newton captures eager so pre-reset camera setup is unchanged - remove duplicate-viewer camera reconstruction, world-selection synchronization, overlay callbacks, and the associated standalone capture APIs - document that active-Newton capture uses the visualizer resolution and excludes Newton UI panels such as the tiled-camera panel With `VideoRecorderCfg.backend_source = "renderer"`, recording remains independent of active visualizers and uses the existing standalone capture path. ## Testing - `38 passed`: `test_video_recorder.py` and `test_newton_adapter.py` - fail-before-fix verification: the old recorder returned the standalone capture frame instead of binding the active Newton visualizer framebuffer - `./isaaclab.sh -d` passed; rendered HTML contains both stable `:pyobject:` excerpts - scoped pre-commit passed, including the LFS hook - repository-wide pre-commit passed with only the known unrelated golden-image LFS hook skipped - changelog fragment check passed --- docs/source/how-to/record_video.rst | 29 +- .../changelog.d/newton-video-visible-envs.rst | 5 + .../isaaclab/envs/utils/video_recorder.py | 269 +++----- .../isaaclab/envs/utils/video_recorder_cfg.py | 19 +- .../isaaclab/test/envs/test_video_recorder.py | 573 ++++++------------ .../newton-video-framebuffer.minor.rst | 5 + .../newton/newton_visualizer.py | 14 + .../test/test_newton_adapter.py | 17 + 8 files changed, 338 insertions(+), 593 deletions(-) create mode 100644 source/isaaclab/changelog.d/newton-video-visible-envs.rst create mode 100644 source/isaaclab_visualizers/changelog.d/newton-video-framebuffer.minor.rst diff --git a/docs/source/how-to/record_video.rst b/docs/source/how-to/record_video.rst index bd0b6ad7c3b..28b89710546 100644 --- a/docs/source/how-to/record_video.rst +++ b/docs/source/how-to/record_video.rst @@ -37,7 +37,7 @@ The video recording feature is implemented using the ``VideoRecorder`` class. Th and world-space perspective parameters ``eye`` and ``lookat`` (defaults to a diagonal view of the scene). * ``VideoRecorder`` (``isaaclab.envs.utils.video_recorder``) picks a video backend from the scene - (Kit vs Newton GL), builds the matching low-level capture object, and returns RGB frames via + (Kit vs Newton GL), reuses an active Newton visualizer when available, and returns RGB frames via ``render_rgb_array()``. * Direct RL, Direct MARL and manager-based RL environments copy the task's :class:`~isaaclab.envs.common.ViewerCfg` ``eye`` and ``lookat`` into those fields before the @@ -80,6 +80,15 @@ precedence and only one ``--video`` stream is recorded. Rerun records ``.rrd`` r the Rerun visualizer rather than producing ``--video`` clips, and Viser does not currently provide a ``--video`` recording backend. +When the Newton visualizer selects the backend, video capture reuses its framebuffer directly. For +example, +``visible_env_indices=[0, 1, 2, 3]`` makes both the live Newton view and the recorded clip contain +only those four simulation worlds. The same framebuffer also preserves the live camera and +viewer-side scene markers without a second Newton rendering pass. Newton UI panels, including the +tiled camera panel, are not part of the recorded scene framebuffer. This does not apply when +``VideoRecorderCfg.backend_source = "renderer"``, because renderer-selected capture is independent +of active visualizers. + Set ``VideoRecorderCfg.backend_source = "renderer"`` to ignore active visualizers and choose from the physics/renderer stack instead. In that mode, PhysX physics (``physics=physx``) or Isaac RTX (``renderer=isaacsim_rtx_renderer``) selects the Kit path. Newton physics (``physics=newton_mjwarp``) or @@ -90,27 +99,29 @@ Newton GL signals are present, the Kit path is chosen. .. literalinclude:: ../../../source/isaaclab/isaaclab/envs/utils/video_recorder.py :language: python - :lines: 38-59 + :pyobject: _select_video_backend Construction and dispatch -------------------------- When ``env_render_mode`` is ``"rgb_array"`` (as when wrappers or scripts request RGB frames for -video), the recorder instantiates the backend-specific helper and passes through ``eye``, ``lookat``, -and window size. +video), Kit and renderer-selected Newton captures are created before simulation reset. This lets the +Kit path register its fallback camera before physics initializes. Visualizer-selected Newton capture +instead binds to the initialized visualizer on the first frame and reuses its framebuffer. .. literalinclude:: ../../../source/isaaclab/isaaclab/envs/utils/video_recorder.py :language: python - :lines: 70-114 + :pyobject: VideoRecorder.__init__ Customising the camera view ---------------------------- -When ``--video`` is passed, the recording camera uses the same configured -position and look-at target as the active Kit or Newton visualizer when that visualizer drives backend -selection. Otherwise, the defaults come from +When ``--video`` is passed, active Newton visualization supplies the camera and framebuffer used by +the recording, including the visualizer's configured resolution. The Kit path copies the active Kit +visualizer's configured position and look-at target when it drives backend selection. Otherwise, the +defaults come from :class:`~isaaclab.envs.common.ViewerCfg`: * ``eye = (7.5, 7.5, 7.5)`` — camera position in world space (metres) @@ -160,7 +171,7 @@ Summary - Visualizer ``eye`` / ``lookat`` copied to ``/OmniverseKit_Persp`` + Replicator RGB * - ``--visualizer newton`` with default ``backend_source`` - Newton GL (``"newton_gl"``) - - Visualizer ``eye`` / ``lookat`` initially, then live Newton viewer camera sync per frame + - Active visualizer framebuffer (live camera, selected worlds, and markers) See also diff --git a/source/isaaclab/changelog.d/newton-video-visible-envs.rst b/source/isaaclab/changelog.d/newton-video-visible-envs.rst new file mode 100644 index 00000000000..9a440bb99b7 --- /dev/null +++ b/source/isaaclab/changelog.d/newton-video-visible-envs.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed Newton training videos to reuse the active visualizer framebuffer, preserving selected + environments, the live camera, and markers without a second rendering pass. diff --git a/source/isaaclab/isaaclab/envs/utils/video_recorder.py b/source/isaaclab/isaaclab/envs/utils/video_recorder.py index 0925b4a0ab1..93736999c8b 100644 --- a/source/isaaclab/isaaclab/envs/utils/video_recorder.py +++ b/source/isaaclab/isaaclab/envs/utils/video_recorder.py @@ -5,104 +5,58 @@ """Video recorder implementation. -Backend resolution (``--video`` + ``--visualizer``): - -1. **Active visualizer** - ``"kit"`` uses the Kit camera; ``"newton"`` uses the Newton GL viewer. - ``"viser"`` / ``"rerun"`` have no capture API and fall through to rule 2. -2. **Physics/renderer stack** - - - PhysX or Isaac RTX uses the Kit camera; - - Newton physics or Newton Warp uses the Newton GL viewer. - Kit wins when both signals present. Raises if nothing resolves. - -Set :attr:`~isaaclab.envs.utils.video_recorder_cfg.VideoRecorderCfg.backend_source` to ``"renderer"`` -to ignore active visualizers and record from the physics/renderer stack. - -Camera sync when a visualizer drives the backend: construction copies the visualizer config's -``eye`` / ``lookat`` into the recorder config; each :meth:`~VideoRecorder.render_rgb_array` -call then re-reads the Newton viewer's live ``camera.pos/pitch/yaw``. Kit video uses the -configured ``eye`` / ``lookat`` at construction time. - -See :mod:`video_recorder_cfg` for configuration. +An active Kit or Newton visualizer selects the capture backend by default. Newton video reuses the +active visualizer framebuffer, while renderer-selected Newton video uses a separate headless viewer. """ from __future__ import annotations -import logging -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, cast import numpy as np if TYPE_CHECKING: + from isaaclab_newton.video_recording.newton_gl_perspective_video import NewtonGlPerspectiveVideo + from isaaclab_physx.video_recording.isaacsim_kit_perspective_video import IsaacsimKitPerspectiveVideo + from isaaclab_visualizers.newton import NewtonVisualizer + from isaaclab.scene import InteractiveScene + from isaaclab.visualizers import VisualizerCfg from .video_recorder_cfg import VideoRecorderCfg -logger = logging.getLogger(__name__) - _VideoBackend = Literal["kit", "newton_gl"] -# visualizer types that map to a supported video backend. -# viser and rerun are intentionally absent - they have no video-capture API. -_VISUALIZER_TO_VIDEO_BACKEND: dict[str, _VideoBackend] = { - "kit": "kit", - "newton": "newton_gl", -} - -def _resolve_video_backend( - scene: InteractiveScene, backend_source: str = "visualizer" -) -> tuple[_VideoBackend, str | None]: - """Return ``(backend, matched_visualizer_type)`` for the active scene. - - ``matched_visualizer_type`` is ``"kit"`` / ``"newton"`` when a visualizer drove the - selection, or ``None`` when the physics/renderer preset stack was used instead. +def _select_video_backend(scene: InteractiveScene, backend_source: str) -> tuple[_VideoBackend, VisualizerCfg | None]: + """Resolve the capture backend and visualizer configuration that selected it. Args: - scene: The interactive scene that owns the sim context. - backend_source: ``"visualizer"`` to let active visualizers choose the backend, or ``"renderer"`` - to ignore active visualizers and use the physics/renderer stack. + scene: The interactive scene that owns the simulation context. + backend_source: Source used to select the capture backend. + + Returns: + Backend identifier and the visualizer configuration that selected it, if any. Raises: - RuntimeError: If no supported backend is detected. + ValueError: If backend_source is invalid. + RuntimeError: If no supported backend is available. """ if backend_source not in ("visualizer", "renderer"): raise ValueError("VideoRecorderCfg.backend_source must be either 'visualizer' or 'renderer'.") - # Prefer the visualizer backend when --visualizer is active alongside --video. - visualizer_types: list[str] = scene.sim.resolve_visualizer_types() if backend_source == "visualizer" else [] - if visualizer_types: - supported_visualizers = [viz for viz in ("kit", "newton") if viz in visualizer_types] - if len(supported_visualizers) > 1: - logger.warning( - "[VideoRecorder] Multiple video-capable visualizers are active (%s), but --video records one " - "env.render() stream. Using Kit because it has priority. Run with only --viz newton to record " - "a Newton GL video.", - supported_visualizers, - ) - # kit takes priority when multiple visualizers are active - for preferred in ("kit", "newton"): - if preferred in visualizer_types: - backend = _VISUALIZER_TO_VIDEO_BACKEND[preferred] - logger.debug("[VideoRecorder] Using '%s' backend from active '%s' visualizer.", backend, preferred) - return backend, preferred - # only unsupported visualizer types (viser, rerun) are active. - logger.warning( - "[VideoRecorder] Active visualizer(s) %s do not support video capture; " - "falling back to physics/renderer stack detection.", - visualizer_types, - ) - - # fall back to physics/renderer preset stack detection. - sim = scene.sim - physics_name = sim.physics_manager.__name__.lower() - renderer_types: list[str] = scene._sensor_renderer_types() - - use_kit = "physx" in physics_name or "isaac_rtx" in renderer_types - use_newton_gl = "newton" in physics_name or "newton_warp" in renderer_types - - if use_kit: + if backend_source == "visualizer": + visualizer_cfgs = scene.sim._resolve_visualizer_cfgs() + for visualizer_type, backend in (("kit", "kit"), ("newton", "newton_gl")): + for visualizer_cfg in visualizer_cfgs: + if visualizer_cfg.visualizer_type == visualizer_type: + return backend, visualizer_cfg + + physics_name = scene.sim.physics_manager.__name__.lower() + renderer_types = scene._sensor_renderer_types() + if "physx" in physics_name or "isaac_rtx" in renderer_types: return "kit", None - if use_newton_gl: + if "newton" in physics_name or "newton_warp" in renderer_types: return "newton_gl", None raise RuntimeError( "Video recording (--video) requires a supported backend: " @@ -111,48 +65,6 @@ def _resolve_video_backend( ) -def _sync_camera_from_visualizer( - scene: InteractiveScene, - visualizer_type: str, - cfg: VideoRecorderCfg, -) -> None: - """Overwrite ``cfg.eye`` and ``cfg.lookat`` from the active visualizer. - - Args: - scene: The interactive scene that owns the sim context. - visualizer_type: The visualizer type string matched by ``_resolve_video_backend`` - (e.g. ``"kit"`` or ``"newton"``). - cfg: The recorder configuration to update in place. - """ - try: - resolved_cfgs = scene.sim._resolve_visualizer_cfgs() - except Exception as exc: - logger.debug("[VideoRecorder] Could not resolve visualizer cfgs for camera sync: %s", exc) - return - - for vcfg in resolved_cfgs: - if getattr(vcfg, "visualizer_type", None) != visualizer_type: - continue - pos = getattr(vcfg, "eye", None) - tgt = getattr(vcfg, "lookat", None) - if pos is None or tgt is None: - break - cfg.eye = tuple(float(x) for x in pos) - cfg.lookat = tuple(float(x) for x in tgt) - logger.debug( - "[VideoRecorder] Camera synced from '%s' visualizer: position=%s, target=%s.", - visualizer_type, - cfg.eye, - cfg.lookat, - ) - return - - logger.debug( - "[VideoRecorder] Could not find eye/lookat on '%s' visualizer cfg; keeping existing camera values.", - visualizer_type, - ) - - class VideoRecorder: """Records perspective video frames from the scene's active renderer. @@ -164,92 +76,63 @@ class VideoRecorder: def __init__(self, cfg: VideoRecorderCfg, scene: InteractiveScene): self.cfg = cfg self._scene = scene - self._backend: _VideoBackend | None = None - self._capture = None - # visualizer type that drove backend selection (or None when using physics/renderer stack). - self._matched_visualizer: str | None = None - # live visualizer instance - looked up lazily on first render_rgb_array() call because - # visualizers are initialised by sim.reset(), which runs after VideoRecorder.__init__. - self._live_visualizer = None - - if cfg.env_render_mode == "rgb_array": - backend_source = getattr(cfg, "backend_source", "visualizer") - self._backend, self._matched_visualizer = _resolve_video_backend(scene, backend_source) - if self._matched_visualizer is not None: - _sync_camera_from_visualizer(scene, self._matched_visualizer, cfg) - if self._backend == "newton_gl": - try: - import pyglet as _pyglet # noqa: F401 - verify pyglet is available - except ImportError as e: - raise ImportError( - "The Newton GL video backend requires 'pyglet'. Install IsaacLab with './isaaclab.sh -i'." - ) from e - from isaaclab_newton.video_recording.newton_gl_perspective_video import ( - create_newton_gl_perspective_video, - ) - from isaaclab_newton.video_recording.newton_gl_perspective_video_cfg import NewtonGlPerspectiveVideoCfg + self._capture: NewtonGlPerspectiveVideo | IsaacsimKitPerspectiveVideo | NewtonVisualizer | None = None + self._use_newton_visualizer = False + + if cfg.env_render_mode != "rgb_array": + return + + backend, visualizer_cfg = _select_video_backend(scene, cfg.backend_source) + eye = cfg.eye if visualizer_cfg is None else visualizer_cfg.eye + lookat = cfg.lookat if visualizer_cfg is None else visualizer_cfg.lookat - ncfg = NewtonGlPerspectiveVideoCfg( + if backend == "newton_gl" and visualizer_cfg is not None: + self._use_newton_visualizer = True + elif backend == "newton_gl": + from isaaclab_newton.video_recording.newton_gl_perspective_video import ( + create_newton_gl_perspective_video, + ) + from isaaclab_newton.video_recording.newton_gl_perspective_video_cfg import NewtonGlPerspectiveVideoCfg + + self._capture = create_newton_gl_perspective_video( + NewtonGlPerspectiveVideoCfg( window_width=cfg.window_width, window_height=cfg.window_height, - eye=cfg.eye, - lookat=cfg.lookat, - ) - self._capture = create_newton_gl_perspective_video(ncfg) - else: - from isaaclab_physx.video_recording.isaacsim_kit_perspective_video import ( - create_isaacsim_kit_perspective_video, - ) - from isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg import ( - IsaacsimKitPerspectiveVideoCfg, + eye=eye, + lookat=lookat, ) + ) + else: + from isaaclab_physx.video_recording.isaacsim_kit_perspective_video import ( + create_isaacsim_kit_perspective_video, + ) + from isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg import ( + IsaacsimKitPerspectiveVideoCfg, + ) - kcfg = IsaacsimKitPerspectiveVideoCfg( - eye=cfg.eye, - lookat=cfg.lookat, + self._capture = create_isaacsim_kit_perspective_video( + IsaacsimKitPerspectiveVideoCfg( + eye=eye, + lookat=lookat, window_width=cfg.window_width, window_height=cfg.window_height, ) - self._capture = create_isaacsim_kit_perspective_video(kcfg) - - def _sync_newton_camera(self) -> None: - """Push the Newton visualizer's live camera pose into the capture object. - - Called once per :meth:`render_rgb_array` when a Newton visualizer is active. - The live visualizer instance is resolved lazily (visualizers are initialised by - ``sim.reset()``, which runs after ``VideoRecorder.__init__``). - """ - if self._live_visualizer is None: - for viz in self._scene.sim.visualizers: - if getattr(getattr(viz, "cfg", None), "visualizer_type", None) == "newton": - self._live_visualizer = viz - break - if self._live_visualizer is None: - return - - viewer = getattr(self._live_visualizer, "_viewer", None) - if viewer is None: - return - - import math - - cam = viewer.camera - pos = (float(cam.pos[0]), float(cam.pos[1]), float(cam.pos[2])) - yaw_rad = math.radians(float(cam.yaw)) - pitch_rad = math.radians(float(cam.pitch)) - dx = math.cos(pitch_rad) * math.cos(yaw_rad) - dy = math.cos(pitch_rad) * math.sin(yaw_rad) - dz = math.sin(pitch_rad) - target = (pos[0] + dx, pos[1] + dy, pos[2] + dz) - self._capture.update_camera(pos, target) + ) def render_rgb_array(self) -> np.ndarray | None: - """Return an RGB frame for the resolved backend. Fails if backend is unavailable.""" - if self._backend is None or self._capture is None: + """Return an RGB frame for the resolved backend.""" + if self._use_newton_visualizer and self._capture is None: + visualizer = next( + ( + visualizer + for visualizer in self._scene.sim.visualizers + if visualizer.cfg.visualizer_type == "newton" + ), + None, + ) + if visualizer is None: + raise RuntimeError("The Newton visualizer was selected for video capture but is not initialized.") + self._capture = cast("NewtonVisualizer", visualizer) + if self._capture is None: return None - if self._matched_visualizer == "newton": - # Newton GL camera state lives in the capture object and must be synced each frame - # to follow interactive viewer movement. - self._sync_newton_camera() - # Kit capture uses the configured eye/lookat applied to the recording camera at construction time. return self._capture.render_rgb_array() diff --git a/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py b/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py index f795ad114aa..e6268429cbf 100644 --- a/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py +++ b/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py @@ -36,12 +36,15 @@ class VideoRecorderCfg: """Perspective camera position in world space (metres). Direct RL / MARL and manager-based RL environments overwrite this from - :attr:`~isaaclab.envs.common.ViewerCfg.eye` before recording so ``--video`` matches the - task viewport for both Kit (PhysX / Isaac RTX) and Newton GL (Newton / OVRTX / etc.). + :attr:`~isaaclab.envs.common.ViewerCfg.eye`. Kit and renderer-selected Newton capture use this + value. Visualizer-selected Newton capture uses the active visualizer camera instead. """ lookat: tuple[float, float, float] = (0.0, 0.0, 0.0) - """Perspective camera look-at target in world space (metres). Set from ``ViewerCfg.lookat`` at env init.""" + """Perspective camera look-at target in world space (metres). + + Visualizer-selected Newton capture uses the active visualizer camera instead. + """ backend_source: Literal["visualizer", "renderer"] = "visualizer" """Source used to resolve the video capture backend. @@ -52,7 +55,13 @@ class VideoRecorderCfg: """ window_width: int = 1280 - """Width in pixels of the recorded frame.""" + """Width of the recorded frame in pixels. + + Visualizer-selected Newton capture uses the active visualizer width instead. + """ window_height: int = 720 - """Height in pixels of the recorded frame.""" + """Height of the recorded frame in pixels. + + Visualizer-selected Newton capture uses the active visualizer height instead. + """ diff --git a/source/isaaclab/test/envs/test_video_recorder.py b/source/isaaclab/test/envs/test_video_recorder.py index e22cbe65614..4a76036bf9b 100644 --- a/source/isaaclab/test/envs/test_video_recorder.py +++ b/source/isaaclab/test/envs/test_video_recorder.py @@ -2,9 +2,9 @@ # All rights reserved. # # SPDX-License-Identifier: BSD-3-Clause + """Unit tests for VideoRecorder.""" -import math import sys from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -12,433 +12,234 @@ import numpy as np import pytest -from isaaclab.envs.utils import video_recorder as _video_recorder_module -from isaaclab.envs.utils.video_recorder import VideoRecorder, _resolve_video_backend, _sync_camera_from_visualizer +from isaaclab.envs.utils.video_recorder import VideoRecorder, _select_video_backend pytestmark = pytest.mark.isaacsim_ci -_BLANK_720p = np.zeros((720, 1280, 3), dtype=np.uint8) -_DEFAULT_CFG = dict( - env_render_mode="rgb_array", - eye=(7.5, 7.5, 7.5), - lookat=(0.0, 0.0, 0.0), - backend_source="visualizer", - window_width=1280, - window_height=720, -) +_FRAME = np.zeros((8, 12, 3), dtype=np.uint8) +_DEFAULT_CFG = { + "env_render_mode": "rgb_array", + "eye": (7.5, 7.5, 7.5), + "lookat": (0.0, 0.0, 0.0), + "backend_source": "visualizer", + "window_width": 1280, + "window_height": 720, +} -def _create_recorder(**kw): - """Return a VideoRecorder with ``__init__`` bypassed and all deps mocked out.""" - backend = kw.pop("_backend", None) - matched_visualizer = kw.pop("_matched_visualizer", None) - live_visualizer = kw.pop("_live_visualizer", None) - recorder = object.__new__(VideoRecorder) - recorder.cfg = SimpleNamespace(**{**_DEFAULT_CFG, **kw}) - recorder._scene = MagicMock() - recorder._scene.sensors = {} - recorder._scene._sensor_renderer_types = MagicMock(return_value=[]) - recorder._scene.sim.visualizers = [] - recorder._backend = backend - recorder._matched_visualizer = matched_visualizer - recorder._live_visualizer = live_visualizer - cap = MagicMock() - cap.render_rgb_array = MagicMock(return_value=_BLANK_720p) - recorder._capture = cap if backend else None - return recorder - - -def test_init_perspective_mode_creates_kit_capture(): - """With kit backend, __init__ builds Isaac Sim Kit perspective capture.""" - scene = MagicMock() - scene.sensors = {} - scene.num_envs = 1 - cfg = SimpleNamespace(**_DEFAULT_CFG) - fake_capture = MagicMock() - kit_mod = MagicMock() - kit_mod.create_isaacsim_kit_perspective_video = MagicMock(return_value=fake_capture) - with patch.object(_video_recorder_module, "_resolve_video_backend", return_value=("kit", None)): - with patch.object(_video_recorder_module, "_sync_camera_from_visualizer"): - with patch.dict( - sys.modules, - { - "isaaclab_physx.video_recording": MagicMock(), - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video": kit_mod, - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg": MagicMock(), - }, - ): - vr = VideoRecorder(cfg, scene) - kit_mod.create_isaacsim_kit_perspective_video.assert_called_once() - assert vr._capture is fake_capture - assert vr._matched_visualizer is None - - -def test_init_newton_backend_creates_newton_capture(): - """With newton_gl backend, __init__ builds Newton GL perspective capture.""" - scene = MagicMock() - cfg = SimpleNamespace(**_DEFAULT_CFG) - fake_capture = MagicMock() - newton_mod = MagicMock() - newton_mod.create_newton_gl_perspective_video = MagicMock(return_value=fake_capture) - with patch.object(_video_recorder_module, "_resolve_video_backend", return_value=("newton_gl", "newton")): - with patch.object(_video_recorder_module, "_sync_camera_from_visualizer"): - with patch.dict( - sys.modules, - { - "pyglet": MagicMock(), - "isaaclab_newton.video_recording": MagicMock(), - "isaaclab_newton.video_recording.newton_gl_perspective_video": newton_mod, - "isaaclab_newton.video_recording.newton_gl_perspective_video_cfg": MagicMock(), - }, - ): - vr = VideoRecorder(cfg, scene) - newton_mod.create_newton_gl_perspective_video.assert_called_once() - assert vr._capture is fake_capture - assert vr._matched_visualizer == "newton" - - -def test_init_kit_from_visualizer_syncs_camera(): - """When backend comes from a visualizer, _sync_camera_from_visualizer is called.""" - scene = MagicMock() - cfg = SimpleNamespace(**_DEFAULT_CFG) - with patch.object(_video_recorder_module, "_resolve_video_backend", return_value=("kit", "kit")): - with patch.object(_video_recorder_module, "_sync_camera_from_visualizer") as mock_sync: - with patch.dict( - sys.modules, - { - "isaaclab_physx.video_recording": MagicMock(), - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video": MagicMock(), - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg": MagicMock(), - }, - ): - VideoRecorder(cfg, scene) - mock_sync.assert_called_once_with(scene, "kit", cfg) - - -def test_init_no_visualizer_skips_camera_sync(): - """When backend comes from physics/renderer stack, camera sync is skipped.""" - scene = MagicMock() - cfg = SimpleNamespace(**_DEFAULT_CFG) - with patch.object(_video_recorder_module, "_resolve_video_backend", return_value=("kit", None)): - with patch.object(_video_recorder_module, "_sync_camera_from_visualizer") as mock_sync: - with patch.dict( - sys.modules, - { - "isaaclab_physx.video_recording": MagicMock(), - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video": MagicMock(), - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg": MagicMock(), - }, - ): - VideoRecorder(cfg, scene) - mock_sync.assert_not_called() - - -def _make_scene(visualizer_types, physics_name="PhysxPhysicsManager", renderer_types=None): - scene = MagicMock() - scene.sim.resolve_visualizer_types.return_value = visualizer_types - scene.sim.physics_manager.__name__ = physics_name - scene._sensor_renderer_types.return_value = renderer_types or [] - return scene +class _FakeVisualizer: + """Initialized visualizer with a recorder-facing framebuffer.""" + def __init__( + self, + visualizer_type: str, + *, + eye: tuple[float, float, float] = (1.0, 2.0, 3.0), + lookat: tuple[float, float, float] = (4.0, 5.0, 6.0), + ): + self.cfg = SimpleNamespace(visualizer_type=visualizer_type, eye=eye, lookat=lookat) + self.render_calls = 0 -def test_resolve_backend_prefers_kit_visualizer(): - """When 'kit' visualizer is active, backend is 'kit' with matched type 'kit'.""" - scene = _make_scene(["kit"]) - backend, matched = _resolve_video_backend(scene) - assert backend == "kit" - assert matched == "kit" + def render_rgb_array(self) -> np.ndarray: + self.render_calls += 1 + return _FRAME -def test_resolve_backend_prefers_newton_visualizer(): - """When 'newton' visualizer is active, backend is 'newton_gl' with matched type 'newton'.""" - scene = _make_scene(["newton"], physics_name="NewtonPhysicsManager") - backend, matched = _resolve_video_backend(scene) - assert backend == "newton_gl" - assert matched == "newton" +def _make_cfg(**overrides): + return SimpleNamespace(**(_DEFAULT_CFG | overrides)) -def test_resolve_backend_renderer_source_ignores_visualizer(): - """When backend_source is 'renderer', active visualizers do not drive backend selection.""" - scene = _make_scene(["newton"], physics_name="PhysxPhysicsManager") - backend, matched = _resolve_video_backend(scene, backend_source="renderer") - assert backend == "kit" - assert matched is None +def _make_visualizer_cfg(visualizer_type, eye=(1.0, 2.0, 3.0), lookat=(4.0, 5.0, 6.0)): + return SimpleNamespace(visualizer_type=visualizer_type, eye=eye, lookat=lookat) -def test_resolve_backend_kit_wins_over_newton_visualizer(): - """When both kit and newton visualizers are active, kit takes priority.""" - scene = _make_scene(["newton", "kit"]) - backend, matched = _resolve_video_backend(scene) - assert backend == "kit" - assert matched == "kit" - +def _make_scene(visualizer_cfgs=(), physics_backend="PhysxPhysicsManager", renderer_types=()): + scene = MagicMock() + scene.sim._resolve_visualizer_cfgs.return_value = list(visualizer_cfgs) + scene.sim.physics_manager.__name__ = physics_backend + scene.sim.visualizers = [] + scene._sensor_renderer_types.return_value = list(renderer_types) + return scene -def test_resolve_backend_warns_when_multiple_video_capable_visualizers(caplog: pytest.LogCaptureFixture): - """The Gymnasium video wrapper records one stream even if both Kit and Newton are active.""" - scene = _make_scene(["newton", "kit"]) - with caplog.at_level("WARNING"): - backend, matched = _resolve_video_backend(scene) - assert backend == "kit" - assert matched == "kit" - assert any("Multiple video-capable visualizers are active" in record.getMessage() for record in caplog.records) +def test_resolve_backend_prefers_kit_visualizer(): + """Kit has priority when both video-capable visualizers are configured.""" + newton_cfg = _make_visualizer_cfg("newton") + kit_cfg = _make_visualizer_cfg("kit") + scene = _make_scene([newton_cfg, kit_cfg]) + backend, visualizer_cfg = _select_video_backend(scene, "visualizer") -def test_resolve_backend_unsupported_visualizer_falls_through(): - """viser/rerun visualizers fall through to physics stack detection.""" - scene = _make_scene(["viser"], physics_name="PhysxPhysicsManager") - backend, matched = _resolve_video_backend(scene) assert backend == "kit" - assert matched is None + assert visualizer_cfg is kit_cfg -def test_resolve_backend_fallback_physx_returns_none_matched(): - """Physics/renderer fallback returns None as matched visualizer.""" - scene = _make_scene([], physics_name="PhysxPhysicsManager") - backend, matched = _resolve_video_backend(scene) - assert backend == "kit" - assert matched is None +def test_resolve_backend_selects_newton_visualizer(): + """A configured Newton visualizer selects direct Newton framebuffer capture.""" + newton_cfg = _make_visualizer_cfg("newton") + backend, visualizer_cfg = _select_video_backend(_make_scene([newton_cfg], "NewtonPhysicsManager"), "visualizer") -def test_resolve_backend_fallback_newton_physics_returns_none_matched(): - """Newton physics fallback returns None as matched visualizer.""" - scene = _make_scene([], physics_name="NewtonPhysicsManager") - backend, matched = _resolve_video_backend(scene) assert backend == "newton_gl" - assert matched is None - + assert visualizer_cfg is newton_cfg -def test_resolve_backend_raises_when_no_supported_backend(): - """RuntimeError when no supported backend can be detected.""" - scene = _make_scene([], physics_name="UnknownManager") - with pytest.raises(RuntimeError, match="No supported backend detected"): - _resolve_video_backend(scene) +def test_resolve_backend_renderer_source_ignores_visualizers(): + """Renderer source bypasses active visualizers.""" + newton_cfg = _make_visualizer_cfg("newton") -def test_resolve_backend_raises_for_invalid_backend_source(): - """Only 'visualizer' and 'renderer' are valid backend source modes.""" - scene = _make_scene([]) - with pytest.raises(ValueError, match="backend_source"): - _resolve_video_backend(scene, backend_source="invalid") + backend, visualizer_cfg = _select_video_backend(_make_scene([newton_cfg], "PhysxPhysicsManager"), "renderer") + assert backend == "kit" + assert visualizer_cfg is None -def _make_visualizer_cfg(visualizer_type, eye=None, lookat=None): - return SimpleNamespace(visualizer_type=visualizer_type, eye=eye, lookat=lookat) +def test_resolve_backend_unsupported_visualizer_falls_back(): + """A visualizer without RGB capture falls back to the renderer stack.""" + viser_cfg = _make_visualizer_cfg("viser") -def test_sync_camera_overwrites_cfg_from_visualizer(): - """Visualizer cfg eye/lookat are written into VideoRecorderCfg.""" - scene = MagicMock() - scene.sim._resolve_visualizer_cfgs.return_value = [ - _make_visualizer_cfg("newton", eye=(1.0, 2.0, 3.0), lookat=(4.0, 5.0, 6.0)), - ] - cfg = SimpleNamespace(**_DEFAULT_CFG) - _sync_camera_from_visualizer(scene, "newton", cfg) - assert cfg.eye == (1.0, 2.0, 3.0) - assert cfg.lookat == (4.0, 5.0, 6.0) + backend, visualizer_cfg = _select_video_backend(_make_scene([viser_cfg], "PhysxPhysicsManager"), "visualizer") + assert backend == "kit" + assert visualizer_cfg is None -def test_sync_camera_skips_wrong_visualizer_type(): - """Only the matching visualizer type updates the cfg.""" - scene = MagicMock() - scene.sim._resolve_visualizer_cfgs.return_value = [ - _make_visualizer_cfg("kit", eye=(9.0, 9.0, 9.0), lookat=(1.0, 1.0, 1.0)), - ] - cfg = SimpleNamespace(**_DEFAULT_CFG) - original_eye = cfg.eye - _sync_camera_from_visualizer(scene, "newton", cfg) - assert cfg.eye == original_eye # unchanged +@pytest.mark.parametrize( + ("physics_backend", "renderer_types", "expected"), + [ + ("PhysxPhysicsManager", (), "kit"), + ("unknown", ("isaac_rtx",), "kit"), + ("NewtonPhysicsManager", (), "newton_gl"), + ("unknown", ("newton_warp",), "newton_gl"), + ], +) +def test_resolve_backend_uses_renderer_stack(physics_backend, renderer_types, expected): + """Physics and sensor renderers select the fallback capture backend.""" + backend, visualizer = _select_video_backend( + _make_scene(physics_backend=physics_backend, renderer_types=renderer_types), + "visualizer", + ) -def test_sync_camera_handles_missing_camera_fields(): - """If visualizer cfg has no camera fields, existing cfg values are kept.""" - scene = MagicMock() - vcfg = _make_visualizer_cfg("newton", eye=None, lookat=None) - scene.sim._resolve_visualizer_cfgs.return_value = [vcfg] - cfg = SimpleNamespace(**_DEFAULT_CFG) - original_eye = cfg.eye - _sync_camera_from_visualizer(scene, "newton", cfg) - assert cfg.eye == original_eye + assert backend == expected + assert visualizer is None -def test_sync_camera_handles_resolve_exception(): - """If _resolve_visualizer_cfgs raises, no exception propagates and cfg is unchanged.""" - scene = MagicMock() - scene.sim._resolve_visualizer_cfgs.side_effect = RuntimeError("boom") - cfg = SimpleNamespace(**_DEFAULT_CFG) - original_eye = cfg.eye - _sync_camera_from_visualizer(scene, "newton", cfg) - assert cfg.eye == original_eye - - -def test_render_rgb_array_delegates_to_capture(): - """render_rgb_array returns capture.render_rgb_array().""" - recorder = _create_recorder(_backend="kit") - result = recorder.render_rgb_array() - recorder._capture.render_rgb_array.assert_called_once() - assert result.shape == (720, 1280, 3) - - -def test_render_rgb_array_none_when_no_backend(): - """Without rgb_array env_render_mode, _capture is None and render returns None.""" - recorder = _create_recorder(env_render_mode=None) - recorder._backend = None - recorder._capture = None - assert recorder.render_rgb_array() is None - - -def test_capture_exception_propagates(): - """Failures in backend capture propagate.""" - recorder = _create_recorder(_backend="newton_gl") - recorder._capture.render_rgb_array.side_effect = RuntimeError("fail") - with pytest.raises(RuntimeError, match="fail"): - recorder.render_rgb_array() +def test_resolve_backend_raises_without_supported_source(): + """An unsupported stack cannot provide RGB video.""" + with pytest.raises(RuntimeError, match="No supported backend detected"): + _select_video_backend(_make_scene(physics_backend="unknown"), "visualizer") -def test_render_rgb_array_calls_capture_each_step(): - """Each render_rgb_array call hits the backend capture.""" - recorder = _create_recorder(_backend="kit") - for _ in range(3): +def test_resolve_backend_rejects_invalid_source(): + """Only visualizer and renderer are valid backend sources.""" + with pytest.raises(ValueError, match="backend_source"): + _select_video_backend(_make_scene(), "invalid") + + +def test_newton_visualizer_capture_is_bound_on_first_render(): + """Newton video reads the initialized visualizer framebuffer without creating another viewer.""" + newton_cfg = _make_visualizer_cfg("newton") + newton = _FakeVisualizer("newton") + scene = _make_scene([newton_cfg], "NewtonPhysicsManager") + recorder = VideoRecorder(_make_cfg(), scene) + + assert recorder._capture is None + scene.sim.visualizers = [newton] + assert recorder.render_rgb_array() is _FRAME + assert recorder._capture is newton + + scene.sim.visualizers = [] + assert recorder.render_rgb_array() is _FRAME + assert newton.render_calls == 2 + + +def test_init_creates_kit_capture_with_visualizer_camera(): + """Kit capture is created before reset using the selected visualizer camera.""" + kit_cfg = _make_visualizer_cfg("kit", eye=(1.0, 2.0, 3.0), lookat=(4.0, 5.0, 6.0)) + capture = MagicMock() + capture.render_rgb_array.return_value = _FRAME + create_capture = MagicMock(return_value=capture) + capture_cfg = MagicMock() + capture_cfg_type = MagicMock(return_value=capture_cfg) + capture_module = SimpleNamespace(create_isaacsim_kit_perspective_video=create_capture) + cfg_module = SimpleNamespace(IsaacsimKitPerspectiveVideoCfg=capture_cfg_type) + + with patch.dict( + sys.modules, + { + "isaaclab_physx.video_recording.isaacsim_kit_perspective_video": capture_module, + "isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg": cfg_module, + }, + ): + recorder = VideoRecorder(_make_cfg(), _make_scene([kit_cfg])) + create_capture.assert_called_once_with(capture_cfg) + frame = recorder.render_rgb_array() + + assert frame is _FRAME + capture_cfg_type.assert_called_once_with( + eye=(1.0, 2.0, 3.0), + lookat=(4.0, 5.0, 6.0), + window_width=1280, + window_height=720, + ) + + +def test_renderer_source_creates_standalone_newton_capture(): + """Renderer-selected Newton video uses the recorder camera and standalone capture.""" + newton_cfg = _make_visualizer_cfg("newton") + capture = MagicMock() + capture.render_rgb_array.return_value = _FRAME + create_capture = MagicMock(return_value=capture) + capture_cfg = MagicMock() + capture_cfg_type = MagicMock(return_value=capture_cfg) + capture_module = SimpleNamespace(create_newton_gl_perspective_video=create_capture) + cfg_module = SimpleNamespace(NewtonGlPerspectiveVideoCfg=capture_cfg_type) + + with patch.dict( + sys.modules, + { + "isaaclab_newton.video_recording.newton_gl_perspective_video": capture_module, + "isaaclab_newton.video_recording.newton_gl_perspective_video_cfg": cfg_module, + }, + ): + recorder = VideoRecorder( + _make_cfg(backend_source="renderer"), + _make_scene([newton_cfg], "NewtonPhysicsManager"), + ) + create_capture.assert_called_once_with(capture_cfg) + frame = recorder.render_rgb_array() + + assert frame is _FRAME + capture_cfg_type.assert_called_once_with( + window_width=1280, + window_height=720, + eye=(7.5, 7.5, 7.5), + lookat=(0.0, 0.0, 0.0), + ) + + +def test_newton_visualizer_capture_requires_initialized_visualizer(): + """The selected Newton visualizer must be initialized before its framebuffer is read.""" + newton_cfg = _make_visualizer_cfg("newton") + recorder = VideoRecorder(_make_cfg(), _make_scene([newton_cfg], "NewtonPhysicsManager")) + + with pytest.raises(RuntimeError, match="not initialized"): recorder.render_rgb_array() - assert recorder._capture.render_rgb_array.call_count == 3 -def test_render_rgb_array_calls_sync_newton_camera_when_newton_visualizer(): - """render_rgb_array triggers _sync_newton_camera when matched_visualizer is 'newton'.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - with patch.object(recorder, "_sync_newton_camera") as mock_sync: - recorder.render_rgb_array() - mock_sync.assert_called_once() +def test_render_rgb_array_is_disabled_for_other_render_modes(): + """The recorder remains dormant unless Gym requests RGB arrays.""" + recorder = VideoRecorder(_make_cfg(env_render_mode=None), _make_scene()) + assert recorder.render_rgb_array() is None + assert recorder._capture is None -def test_render_rgb_array_skips_sync_for_kit_visualizer(): - """render_rgb_array does NOT call _sync_newton_camera for kit backend.""" - recorder = _create_recorder(_backend="kit", _matched_visualizer="kit") - with patch.object(recorder, "_sync_newton_camera") as mock_sync: - recorder.render_rgb_array() - mock_sync.assert_not_called() +def test_capture_errors_propagate(): + """Capture failures are not hidden by VideoRecorder.""" + capture = MagicMock() + capture.render_rgb_array.side_effect = RuntimeError("capture failed") + recorder = VideoRecorder(_make_cfg(), _make_scene()) + recorder._capture = capture -def test_render_rgb_array_skips_sync_when_no_visualizer(): - """render_rgb_array does NOT call _sync_newton_camera when using physics/renderer stack.""" - recorder = _create_recorder(_backend="kit", _matched_visualizer=None) - with patch.object(recorder, "_sync_newton_camera") as mock_sync: + with pytest.raises(RuntimeError, match="capture failed"): recorder.render_rgb_array() - mock_sync.assert_not_called() - - -def _make_newton_visualizer(pos=(1.0, 2.0, 3.0), yaw_deg=45.0, pitch_deg=30.0): - """Return a mock that quacks like a NewtonVisualizer with a live camera.""" - viz = MagicMock() - viz.cfg.visualizer_type = "newton" - cam = MagicMock() - cam.pos = pos - cam.yaw = yaw_deg - cam.pitch = pitch_deg - viz._viewer = MagicMock() - viz._viewer.camera = cam - return viz - - -def test_sync_newton_camera_lazy_lookup_finds_visualizer(): - """_sync_newton_camera resolves the Newton visualizer on the first call.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - newton_viz = _make_newton_visualizer() - recorder._scene.sim.visualizers = [newton_viz] - - recorder._sync_newton_camera() - - assert recorder._live_visualizer is newton_viz - recorder._capture.update_camera.assert_called_once() - - -def test_sync_newton_camera_uses_cached_visualizer(): - """_sync_newton_camera uses the cached _live_visualizer and skips the list walk.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - newton_viz = _make_newton_visualizer() - # pre-cache the visualizer - recorder._live_visualizer = newton_viz - - other_viz = _make_newton_visualizer(pos=(99.0, 99.0, 99.0)) - # replace sim.visualizers with a second Newton visualizer - # if the cache is bypassed the recorder would use this one instead. - recorder._scene.sim.visualizers = [other_viz] - recorder._sync_newton_camera() - position = recorder._capture.update_camera.call_args[0][0] - assert position != (99.0, 99.0, 99.0) - recorder._capture.update_camera.assert_called_once() - - -def test_sync_newton_camera_correct_position_forwarded(): - """_sync_newton_camera reads cam.pos and passes it as position to update_camera.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - newton_viz = _make_newton_visualizer(pos=(10.0, 20.0, 30.0), yaw_deg=0.0, pitch_deg=0.0) - recorder._live_visualizer = newton_viz - - recorder._sync_newton_camera() - - args = recorder._capture.update_camera.call_args - position = args[0][0] - assert position == (10.0, 20.0, 30.0) - - -def test_sync_newton_camera_target_derived_from_pitch_yaw(): - """Target is reconstructed from pitch/yaw and is unit-distance from position.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - pos = (0.0, 0.0, 0.0) - yaw_deg, pitch_deg = 0.0, 0.0 # looking along +X at horizon - newton_viz = _make_newton_visualizer(pos=pos, yaw_deg=yaw_deg, pitch_deg=pitch_deg) - recorder._live_visualizer = newton_viz - - recorder._sync_newton_camera() - - args = recorder._capture.update_camera.call_args[0] - position, target = args - dx = target[0] - position[0] - dy = target[1] - position[1] - dz = target[2] - position[2] - dist = math.sqrt(dx**2 + dy**2 + dz**2) - assert abs(dist - 1.0) < 1e-6 - assert abs(dx - 1.0) < 1e-6 - assert abs(dy) < 1e-6 - assert abs(dz) < 1e-6 - - -def test_sync_newton_camera_no_visualizer_does_not_raise(): - """_sync_newton_camera silently skips when no Newton visualizer is registered.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - recorder._scene.sim.visualizers = [] - recorder._sync_newton_camera() # must not raise - recorder._capture.update_camera.assert_not_called() - - -def test_sync_newton_camera_skips_non_newton_visualizers(): - """_sync_newton_camera ignores visualizers whose type is not 'newton'.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - kit_viz = MagicMock() - kit_viz.cfg.visualizer_type = "kit" - recorder._scene.sim.visualizers = [kit_viz] - recorder._sync_newton_camera() - recorder._capture.update_camera.assert_not_called() - - -def test_sync_newton_camera_skips_when_viewer_is_none(): - """_sync_newton_camera skips camera update when _viewer is None (headless fallback).""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - viz = MagicMock() - viz.cfg.visualizer_type = "newton" - viz._viewer = None - recorder._live_visualizer = viz - recorder._sync_newton_camera() - recorder._capture.update_camera.assert_not_called() - - -def test_sync_newton_camera_called_per_frame(): - """_sync_newton_camera (and thus update_camera) is called on every render step.""" - recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") - newton_viz = _make_newton_visualizer() - recorder._live_visualizer = newton_viz - - for _ in range(4): - recorder.render_rgb_array() - - assert recorder._capture.update_camera.call_count == 4 diff --git a/source/isaaclab_visualizers/changelog.d/newton-video-framebuffer.minor.rst b/source/isaaclab_visualizers/changelog.d/newton-video-framebuffer.minor.rst new file mode 100644 index 00000000000..3ff203ac4d3 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/newton-video-framebuffer.minor.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added :meth:`~isaaclab_visualizers.newton.NewtonVisualizer.render_rgb_array` to access the latest + Newton viewer framebuffer. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index a10e5ca8193..8da5da5322d 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 sys from typing import TYPE_CHECKING +import numpy as np import torch import warp as wp from newton.viewer import ViewerGL @@ -853,6 +854,19 @@ def set_camera_view( self.cfg.lookat = target_t self._apply_camera_pose((eye_t, target_t)) + def render_rgb_array(self) -> np.ndarray: + """Return the latest RGB frame rendered by the Newton viewer. + + Returns: + The latest viewer framebuffer as a uint8 array with shape ``(height, width, 3)``. + + Raises: + RuntimeError: If the visualizer has not been initialized. + """ + if self._viewer is None: + raise RuntimeError("NewtonVisualizer must be initialized before capturing an RGB frame.") + return self._viewer.get_frame().numpy() + def supports_markers(self) -> bool: """Newton OpenGL viewer supports Isaac Lab markers through viewer-side meshes and lines.""" return bool(self.cfg.enable_markers) diff --git a/source/isaaclab_visualizers/test/test_newton_adapter.py b/source/isaaclab_visualizers/test/test_newton_adapter.py index fd93a75ff94..9e94d3f1dfa 100644 --- a/source/isaaclab_visualizers/test/test_newton_adapter.py +++ b/source/isaaclab_visualizers/test/test_newton_adapter.py @@ -10,6 +10,7 @@ from types import SimpleNamespace import numpy as np +import pytest import torch import warp as wp from isaaclab_visualizers.newton import NewtonVisualizer, NewtonVisualizerCfg @@ -148,6 +149,22 @@ def __init__(self): assert visualizer.cfg.lookat == (0.0, 0.0, 1.0) +def test_newton_visualizer_render_rgb_array_returns_viewer_frame(): + frame = np.zeros((4, 6, 3), dtype=np.uint8) + viewer = SimpleNamespace(get_frame=lambda: SimpleNamespace(numpy=lambda: frame)) + visualizer = NewtonVisualizer(NewtonVisualizerCfg()) + visualizer._viewer = viewer + + assert visualizer.render_rgb_array() is frame + + +def test_newton_visualizer_render_rgb_array_requires_initialized_viewer(): + visualizer = NewtonVisualizer(NewtonVisualizerCfg()) + + with pytest.raises(RuntimeError, match="must be initialized"): + visualizer.render_rgb_array() + + def test_newton_viewer_particle_color_override(monkeypatch): from newton.viewer import ViewerGL From 6f359fee6516df97b86054505949b3a90228d0c2 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Wed, 1 Jul 2026 16:01:20 -0700 Subject: [PATCH 062/139] Fix Newton marker world rendering (#6281) ## Summary - add `NewtonVisualizerCfg.world_spacing` for visual-only separation of co-located simulation worlds - render dense environment-major marker batches using the same selected worlds and offsets as model geometry - select marker state along its environment axis and broadcast offsets without flattened gather or repeated-offset tensors - cache marker prototype conversion and keep filtering/offset application in one typed render path - resolve and deduplicate visible environment IDs once, then pass the exact selection to the Newton viewer - document partial Newton visualization and its dense environment-major marker convention Non-divisible marker batches remain global and are neither filtered nor offset. ## Testing - `22 passed`: `source/isaaclab_visualizers/test/test_newton_adapter.py` - `10 passed`: scoped Newton marker render and partial-update tests - CPU marker state with CUDA viewer offsets: selected worlds produced `[12, 13, 36, 37]` - all-world offset smoke: `[0, 1, 12, 13, 24, 25, 36, 37]` - non-divisible global marker smoke remained `[0, 1, 2]` - fail-before-fix verification: old filtering produced `[1, 3, 5, 7]` instead of `[12, 13, 36, 37]`; the old allocation path also invoked the forbidden `torch.arange` regression guard - dynamic reordered selections `[3, 1]` and `[1, 3]` preserve transform, scale, and source tensor state - `./isaaclab.sh -d` passed with warnings treated as errors - scoped pre-commit passed, including the LFS hook - repository-wide pre-commit passed with only the known unrelated golden-image LFS hook skipped - changelog fragment check passed --- .../overview/core-concepts/visualization.rst | 16 ++ .../changelog.d/newton-world-spacing.skip | 0 .../markers/test_visualization_markers.py | 222 ++++++++++++++++-- .../newton-world-spacing.minor.rst | 11 + .../newton/newton_visualization_markers.py | 161 ++++++------- .../newton/newton_visualizer.py | 13 +- .../newton/newton_visualizer_cfg.py | 6 + .../isaaclab_visualizers/newton_adapter.py | 6 +- .../test/test_newton_adapter.py | 10 + 9 files changed, 323 insertions(+), 122 deletions(-) create mode 100644 source/isaaclab/changelog.d/newton-world-spacing.skip create mode 100644 source/isaaclab_visualizers/changelog.d/newton-world-spacing.minor.rst diff --git a/docs/source/overview/core-concepts/visualization.rst b/docs/source/overview/core-concepts/visualization.rst index f095f9d77ae..d16a23c928c 100644 --- a/docs/source/overview/core-concepts/visualization.rst +++ b/docs/source/overview/core-concepts/visualization.rst @@ -163,6 +163,22 @@ There are 3 fields exposed in the ``VisualizerCfg`` for selecting environments f Also, there is a CLI arg ``--max_visible_envs`` that overrides ``VisualizerCfg.max_visible_envs`` for the run. +Newton environments can share simulated coordinates, for example when ``scene.env_spacing=0``. +Use :attr:`~isaaclab_visualizers.newton.NewtonVisualizerCfg.world_spacing` to arrange selected +worlds visually without changing their simulated poses: + +.. code-block:: python + + + from isaaclab_visualizers.newton import NewtonVisualizerCfg + NewtonVisualizerCfg( + visible_env_indices=[0, 1, 2, 3], + world_spacing=(2.0, 2.0, 0.0), + ) + +Dense environment-major :class:`~isaaclab.markers.VisualizationMarkers` batches follow the same +selection and visual offsets. This includes point-cloud and task-geometry markers. + .. _visualization-common-modes: .. list-table:: Common modes diff --git a/source/isaaclab/changelog.d/newton-world-spacing.skip b/source/isaaclab/changelog.d/newton-world-spacing.skip new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/isaaclab/test/markers/test_visualization_markers.py b/source/isaaclab/test/markers/test_visualization_markers.py index 79a66443eca..63693a160f5 100644 --- a/source/isaaclab/test/markers/test_visualization_markers.py +++ b/source/isaaclab/test/markers/test_visualization_markers.py @@ -19,6 +19,7 @@ import numpy as np import pytest import torch +import warp as wp 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 @@ -498,8 +499,22 @@ class _FakeNewtonMarkerMesh: uvs = np.zeros((0, 2), dtype=np.float32) +_NEWTON_MARKER_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"), +} + + class _FakeNewtonMarkerViewer: - def __init__(self): + def __init__(self, world_offsets): + self.world_offsets = world_offsets self.meshes = [] self.instances = [] self.lines = [] @@ -552,53 +567,218 @@ def _make_newton_marker_for_render( marker.count = translations.shape[0] marker._registered_meshes = set() marker._warned_unsupported = set() + marker._marker_specs = {name: _NEWTON_MARKER_SPECS[name] for name in marker_names} 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"), - } +def _patch_newton_marker_render_deps( + monkeypatch: pytest.MonkeyPatch, world_offsets: np.ndarray | None = None, world_offsets_device: str = "cpu" +): + if world_offsets is None: + world_offsets = np.zeros((4, 3), dtype=np.float32) + warp_world_offsets = wp.array(world_offsets, dtype=wp.vec3, device=world_offsets_device) 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]) + return warp_world_offsets + + +def test_newton_marker_partial_update_preserves_prototype_indices(): + marker = _make_newton_marker_for_render( + marker_names=["arrow", "sphere"], + translations=torch.zeros((4, 3), dtype=torch.float32), + marker_indices=torch.tensor([0, 1, 0, 1], dtype=torch.int32), + ) + expected_indices = marker.marker_indices + + marker.visualize( + translations=torch.ones((4, 3), dtype=torch.float32), + orientations=None, + scales=None, + marker_indices=None, + ) + + assert marker.marker_indices is expected_indices def test_newton_marker_render_filters_visible_envs(monkeypatch: pytest.MonkeyPatch): - _patch_newton_marker_render_deps(monkeypatch) + world_offsets = _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() + viewer = _FakeNewtonMarkerViewer(world_offsets) 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] + assert viewer.instances[0]["xforms"][:, 0].tolist() == [2.0, 3.0, 6.0, 7.0] + + +@pytest.mark.parametrize( + ("visible_env_ids", "expected"), + [ + ([1, 3], [12.0, 13.0, 36.0, 37.0]), + (None, [0.0, 1.0, 12.0, 13.0, 24.0, 25.0, 36.0, 37.0]), + ], +) +def test_newton_marker_render_applies_world_offsets( + monkeypatch: pytest.MonkeyPatch, visible_env_ids: list[int] | None, expected: list[float] +): + world_offsets_device = "cuda:0" if wp.is_cuda_available() else "cpu" + world_offsets = _patch_newton_marker_render_deps( + monkeypatch, + np.array( + [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [20.0, 0.0, 0.0], [30.0, 0.0, 0.0]], + dtype=np.float32, + ), + world_offsets_device=world_offsets_device, + ) + 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(world_offsets) + + marker.render(viewer, visible_env_ids=visible_env_ids, num_envs=4) + + assert viewer.instances[0]["xforms"][:, 0].tolist() == expected + + +def test_newton_marker_render_preserves_reordered_env_state(monkeypatch: pytest.MonkeyPatch): + offsets = np.array( + [ + [0.0, 0.0, 0.0], + [100.0, 200.0, 300.0], + [200.0, 400.0, 600.0], + [300.0, 600.0, 900.0], + ], + dtype=np.float32, + ) + world_offsets = _patch_newton_marker_render_deps(monkeypatch, offsets) + translations = torch.arange(24, dtype=torch.float32).reshape(3, 8).T + orientations = torch.arange(32, dtype=torch.float32).reshape(4, 8).T + scales = torch.arange(1, 25, dtype=torch.float32).reshape(3, 8).T + marker = _make_newton_marker_for_render( + marker_names=["arrow"], + translations=translations, + marker_indices=torch.zeros(8, dtype=torch.int32), + ) + marker.orientations = orientations + marker.scales = scales + source_state = ( + marker.translations, + marker.orientations, + marker.scales, + marker.marker_indices, + ) + source_values = tuple(value.clone() for value in source_state) + viewer = _FakeNewtonMarkerViewer(world_offsets) + + selections = ( + ([3, 1], torch.tensor([6, 7, 2, 3])), + ([1, 3], torch.tensor([2, 3, 6, 7])), + ) + + monkeypatch.setattr( + newton_markers.torch, + "arange", + lambda *args, **kwargs: pytest.fail("render should not construct environment index tensors"), + ) + + for visible_env_ids, expected_indices in selections: + selected_offsets = torch.from_numpy(offsets[np.repeat(visible_env_ids, 2)]) + expected_positions = translations[expected_indices] + selected_offsets + expected_xforms = torch.cat((expected_positions, orientations[expected_indices]), dim=1) + expected_scales = scales[expected_indices] + marker.render(viewer, visible_env_ids=visible_env_ids, num_envs=4) + call = viewer.instances[-1] + assert call["hidden"] is False + np.testing.assert_allclose(call["xforms"], expected_xforms.numpy(), rtol=0.0, atol=0.0) + np.testing.assert_allclose(call["scales"], expected_scales.numpy(), rtol=0.0, atol=0.0) + + current_state = ( + marker.translations, + marker.orientations, + marker.scales, + marker.marker_indices, + ) + for current, source, expected in zip(current_state, source_state, source_values): + assert current is source + assert torch.equal(current, expected) + assert len(viewer.instances) == 2 + + +def test_newton_marker_render_hides_empty_env_selection(monkeypatch: pytest.MonkeyPatch): + world_offsets = _patch_newton_marker_render_deps(monkeypatch) + marker = _make_newton_marker_for_render( + marker_names=["arrow"], + translations=torch.zeros((8, 3), dtype=torch.float32), + ) + viewer = _FakeNewtonMarkerViewer(world_offsets) + + marker.render(viewer, visible_env_ids=[], num_envs=4) + + assert len(viewer.instances) == 1 + assert viewer.instances[0]["hidden"] is True + + +def test_newton_marker_render_defaults_to_first_prototype(monkeypatch: pytest.MonkeyPatch): + world_offsets = _patch_newton_marker_render_deps(monkeypatch) + marker = _make_newton_marker_for_render( + marker_names=["arrow", "sphere"], + translations=torch.zeros((4, 3), dtype=torch.float32), + ) + marker.orientations = None + marker.scales = None + viewer = _FakeNewtonMarkerViewer(world_offsets) + + marker.render(viewer, visible_env_ids=None, num_envs=4) + + visible_instances = [call for call in viewer.instances if not call["hidden"]] + assert len(visible_instances) == 1 + assert visible_instances[0]["batch_name"] == "/Visuals/marker::test/arrow" + assert visible_instances[0]["xforms"][:, 3:].tolist() == [[0.0, 0.0, 0.0, 1.0]] * 4 + assert visible_instances[0]["scales"].tolist() == [[1.0, 1.0, 1.0]] * 4 + hidden_batches = [call["batch_name"] for call in viewer.instances if call["hidden"]] + assert hidden_batches == ["/Visuals/marker::test/sphere"] + + +def test_newton_marker_render_keeps_global_batch_unmodified(monkeypatch: pytest.MonkeyPatch): + world_offsets = _patch_newton_marker_render_deps( + monkeypatch, + np.array( + [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [20.0, 0.0, 0.0], [30.0, 0.0, 0.0]], + dtype=np.float32, + ), + ) + translations = torch.arange(3, dtype=torch.float32).unsqueeze(1).repeat(1, 3) + marker = _make_newton_marker_for_render( + marker_names=["arrow"], + translations=translations, + marker_indices=torch.zeros(3, dtype=torch.int32), + ) + viewer = _FakeNewtonMarkerViewer(world_offsets) + + marker.render(viewer, visible_env_ids=[1, 3], num_envs=4) + + assert viewer.instances[0]["xforms"][:, 0].tolist() == [0.0, 1.0, 2.0] def test_newton_marker_render_routes_instances_by_prototype(monkeypatch: pytest.MonkeyPatch): - _patch_newton_marker_render_deps(monkeypatch) + world_offsets = _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() + viewer = _FakeNewtonMarkerViewer(world_offsets) marker.render(viewer, visible_env_ids=None, num_envs=4) @@ -613,13 +793,13 @@ def test_newton_marker_render_routes_instances_by_prototype(monkeypatch: pytest. def test_newton_marker_render_hides_unselected_prototypes(monkeypatch: pytest.MonkeyPatch): - _patch_newton_marker_render_deps(monkeypatch) + world_offsets = _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() + viewer = _FakeNewtonMarkerViewer(world_offsets) marker.render(viewer, visible_env_ids=None, num_envs=3) diff --git a/source/isaaclab_visualizers/changelog.d/newton-world-spacing.minor.rst b/source/isaaclab_visualizers/changelog.d/newton-world-spacing.minor.rst new file mode 100644 index 00000000000..99002aeb529 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/newton-world-spacing.minor.rst @@ -0,0 +1,11 @@ +Added +^^^^^ + +* Added :attr:`~isaaclab_visualizers.newton.NewtonVisualizerCfg.world_spacing` + to visually separate Newton worlds without changing their simulated poses. + +Fixed +^^^^^ + +* Fixed Newton marker filtering for environment-major marker arrays and aligned marker + overlays with visual world spacing. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualization_markers.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualization_markers.py index 9d222ed71a6..5e2c39c9365 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualization_markers.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualization_markers.py @@ -15,6 +15,7 @@ import torch import warp as wp from newton import Axis, Mesh +from newton.viewer import ViewerBase import isaaclab.sim as sim_utils from isaaclab.markers.visualization_markers_cfg import VisualizationMarkersCfg @@ -49,7 +50,7 @@ class _MeshData: uvs: np.ndarray -def render_newton_visualization_markers(viewer, visible_env_ids: list[int] | None, num_envs: int) -> None: +def render_newton_visualization_markers(viewer: ViewerBase, 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: @@ -74,6 +75,9 @@ def __init__(self, cfg: VisualizationMarkersCfg, visible: bool = True): self.count = len(cfg.markers) self._registered_meshes: set[tuple[int, str]] = set() self._warned_unsupported: set[str] = set() + self._marker_specs: dict[str, _NewtonMarkerSpec] = { + name: _infer_newton_marker_cfg(marker_cfg) for name, marker_cfg in cfg.markers.items() + } sim = sim_utils.SimulationContext.instance() if sim is not None: @@ -120,37 +124,62 @@ def visualize( 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)) + + def render(self, viewer: ViewerBase, visible_env_ids: list[int] | None, num_envs: int) -> None: + """Render marker state to a Newton viewer. + + Marker batches divisible by ``num_envs`` are interpreted as dense environment-major arrays + with shape ``(num_envs, markers_per_env, ...)``. Other batches are rendered as global markers. + """ + translations = self.translations + orientations = self.orientations + scales = self.scales + marker_indices = self.marker_indices + count = self.count + + if not self.visible: + for name, newton_cfg in self._marker_specs.items(): + self._hide_batch(viewer, name, newton_cfg) + return + + if count > 0 and num_envs > 0 and count % num_envs == 0: + markers_per_env = count // num_envs + env_selection = slice(num_envs) if visible_env_ids is None else visible_env_ids + selected_env_count = num_envs if visible_env_ids is None else len(visible_env_ids) + if translations is not None: + translations = translations.reshape(num_envs, markers_per_env, 3)[env_selection] + if orientations is not None: + orientations = orientations.reshape(num_envs, markers_per_env, 4)[env_selection].flatten(0, 1) + if scales is not None: + scales = scales.reshape(num_envs, markers_per_env, 3)[env_selection].flatten(0, 1) + if marker_indices is not None: + marker_indices = marker_indices.reshape(num_envs, markers_per_env)[env_selection].flatten(0, 1) + count = selected_env_count * markers_per_env + if translations is not None: + offsets = wp.to_torch(viewer.world_offsets)[env_selection].to(translations) + translations = (translations + offsets[:, None, :]).flatten(0, 1) + + if count == 0: + for name, newton_cfg in self._marker_specs.items(): + self._hide_batch(viewer, name, newton_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) + newton_cfg = self._marker_specs[name] 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 marker_indices is None: + if proto_index != 0: + self._hide_batch(viewer, name, newton_cfg) + continue + selected = slice(None) + else: + selected = marker_indices == proto_index + if not torch.any(selected): + self._hide_batch(viewer, name, newton_cfg) + continue if newton_cfg.renderer == "none": unsupported_key = f"{self.group_id}:{name}" @@ -164,24 +193,28 @@ def render(self, viewer, visible_env_ids: list[int] | None, num_envs: int) -> No continue selected_translations = translations[selected] - selected_orientations = orientations[selected] + selected_count = selected_translations.shape[0] + if orientations is None: + selected_orientations = selected_translations.new_tensor((0.0, 0.0, 0.0, 1.0)).expand( + selected_count, -1 + ) + else: + 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) + default_scale_tensor = selected_translations.new_tensor(default_scale) + if scales is None: + selected_scales = default_scale_tensor.expand(selected_count, -1) + else: + selected_scales = scales[selected] * default_scale_tensor 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 + colors = selected_translations.new_tensor(color).expand(selected_count, -1) + # ViewerGL gates texture sampling with material.w. Rerun and Viser ignore this flag. + texture_flag = float(newton_cfg.texture is not None) + materials = selected_translations.new_tensor((0.0, 0.0, 0.0, texture_flag)).expand(selected_count, -1) xforms = torch.cat((selected_translations, selected_orientations), dim=1).detach().cpu().numpy() viewer.log_instances( batch_name, @@ -204,7 +237,7 @@ def render(self, viewer, visible_env_ids: list[int] | None, num_envs: int) -> No hidden=False, ) - def _hide_batch(self, viewer, name: str, newton_cfg: _NewtonMarkerSpec) -> None: + def _hide_batch(self, viewer: ViewerBase, 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}" @@ -213,7 +246,7 @@ def _hide_batch(self, viewer, name: str, newton_cfg: _NewtonMarkerSpec) -> None: 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: + def _ensure_mesh_registered(self, viewer: ViewerBase, 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). @@ -233,11 +266,6 @@ def _ensure_mesh_registered(self, viewer, mesh_name: str, newton_cfg: _NewtonMar 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__ @@ -408,51 +436,6 @@ def _create_textured_box_mesh(size: tuple[float, float, float]) -> _MeshData: ) -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: diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 8da5da5322d..1f5f25f4dde 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -33,7 +33,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 isaaclab_visualizers.newton_adapter import resolve_visible_env_indices from .newton_visualizer_cfg import NewtonVisualizerCfg @@ -478,6 +478,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: num_envs = scene_data_provider.num_envs metadata = {"num_envs": num_envs} self._env_ids = self._compute_visualized_env_ids() + self._resolved_visible_env_ids = resolve_visible_env_indices(self._env_ids, self.cfg.max_visible_envs, num_envs) self._model = NewtonManager.get_model() self._state = NewtonManager.get_state(self._scene_data_provider) @@ -503,13 +504,8 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: if self._viewer is not None: self._viewer.set_model(self._model) - apply_viewer_visible_worlds( - self._viewer, - env_ids=self._env_ids, - max_visible_envs=self.cfg.max_visible_envs, - num_envs=num_envs, - ) - self._viewer.set_world_offsets((0.0, 0.0, 0.0)) + self._viewer.set_visible_worlds(self._resolved_visible_env_ids) + self._viewer.set_world_offsets(self.cfg.world_spacing) self._apply_camera_focal_length() initial_pose = self._resolve_initial_camera_pose() self._apply_camera_pose(initial_pose) @@ -536,7 +532,6 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> 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) - self._resolved_visible_env_ids = resolve_visible_env_indices(self._env_ids, self.cfg.max_visible_envs, num_envs) self._setup_camera_sensor_view(num_envs) num_visualized_envs = ( len(self._resolved_visible_env_ids) if self._resolved_visible_env_ids is not None else num_envs 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 67336c054d5..06d01f68aa0 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py @@ -28,6 +28,12 @@ class NewtonVisualizerCfg(VisualizerCfg): update_frequency: int = 1 """Visualizer update frequency (updates every N frames).""" + world_spacing: tuple[float, float, float] = (0.0, 0.0, 0.0) + """Visual spacing between simulation worlds along each axis [m]. + + Non-zero axes arrange visible worlds in a compact grid without changing their simulated poses. + """ + show_joints: bool = False """Show joint visualization.""" diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py index 8a1554a253f..d88581a06cd 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py @@ -64,14 +64,14 @@ def resolve_visible_env_indices( * Cap-only path (``env_ids`` is ``None``): contiguous ``0 .. min(cap, num_envs) - 1`` when ``max_visible_envs`` is set; otherwise ``None`` (viewer shows all worlds). (Random cap-only selection is applied earlier by turning it into explicit ``env_ids``.) - * Explicit path (``env_ids`` is a list): if ``max_visible_envs`` is set, keep only the first *cap* indices - (truncate from the end); if ``None``, use the full list. + * Explicit path (``env_ids`` is a list): remove duplicate indices while preserving order, then keep only the + first *cap* indices when ``max_visible_envs`` is set. Returns: Selected indices, or ``None`` when all environments should be visible (cap-only, no limit). """ if env_ids is not None: - out = list(env_ids) + out = list(dict.fromkeys(env_ids)) if max_visible_envs is not None: out = out[: max(0, int(max_visible_envs))] return out diff --git a/source/isaaclab_visualizers/test/test_newton_adapter.py b/source/isaaclab_visualizers/test/test_newton_adapter.py index 9e94d3f1dfa..448a7183481 100644 --- a/source/isaaclab_visualizers/test/test_newton_adapter.py +++ b/source/isaaclab_visualizers/test/test_newton_adapter.py @@ -71,6 +71,10 @@ def test_resolve_visible_env_indices_truncates_explicit_list(): assert resolve_visible_env_indices([1, 3], 1, 10) == [1] +def test_resolve_visible_env_indices_deduplicates_before_truncating(): + assert resolve_visible_env_indices([1, 1, 3, 5], 2, 10) == [1, 3] + + def test_resolve_visible_env_indices_explicit_full_list_when_no_cap(): assert resolve_visible_env_indices([1, 3], None, 10) == [1, 3] @@ -112,6 +116,12 @@ def test_newton_visualizer_cfg_exposes_particle_options(): assert cfg.particle_color == (0.1, 0.2, 0.3) +def test_newton_visualizer_cfg_exposes_world_spacing(): + cfg = NewtonVisualizerCfg(world_spacing=(2.0, 2.0, 0.0)) + + assert cfg.world_spacing == (2.0, 2.0, 0.0) + + def test_newton_visualizer_set_camera_view_updates_cfg_without_viewer(): visualizer = NewtonVisualizer(NewtonVisualizerCfg()) From 680270d4598e28e6364f5381fa383bc0411cc057 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:20:11 +0000 Subject: [PATCH 063/139] [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: 8.0.1 → 8.0.2 - isaaclab_physx: 2.6.1 → 2.6.2 - isaaclab_visualizers: 1.0.4 → 1.1.0 --- .../changelog.d/newton-video-visible-envs.rst | 5 ----- .../changelog.d/newton-world-spacing.skip | 0 source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 10 ++++++++++ source/isaaclab/pyproject.toml | 2 +- .../changelog.d/pbarejko-missing-imports.rst | 5 ----- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 10 ++++++++++ source/isaaclab_physx/pyproject.toml | 2 +- .../newton-video-framebuffer.minor.rst | 5 ----- .../changelog.d/newton-world-spacing.minor.rst | 11 ----------- .../isaaclab_visualizers/config/extension.toml | 2 +- source/isaaclab_visualizers/docs/CHANGELOG.rst | 18 ++++++++++++++++++ source/isaaclab_visualizers/pyproject.toml | 2 +- 14 files changed, 44 insertions(+), 32 deletions(-) delete mode 100644 source/isaaclab/changelog.d/newton-video-visible-envs.rst delete mode 100644 source/isaaclab/changelog.d/newton-world-spacing.skip delete mode 100644 source/isaaclab_physx/changelog.d/pbarejko-missing-imports.rst delete mode 100644 source/isaaclab_visualizers/changelog.d/newton-video-framebuffer.minor.rst delete mode 100644 source/isaaclab_visualizers/changelog.d/newton-world-spacing.minor.rst diff --git a/source/isaaclab/changelog.d/newton-video-visible-envs.rst b/source/isaaclab/changelog.d/newton-video-visible-envs.rst deleted file mode 100644 index 9a440bb99b7..00000000000 --- a/source/isaaclab/changelog.d/newton-video-visible-envs.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed Newton training videos to reuse the active visualizer framebuffer, preserving selected - environments, the live camera, and markers without a second rendering pass. diff --git a/source/isaaclab/changelog.d/newton-world-spacing.skip b/source/isaaclab/changelog.d/newton-world-spacing.skip deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index d3465e49b32..f333a9b1cdc 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 = "8.0.1" +version = "8.0.2" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 1c7b93de714..0ec31d8f79d 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +8.0.2 (2026-07-02) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed Newton training videos to reuse the active visualizer framebuffer, preserving selected + environments, the live camera, and markers without a second rendering pass. + + 8.0.1 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 68fb7978c89..989aedb9437 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.0.1" +version = "8.0.2" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/pbarejko-missing-imports.rst b/source/isaaclab_physx/changelog.d/pbarejko-missing-imports.rst deleted file mode 100644 index 9ea174c6131..00000000000 --- a/source/isaaclab_physx/changelog.d/pbarejko-missing-imports.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed the public export of - :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg`. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index bdb57d6c746..9ea4a736d54 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 = "2.6.1" +version = "2.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 473f5108836..88a48a0ce62 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +2.6.2 (2026-07-02) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed the public export of + :class:`~isaaclab_physx.renderers.IsaacRtxRendererGlobalSettingsCfg`. + + 2.6.1 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index 898c1ba20a7..63d0495a2a7 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "2.6.1" +version = "2.6.2" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_visualizers/changelog.d/newton-video-framebuffer.minor.rst b/source/isaaclab_visualizers/changelog.d/newton-video-framebuffer.minor.rst deleted file mode 100644 index 3ff203ac4d3..00000000000 --- a/source/isaaclab_visualizers/changelog.d/newton-video-framebuffer.minor.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added :meth:`~isaaclab_visualizers.newton.NewtonVisualizer.render_rgb_array` to access the latest - Newton viewer framebuffer. diff --git a/source/isaaclab_visualizers/changelog.d/newton-world-spacing.minor.rst b/source/isaaclab_visualizers/changelog.d/newton-world-spacing.minor.rst deleted file mode 100644 index 99002aeb529..00000000000 --- a/source/isaaclab_visualizers/changelog.d/newton-world-spacing.minor.rst +++ /dev/null @@ -1,11 +0,0 @@ -Added -^^^^^ - -* Added :attr:`~isaaclab_visualizers.newton.NewtonVisualizerCfg.world_spacing` - to visually separate Newton worlds without changing their simulated poses. - -Fixed -^^^^^ - -* Fixed Newton marker filtering for environment-major marker arrays and aligned marker - overlays with visual world spacing. diff --git a/source/isaaclab_visualizers/config/extension.toml b/source/isaaclab_visualizers/config/extension.toml index 5f4716d57eb..a91c87dbc9a 100644 --- a/source/isaaclab_visualizers/config/extension.toml +++ b/source/isaaclab_visualizers/config/extension.toml @@ -1,7 +1,7 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "1.0.4" +version = "1.1.0" # Description category = "isaaclab" diff --git a/source/isaaclab_visualizers/docs/CHANGELOG.rst b/source/isaaclab_visualizers/docs/CHANGELOG.rst index 57ee6c175ca..5f1f33733c0 100644 --- a/source/isaaclab_visualizers/docs/CHANGELOG.rst +++ b/source/isaaclab_visualizers/docs/CHANGELOG.rst @@ -1,6 +1,24 @@ Changelog --------- +1.1.0 (2026-07-02) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :meth:`~isaaclab_visualizers.newton.NewtonVisualizer.render_rgb_array` to access the latest + Newton viewer framebuffer. +* Added :attr:`~isaaclab_visualizers.newton.NewtonVisualizerCfg.world_spacing` + to visually separate Newton worlds without changing their simulated poses. + +Fixed +^^^^^ + +* Fixed Newton marker filtering for environment-major marker arrays and aligned marker + overlays with visual world spacing. + + 1.0.4 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_visualizers/pyproject.toml b/source/isaaclab_visualizers/pyproject.toml index c3ba9d6f7d9..9fac9331027 100644 --- a/source/isaaclab_visualizers/pyproject.toml +++ b/source/isaaclab_visualizers/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab-visualizers" -version = "1.0.4" +version = "1.1.0" description = "Visualizer backends for Isaac Lab (Kit, Newton, Rerun, Viser)." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From e45d9329b06f094d0fcb26c0b6337ae0d4d52391 Mon Sep 17 00:00:00 2001 From: Richard Lei Date: Thu, 2 Jul 2026 20:27:55 +1200 Subject: [PATCH 064/139] Added instance_segmentation_fast & instance_id_segmentation_fast support to OVRTX renderer integration (#6312) # Description Added instance_segmentation_fast & instance_id_segmentation_fast support to OVRTX renderer integration to match IsaacSim RTX integration. Additionally, added render correctness tests for instance_segmentation_fast across ovrtx & isaacsim RTX renderers. - Only the colorized output is tested at this moment. We will add tests for the non-colorized in a follow up PR - Because these data types are based on an RTX AOVs which produces IDs which are not stable across runs (hence the `_fast`), we only compare the alpha mask of the colorized output against the golden image's. - I extended the test cartpole cfg in our rendering_test_utils.py with semantic tags to further test this data type, as shadow hand is currently the only other task that natively has objects with semantic tags. ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots See the images in 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 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 --- .../overview/core-concepts/sensors/camera.rst | 15 +- .../ovrtx-instance-segmentation-fast.rst | 13 ++ .../isaaclab_ov/renderers/ovrtx_renderer.py | 144 ++++++++++++++---- .../renderers/ovrtx_renderer_cfg.py | 14 ++ .../renderers/ovrtx_renderer_kernels.py | 17 +++ .../isaaclab_ov/renderers/ovrtx_usd.py | 24 ++- .../test/test_ovrtx_renderer_contract.py | 2 + source/isaaclab_ov/test/test_ovrtx_usd.py | 18 +++ .../ovrtx-instance-segmentation-fast.rst | 9 ++ ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + ...renderer-instance_id_segmentation_fast.png | 3 + ...tx_renderer-instance_segmentation_fast.png | 3 + .../test/rendering_test_utils.py | 79 +++++++++- 36 files changed, 368 insertions(+), 45 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst create mode 100644 source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-instance_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-instance_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-instance_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-instance_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-instance_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png diff --git a/docs/source/overview/core-concepts/sensors/camera.rst b/docs/source/overview/core-concepts/sensors/camera.rst index 23a6f5f11da..6928477f2a2 100644 --- a/docs/source/overview/core-concepts/sensors/camera.rst +++ b/docs/source/overview/core-concepts/sensors/camera.rst @@ -31,20 +31,16 @@ The renderer used by a camera is configured via the ``renderer_cfg`` field on .. list-table:: :header-rows: 1 - :widths: 30 30 40 + :widths: 30 30 * - ``renderer_cfg`` - Requires Isaac Sim? - - Supported data types * - ``IsaacRtxRendererCfg`` *(default)* - Yes - - All annotators (rgb, rgba, depth, distances, normals, motion vectors, semantic/instance segmentation) * - ``NewtonWarpRendererCfg`` - No (kit-less) - - rgb, rgba, rgb_hdr, depth, normals, instance segmentation * - ``OVRTXRendererCfg`` - No (+ ``isaaclab_ov``) - - rgb, rgba, rgb_hdr, depth, distances, normals, semantic segmentation .. note:: @@ -251,11 +247,11 @@ is :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`, and ``Newton Warp`` is - ❌ * - ``instance_segmentation_fast`` - ✅ - - ❌ + - ✅ - ✅ * - ``instance_id_segmentation_fast`` - ✅ - - ❌ + - ✅ - ❌ RGB and RGBA @@ -506,5 +502,10 @@ An ``info`` dictionary is available via ``tiled_camera.data.info['instance_segme - If ``colorize_instance_segmentation=True``: shape ``(B, H, W, 4)``, type ``torch.uint8``. - If ``colorize_instance_segmentation=False``: shape ``(B, H, W, 1)``, type ``torch.int32``. +Pixels belonging to prims with no assigned semantic label are rendered black +(RGBA ``(0, 0, 0, 255)``) when ``colorize_instance_segmentation=True``. When +``colorize_instance_segmentation=False``, those pixels instead carry the raw +UNLABELLED instance ID (``1``) rather than a color value. + The ``idToLabels`` dict maps color to USD prim path. The ``idToSemantics`` dict maps color to semantic label. diff --git a/source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst b/source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst new file mode 100644 index 00000000000..7a2d05faa43 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst @@ -0,0 +1,13 @@ +Added +^^^^^ + +* Added :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` and + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` config fields + to :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`. +* Added support for the ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` + data types in the OVRTX renderer, via the ``NonStableInstanceSegmentation`` and + ``InstanceSegmentationSD`` AOVs respectively. When the corresponding + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` / + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` flag is + ``True`` (default), instance IDs are colorized and returned as ``uint8`` RGBA; when ``False``, + raw ``uint32`` instance IDs are returned. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 0ed157fddc7..38ae6b41a1f 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -64,6 +64,7 @@ extract_all_rgb_float_tiles_kernel, extract_all_rgb_half_tiles_kernel, extract_all_rgba_tiles_kernel, + extract_all_uint32_tiles_kernel, generate_random_colors_from_ids_kernel, sync_newton_transforms_kernel, ) @@ -245,6 +246,14 @@ def provides_temporal_camera_data(cls, data_type: str) -> bool: 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`.""" + instance_seg_spec = ( + RenderBufferSpec(4, wp.uint8) if self.cfg.colorize_instance_segmentation else RenderBufferSpec(1, wp.uint32) + ) + instance_id_seg_spec = ( + RenderBufferSpec(4, wp.uint8) + if self.cfg.colorize_instance_id_segmentation + else RenderBufferSpec(1, wp.uint32) + ) return { RenderBufferKind.RGBA: RenderBufferSpec(4, wp.uint8), RenderBufferKind.RGB: RenderBufferSpec(3, wp.uint8), @@ -254,6 +263,8 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: 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.INSTANCE_SEGMENTATION_FAST: instance_seg_spec, + RenderBufferKind.INSTANCE_ID_SEGMENTATION_FAST: instance_id_seg_spec, RenderBufferKind.DEPTH: RenderBufferSpec(1, wp.float32), RenderBufferKind.DISTANCE_TO_IMAGE_PLANE: RenderBufferSpec(1, wp.float32), RenderBufferKind.DISTANCE_TO_CAMERA: RenderBufferSpec(1, wp.float32), @@ -276,7 +287,7 @@ def __init__(self, cfg: OVRTXRendererCfg): self._initialized_scene = False self._exported_usd_string: str | None = None self._camera_rel_path: str | None = None - self._output_semantic_color_buffer: wp.array | None = None + self._output_id_color_buffers: dict[str, wp.array] = {} self._clone_plan: ClonePlan | None = None logger.info("Creating OVRTX renderer...") @@ -644,12 +655,20 @@ def read_output( See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.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=self._device) + def _generate_random_colors_from_ids(self, input_ids: wp.array, output_colors: wp.array | None) -> wp.array: + """Generate pseudo-random RGBA colors from uint32 IDs into a reusable output buffer. + + Args: + input_ids: 3-D uint32 Warp array of shape (H, W, 1). + output_colors: Existing color buffer to reuse, or None to allocate a new one. + + Returns: + Color buffer containing the generated colors. + """ - output_colors = self._output_semantic_color_buffer + # Lazily allocate, and re-allocate if the shape changes. + if output_colors is None or output_colors.shape != input_ids.shape: + output_colors = wp.zeros(shape=input_ids.shape, dtype=wp.uint32, device=self._device) wp.launch( kernel=generate_random_colors_from_ids_kernel, @@ -657,9 +676,71 @@ def _generate_random_colors_from_ids(self, input_ids: wp.array) -> wp.array: inputs=[input_ids, output_colors], device=self._device, ) - return output_colors + def _process_id_segmentation_render_var( + self, + render_data: OVRTXRenderData, + frame, + output_buffers: dict, + render_var_name: str, + buffer_key: str, + colorize: bool, + ) -> None: + """Extract a uint32 ID-segmentation render var into ``output_buffers[buffer_key]``. + + Shared by ``semantic_segmentation`` (``SemanticSegmentation``), ``instance_segmentation_fast`` + (``NonStableInstanceSegmentation``), and ``instance_id_segmentation_fast`` (``InstanceSegmentationSD``), + which only differ in the source render var, the destination buffer, and whether to colorize. + + Args: + render_data: OVRTX render data for the current frame. + frame: OVRTX frame holding the mapped render vars. + output_buffers: Destination warp buffers, keyed by data type. + render_var_name: Name of the OVRTX render var to read. + buffer_key: Data type key into ``output_buffers``. + colorize: If True, IDs are mapped to RGBA colors; otherwise raw uint32 IDs are copied. + """ + if render_var_name not in frame.render_vars or buffer_key not in output_buffers: + return + + with frame.render_vars[render_var_name].map(device=Device.CUDA) as mapping: + tiled_data = wp.from_dlpack(mapping.tensor) + if tiled_data.dtype != wp.uint32: + return + + if colorize: + color_buffer = self._generate_random_colors_from_ids( + tiled_data, self._output_id_color_buffers.get(buffer_key) + ) + self._output_id_color_buffers[buffer_key] = color_buffer + + colors_torch = wp.to_torch(color_buffer) + colors_uint8 = colors_torch.view(torch.uint8) + if colors_torch.dim() == 2: + h, w = colors_torch.shape + colors_uint8 = colors_uint8.reshape(h, w, 4) + tiled_data = wp.from_torch(colors_uint8, dtype=wp.uint8) + self._extract_rgba_tiles(render_data, tiled_data, output_buffers, buffer_key) + else: + # Non-colorized: ensure (TH, TW, 1) shape for the uint32 extraction kernel. + data_torch = wp.to_torch(tiled_data) + if data_torch.dim() == 2: + data_torch = data_torch.unsqueeze(-1) + tiled_data = wp.from_torch(data_torch, dtype=wp.uint32) + wp.launch( + kernel=extract_all_uint32_tiles_kernel, + dim=(render_data.num_envs, render_data.height, render_data.width), + inputs=[ + tiled_data, + output_buffers[buffer_key], + render_data.num_cols, + render_data.width, + render_data.height, + ], + device=self._device, + ) + def _extract_rgba_tiles( self, render_data: OVRTXRenderData, @@ -792,29 +873,34 @@ def _process_render_frame(self, render_data: OVRTXRenderData, frame, output_buff tiled_hdr_data = self._prepare_ppisp_hdr_source(render_data, tiled_hdr_data, output_buffers) self._extract_hdr_color_tiles(render_data, tiled_hdr_data, output_buffers) - if "SemanticSegmentation" in frame.render_vars and "semantic_segmentation" in output_buffers: - with frame.render_vars["SemanticSegmentation"].map(device=Device.CUDA) as mapping: - tiled_semantic_data = wp.from_dlpack(mapping.tensor) - - if tiled_semantic_data.dtype == wp.uint32: - semantic_colors = self._generate_random_colors_from_ids(tiled_semantic_data) - - semantic_torch = wp.to_torch(semantic_colors) - semantic_uint8 = semantic_torch.view(torch.uint8) - - if semantic_torch.dim() == 2: - h, w = semantic_torch.shape - semantic_uint8 = semantic_uint8.reshape(h, w, 4) + # Semantic segmentation is always colorized: unlike instance (id) segmentation, there is no + # raw-uint32 output mode exposed for it (see OVRTXRenderer.supported_output_types). + self._process_id_segmentation_render_var( + render_data, + frame, + output_buffers, + "SemanticSegmentation", + "semantic_segmentation", + True, + ) - tiled_semantic_data = wp.from_torch(semantic_uint8, dtype=wp.uint8) + self._process_id_segmentation_render_var( + render_data, + frame, + output_buffers, + "NonStableInstanceSegmentation", + "instance_segmentation_fast", + self.cfg.colorize_instance_segmentation, + ) - self._extract_rgba_tiles( - render_data, - tiled_semantic_data, - output_buffers, - "semantic_segmentation", - suffix="semantic", - ) + self._process_id_segmentation_render_var( + render_data, + frame, + output_buffers, + "InstanceSegmentationSD", + "instance_id_segmentation_fast", + self.cfg.colorize_instance_id_segmentation, + ) if "NormalSD" in frame.render_vars and "normals" in output_buffers: with frame.render_vars["NormalSD"].map(device=Device.CUDA) as mapping: @@ -888,5 +974,5 @@ def _safe_unbind(binding, name: str) -> None: self._renderer = None self._render_product_paths.clear() - self._output_semantic_color_buffer = None + self._output_id_color_buffers.clear() self._initialized_scene = False 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 a63b73cd057..7f087308ded 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py @@ -39,3 +39,17 @@ class OVRTXRendererCfg(RendererCfg): log_file_path: str = os.path.join(tempfile.gettempdir(), "ovrtx_renderer.log") """Path for OVRTX log file. Defaults to ``/ovrtx_renderer.log``.""" + + colorize_instance_segmentation: bool = True + """Whether to colorize instance segmentation output. Defaults to True. + + If True, instance IDs are mapped to RGBA colors and returned as a ``uint8`` 4-channel array. + If False, raw instance IDs are returned as a ``uint32`` 1-channel array. + """ + + colorize_instance_id_segmentation: bool = True + """Whether to colorize instance ID segmentation output. Defaults to True. + + If True, instance IDs are mapped to RGBA colors and returned as a ``uint8`` 4-channel array. + If False, raw instance IDs are returned as a ``uint32`` 1-channel array. + """ 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 1b9d27d6101..e7f56eff04e 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py @@ -294,6 +294,23 @@ def random_color_from_id(input_id: wp.uint32) -> wp.uint32: return color +@wp.kernel +def extract_all_uint32_tiles_kernel( + tiled_buffer: wp.array(dtype=wp.uint32, ndim=3), # type: ignore (TH, TW, 1) + output_buffer: wp.array(dtype=wp.uint32, ndim=4), # type: ignore (num_envs, H, W, 1) + num_cols: int, + tile_width: int, + tile_height: int, +): + """Extract all single-channel uint32 tiles (e.g. raw instance segmentation IDs).""" + env_idx, y, x = wp.tid() + tile_x = env_idx % num_cols + tile_y = env_idx // num_cols + src_x = tile_x * tile_width + x + src_y = tile_y * tile_height + y + output_buffer[env_idx, y, x, 0] = tiled_buffer[src_y, src_x, 0] + + @wp.kernel def generate_random_colors_from_ids_kernel( input_ids: wp.array(dtype=wp.uint32, ndim=3), # type: ignore diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py index be89987d64d..443e0d21e4d 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py @@ -23,18 +23,36 @@ def get_render_var_config(data_types: list[str]) -> tuple[str, str, str]: ) use_albedo = "albedo" in data_types use_semantic = "semantic_segmentation" in data_types + use_instance_seg = "instance_segmentation_fast" in data_types + use_instance_id_seg = "instance_id_segmentation_fast" in data_types use_normals = "normals" in data_types use_rgb = any(dt in ["rgb", "rgba"] for dt in data_types) use_hdr = "rgb_hdr" in data_types - if use_depth and not (use_rgb or use_albedo or use_semantic or use_normals): + if use_depth and not ( + use_rgb or use_albedo or use_semantic or use_instance_seg or use_instance_id_seg or use_normals + ): source = "DistanceToCameraSD" if use_distance_to_camera else "DistanceToImagePlaneSD" return "/Render/Vars/depth", "depth", source - if use_albedo and not (use_rgb or use_semantic or use_normals): + if use_albedo and not (use_rgb or use_semantic or use_instance_seg or use_instance_id_seg or use_normals): return "/Render/Vars/albedo", "albedo", "DiffuseAlbedoSD" if use_semantic and not (use_rgb or use_albedo or use_normals): return "/Render/Vars/semantic", "semantic", "SemanticSegmentation" - if use_normals and not (use_rgb or use_albedo or use_semantic or use_depth): + if use_instance_seg and not ( + use_rgb or use_albedo or use_semantic or use_instance_id_seg or use_normals or use_depth or use_hdr + ): + return ( + "/Render/Vars/NonStableInstanceSegmentation", + "NonStableInstanceSegmentation", + "NonStableInstanceSegmentation", + ) + if use_instance_id_seg and not ( + use_rgb or use_albedo or use_semantic or use_instance_seg or use_normals or use_depth or use_hdr + ): + return "/Render/Vars/InstanceSegmentationSD", "InstanceSegmentationSD", "InstanceSegmentationSD" + if use_normals and not ( + use_rgb or use_albedo or use_semantic or use_instance_seg or use_instance_id_seg or use_depth + ): return "/Render/Vars/NormalSD", "NormalSD", "NormalSD" if use_hdr and not use_rgb: return "/Render/Vars/HdrColor", "HdrColor", "HdrColor" diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index b1be655a42d..0c918f76ed3 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -82,6 +82,8 @@ def test_ovrtx_supported_output_types_key_set(): RenderBufferKind.SIMPLE_SHADING_DIFFUSE_MDL, RenderBufferKind.SIMPLE_SHADING_FULL_MDL, RenderBufferKind.SEMANTIC_SEGMENTATION, + RenderBufferKind.INSTANCE_SEGMENTATION_FAST, + RenderBufferKind.INSTANCE_ID_SEGMENTATION_FAST, RenderBufferKind.DEPTH, RenderBufferKind.DISTANCE_TO_IMAGE_PLANE, RenderBufferKind.DISTANCE_TO_CAMERA, diff --git a/source/isaaclab_ov/test/test_ovrtx_usd.py b/source/isaaclab_ov/test/test_ovrtx_usd.py index e07a5df28fc..d5fb78c38d0 100644 --- a/source/isaaclab_ov/test/test_ovrtx_usd.py +++ b/source/isaaclab_ov/test/test_ovrtx_usd.py @@ -81,6 +81,24 @@ def test_ovrtx_rgb_hdr_uses_hdr_color_render_var(): assert get_render_var_config(["rgb_hdr"]) == ("/Render/Vars/HdrColor", "HdrColor", "HdrColor") +def test_ovrtx_instance_segmentation_fast_uses_non_stable_instance_segmentation_render_var(): + """Requesting instance_segmentation_fast from OVRTX selects the NonStableInstanceSegmentation render variable.""" + assert get_render_var_config(["instance_segmentation_fast"]) == ( + "/Render/Vars/NonStableInstanceSegmentation", + "NonStableInstanceSegmentation", + "NonStableInstanceSegmentation", + ) + + +def test_ovrtx_instance_id_segmentation_fast_uses_instance_segmentation_sd_render_var(): + """Requesting instance_id_segmentation_fast from OVRTX selects the InstanceSegmentationSD render variable.""" + assert get_render_var_config(["instance_id_segmentation_fast"]) == ( + "/Render/Vars/InstanceSegmentationSD", + "InstanceSegmentationSD", + "InstanceSegmentationSD", + ) + + def test_ovrtx_rgb_and_rgb_hdr_author_both_render_vars(): """Requesting LDR RGB and RGB_HDR keeps both OVRTX render variables.""" render_var_configs = get_render_var_configs(["rgb", "rgb_hdr"]) diff --git a/source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst b/source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst new file mode 100644 index 00000000000..7dba962376b --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst @@ -0,0 +1,9 @@ +Added +^^^^^ + +* Added ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` rendering test + presets to the rendering test utilities for Cartpole, ShadowHand, and Dexsuite environments, + covering both the OVRTX and Isaac RTX renderers. Because instance IDs are non-stable across + runs, golden images are saved with full RGBA colors but comparison is restricted to the alpha + channel only, which encodes the instance mask shape reliably. SSIM is also disabled for these + data types; only the per-pixel L2 gate is used to decide pass/fail. diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..364a6c43ef3 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ea5b5e7f937b293152960d668cae542e379c7d3512be63f83ed9dca17de635e +size 615 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..2a7a680faad --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1834b5dacf9b54375a6e4dc29cab777af63baf3db62ab3519a8f918d86dc980 +size 501 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..3ff7da88904 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45a4f2dc3c72e0c1472a66c096f106fca193ec18921ad86635f8ef862980a419 +size 596 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..c1daa0e1fdc --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5753345a44259432586b1449ac669149faae6839f286c2dbd51d7c10fc4906fe +size 502 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..0fcec3448e1 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df0dacf8a21ac62bfbd9d4c43354a108283d99eb26d7c661156dded7f9216718 +size 593 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..c1daa0e1fdc --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5753345a44259432586b1449ac669149faae6839f286c2dbd51d7c10fc4906fe +size 502 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..0ecda564081 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f38e6af9aaa8e80ab22ee5cc4139685941c4aa26e2a355e98f67cf54f1f0bdd8 +size 600 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..8bf4d83fe30 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:448cf080979ce371de5d8ffb61de6615b906dbe852aa82c018d45467e59016ec +size 501 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..8d4c654d859 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f0bdb1f222020492308ba02bb1c2328a313abc2431aa6e475cbf667b25c1aea +size 1268 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..273343a0190 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7681c231ab2c75ee863b70f61a252480cb42d2214ef2d4397f0aa418d95aa82a +size 703 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..b13e2a8397c --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e71bcee0ed23c5eb3f243b626560c34f40f9b7d7aec8a3a7fcaa39452512d5a7 +size 1258 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..273343a0190 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/newton-ovrtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7681c231ab2c75ee863b70f61a252480cb42d2214ef2d4397f0aa418d95aa82a +size 703 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..c46027f958b --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:faf7bd52ff523f811df1324c51383491020f053108b365954b7f079f3d26154a +size 1260 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..273343a0190 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7681c231ab2c75ee863b70f61a252480cb42d2214ef2d4397f0aa418d95aa82a +size 703 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..df76e2b9021 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:692702c1fdc7180b17ce49ee8c690ff2bd54e0f70d4145477987dc76533cd4af +size 1280 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..762a2318431 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b8729d722d1780272b24f0804e517ea024ec2b93f4a0fa3e26c82f222f5c9eb +size 700 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..c27c79825e3 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50ebc2502eca0cab618f5e28238724cf7e921d105c4d777889a95dff32b04b9b +size 1285 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..762a2318431 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/newton-ovrtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b8729d722d1780272b24f0804e517ea024ec2b93f4a0fa3e26c82f222f5c9eb +size 700 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..3a91bdd06c6 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82f83c645e08169008c3e8620a93d82963aa17de990c30f9fd460b6f99e91c54 +size 1280 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..762a2318431 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b8729d722d1780272b24f0804e517ea024ec2b93f4a0fa3e26c82f222f5c9eb +size 700 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..a9b6aa29fc5 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7d509b2433290260065056f37d597d889882e0249f4bd1c2a73848bb767b4ac +size 3813 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..5eeab599e71 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d01750545fb0798db0461a97bb65e1a27e4933fa4d9ee3c48272181459bb7ae +size 1480 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..dbee6f99374 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07e2bec85c84cbf9884176ac07d37dfead6f8f7a72217095f2bd3a1a1e4bdd5d +size 3768 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..0a35c6d80a8 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27961cd19e5a0490581141ff00959c964082ce540373978ca7dccc81a83ce407 +size 1482 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png new file mode 100644 index 00000000000..e2cf7bceeda --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3c55c9d15098d60d99701732470aa7a14de07896bd1772a2d3d06d74b67ea77 +size 3556 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png new file mode 100644 index 00000000000..213d8ae9364 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-instance_segmentation_fast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19ccb59003bec3d086580d0842606403d6b6095d38f1ba8688872ffdff695867 +size 1522 diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index 0e4a8433a84..c2bcef398a1 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -59,7 +59,13 @@ # outputs where the per-pixel value distribution is highly non-uniform after normalisation (e.g. depth, where we # divide by the max value so tiny absolute differences near the far plane dominate windowed variance). For these # data types we still compute SSIM for reporting, but only the per-pixel L2 gate is used to decide pass/fail. -_SSIM_DISABLED_DATA_TYPES: set[str] = {"depth", "distance_to_camera", "distance_to_image_plane"} +_SSIM_DISABLED_DATA_TYPES: set[str] = { + "depth", + "distance_to_camera", + "distance_to_image_plane", + "instance_segmentation_fast", + "instance_id_segmentation_fast", +} # Directory for comparison images saved during the test session. # Located under the pytest output root so it gets copied alongside test reports. @@ -86,6 +92,8 @@ "distance_to_camera", "distance_to_image_plane", "normals", + "instance_segmentation_fast", + "instance_id_segmentation_fast", ) @@ -594,10 +602,25 @@ def validate_camera_outputs( failed_data_types[data_type] = f"Error opening golden image: {error}" continue + # Instance seg colors are non-stable across runs (NonStableInstanceSegmentation / + # InstanceSegmentationSD IDs vary). Compare only the alpha channel (instance mask shape) + # by zeroing RGB on both images. + result_image_for_comparison = result_image + golden_image_for_comparison = golden_image + if data_type in {"instance_segmentation_fast", "instance_id_segmentation_fast"}: + + def _alpha_only(img: Image.Image) -> Image.Image: + r, g, b, a = img.split() + zero = r.point(lambda _: 0) + return Image.merge("RGBA", (zero, zero, zero, a)) + + result_image_for_comparison = _alpha_only(result_image) + golden_image_for_comparison = _alpha_only(golden_image) + check_ssim = data_type not in _SSIM_DISABLED_DATA_TYPES succeeded, error_message, diff_pct, ssim_score = _compare_images( - result_image, - golden_image, + result_image_for_comparison, + golden_image_for_comparison, max_different_pixels_percentage, check_ssim=check_ssim, ssim_threshold=ssim_threshold, @@ -618,6 +641,9 @@ def validate_camera_outputs( "img_golden_path": None, } + # We want to keep the record in the test artifact so that we can have a chance to see whether + # test cases that appear successful is a false positive or not, e.g. image diff threshold is set + # 8%, if the result image is 7% different from the golden, it is considered a pass if diff_pct > 0: prefix = f"{test_name}-{physics_backend}-{renderer}-{data_type}" entry["img_result_path"] = _save_comparison_image(result_image, f"{prefix}-actual.png") @@ -659,6 +685,8 @@ class _ShadowHandTiledCameraTestCfg(ShadowHandTiledCameraCfg): distance_to_camera = _ShadowHandBaseTiledCameraCfg(data_types=["distance_to_camera"]) distance_to_image_plane = _ShadowHandBaseTiledCameraCfg(data_types=["distance_to_image_plane"]) normals = _ShadowHandBaseTiledCameraCfg(data_types=["normals"]) + instance_segmentation_fast = _ShadowHandBaseTiledCameraCfg(data_types=["instance_segmentation_fast"]) + instance_id_segmentation_fast = _ShadowHandBaseTiledCameraCfg(data_types=["instance_id_segmentation_fast"]) @configclass class _ShadowHandCameraTestEnvCfg(ShadowHandCameraEnvCfg): @@ -712,6 +740,8 @@ def rendering_test_cartpole( from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env import CartpoleCameraEnv from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env_cfg import CartpoleCameraEnvCfg, CartpoleTiledCameraCfg + from isaaclab_assets.robots.cartpole import CARTPOLE_CFG + @configclass class _CartpoleTiledCameraTestCfg(CartpoleTiledCameraCfg): distance_to_camera = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg(data_types=["distance_to_camera"]) @@ -719,18 +749,37 @@ class _CartpoleTiledCameraTestCfg(CartpoleTiledCameraCfg): data_types=["distance_to_image_plane"] ) normals = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg(data_types=["normals"]) + instance_segmentation_fast = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg( + data_types=["instance_segmentation_fast"] + ) + instance_id_segmentation_fast = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg( + data_types=["instance_id_segmentation_fast"] + ) + + @configclass + class _BaseCartpoleCameraEnvTestCfg(CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg): + robot_cfg = CARTPOLE_CFG.replace( + prim_path="/World/envs/env_.*/Robot", + spawn=CARTPOLE_CFG.spawn.replace(semantic_tags=[("class", "cartpole")]), + ) @configclass class _CartpoleCameraTestEnvCfg(CartpoleCameraEnvCfg): - distance_to_camera = CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg( + distance_to_camera = _BaseCartpoleCameraEnvTestCfg( observation_space=[1, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() ) - distance_to_image_plane = CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg( + distance_to_image_plane = _BaseCartpoleCameraEnvTestCfg( observation_space=[1, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() ) - normals = CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg( + normals = _BaseCartpoleCameraEnvTestCfg( observation_space=[3, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() ) + instance_segmentation_fast = _BaseCartpoleCameraEnvTestCfg( + observation_space=[4, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() + ) + instance_id_segmentation_fast = _BaseCartpoleCameraEnvTestCfg( + observation_space=[4, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() + ) env_cfg = _CartpoleCameraTestEnvCfg() env_cfg = _apply_overrides_to_env_cfg( @@ -807,6 +856,24 @@ class _DexsuiteBaseTiledCameraTestCfg(BaseTiledCameraCfg): normals64 = BASE_CAMERA_CFG.replace(data_types=["normals"], width=64, height=64) normals128 = BASE_CAMERA_CFG.replace(data_types=["normals"], width=128, height=128) normals256 = BASE_CAMERA_CFG.replace(data_types=["normals"], width=256, height=256) + instance_segmentation_fast64 = BASE_CAMERA_CFG.replace( + data_types=["instance_segmentation_fast"], width=64, height=64 + ) + instance_segmentation_fast128 = BASE_CAMERA_CFG.replace( + data_types=["instance_segmentation_fast"], width=128, height=128 + ) + instance_segmentation_fast256 = BASE_CAMERA_CFG.replace( + data_types=["instance_segmentation_fast"], width=256, height=256 + ) + instance_id_segmentation_fast64 = BASE_CAMERA_CFG.replace( + data_types=["instance_id_segmentation_fast"], width=64, height=64 + ) + instance_id_segmentation_fast128 = BASE_CAMERA_CFG.replace( + data_types=["instance_id_segmentation_fast"], width=128, height=128 + ) + instance_id_segmentation_fast256 = BASE_CAMERA_CFG.replace( + data_types=["instance_id_segmentation_fast"], width=256, height=256 + ) @configclass class _DexsuiteSingleCameraTestSceneCfg(SingleCameraSceneCfg): From 378dc59f05b43e18e53b65e2c6dcdff2ba072892 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:04:27 -0400 Subject: [PATCH 065/139] Update Cartpole Task - Bump rsl-rl (#6294) # Description This PR updates and aligns the direct and manager-based Cartpole tasks. The RL workflow changes: - Bump `rsl-rl-lib` to 5.4.1 and use its native image-only `CNNModel`, removing the Isaac Lab override that is no longer required. - Add task-aware `latest` and `best` checkpoint selectors to the unified training and play commands for RL-Games, RSL-RL, SB3, and SKRL. Try it out with ```uv run play --rl_library rsl_rl --task xxxxxx --checkpoint latest``` - Write a `run.json` manifest for new training runs so checkpoint selection is constrained by RL library, task, and agent metadata. Historical runs without a manifest remain available through explicit checkpoint paths but are intentionally excluded from selector-based discovery. - Document checkpoint selector usage in the reinforcement-learning workflow documentation. The Cartpole changes: - Align direct and manager-based state observations, resets, termination bounds, episode horizon, reward terms, camera frame stacking, and lighting. - Use 64 by 64 camera observations and a shared RSL-RL camera PPO configuration with a camera actor and privileged state critic. - Add the `ovphysx` physics preset to manager-based Cartpole, enabling the full physics and renderer backend matrix for camera tasks. - Prevent camera type annotations from importing `pxr` before `SimulationApp` starts. Existing policies trained against the previous Cartpole MDP must be retrained. Dependencies: - `rsl-rl-lib==5.4.1` Fixes # (not provided) ## 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 ## Testing - `uv run --with pytest pytest scripts/reinforcement_learning/test/test_checkpoint_manifest.py -q` (`5 passed`) - `uv run --with pytest pytest source/isaaclab_tasks/test/core/test_env_cfg_no_forbidden_imports.py -k Isaac-Cartpole -q` (`6 passed`) - `uv run train --rl_library rsl_rl --task Isaac-Cartpole --num_envs 16 --max_iterations 1` - One-iteration RSL-RL smoke training for both `Isaac-Cartpole-Camera` and `Isaac-Cartpole-Camera-Direct` with `physics=newton_mjwarp renderer=newton_renderer` - Verified that manager and direct camera runner configurations are identical except for `experiment_name` - `./isaaclab.sh -f` ## Screenshots Not applicable. This PR does not change a graphical user interface. ## 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 - [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) - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/source/features/hydra.rst | 9 +- .../rl_existing_scripts.rst | 12 + scripts/reinforcement_learning/common.py | 132 +++++++++ .../rl_games/play_rl_games.py | 16 +- .../rl_games/train_rl_games.py | 28 +- .../reinforcement_learning/rsl_rl/cli_args.py | 2 +- .../rsl_rl/play_rsl_rl.py | 10 + .../rsl_rl/train_rsl_rl.py | 21 +- .../reinforcement_learning/sb3/play_sb3.py | 13 +- .../reinforcement_learning/sb3/train_sb3.py | 24 +- .../reinforcement_learning/skrl/play_skrl.py | 17 +- .../reinforcement_learning/skrl/train_skrl.py | 32 ++- .../test/test_checkpoint_manifest.py | 133 +++++++++ .../changelog.d/checkpoint-selectors.rst | 4 + .../test_manager_based_rl_env_obs_spaces.py | 114 +++----- .../changelog.d/mh-rslrl_bump.minor.rst | 14 + .../isaaclab_rl/rsl_rl/__init__.pyi | 2 - .../isaaclab_rl/isaaclab_rl/rsl_rl/models.py | 31 -- .../isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py | 5 +- source/isaaclab_rl/pyproject.toml | 4 +- .../cartpole-state-mdp-alignment.rst | 14 + .../cartpole_camera_env_cfg.py | 6 + .../core/cartpole/agents/rsl_rl_ppo_cfg.py | 41 ++- .../cartpole/cartpole_direct_camera_env.py | 65 +---- .../cartpole_direct_camera_env_cfg.py | 14 +- .../core/cartpole/cartpole_direct_env.py | 65 +++-- .../core/cartpole/cartpole_direct_env_cfg.py | 13 +- .../cartpole_manager_camera_env_cfg.py | 22 +- .../core/cartpole/cartpole_manager_env_cfg.py | 14 +- .../isaaclab_tasks/core/cartpole/constants.py | 20 -- .../core/cartpole/mdp/__init__.pyi | 4 +- .../core/cartpole/mdp/observations.py | 66 +++++ ...est_cartpole_camera_presets_frame_stack.py | 269 ------------------ .../test/rendering_test_utils.py | 16 +- tools/wheel_builder/res/python_packages.toml | 6 +- 35 files changed, 711 insertions(+), 547 deletions(-) create mode 100644 scripts/reinforcement_learning/test/test_checkpoint_manifest.py create mode 100644 source/isaaclab/changelog.d/checkpoint-selectors.rst create mode 100644 source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst delete mode 100644 source/isaaclab_rl/isaaclab_rl/rsl_rl/models.py create mode 100644 source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst delete mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/cartpole/constants.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/cartpole/mdp/observations.py delete mode 100644 source/isaaclab_tasks/test/core/test_cartpole_camera_presets_frame_stack.py diff --git a/docs/source/features/hydra.rst b/docs/source/features/hydra.rst index 9e75dd17db2..a4c448c536f 100644 --- a/docs/source/features/hydra.rst +++ b/docs/source/features/hydra.rst @@ -112,10 +112,13 @@ For example, for the configuration of the Cartpole camera environment: :language: python :start-at: class CartpoleTiledCameraCfg :end-at: observation_space = [3, 100, 100] - :emphasize-lines: 12, 44 + :emphasize-lines: 12, 43 -If the user were to modify the width of the camera, i.e. ``env.tiled_camera.width=128``, then the parameter -``env.observation_space=[3,100,128]`` must be updated and given as input as well. +The configuration declares the single-frame shape. At environment initialization, the default +``frame_stack=2`` expands it to an effective policy observation shape of ``[6,100,100]``. +If the user were to modify the width of the camera, i.e. ``env.tiled_camera.width=128``, then the +single-frame parameter ``env.observation_space=[3,100,128]`` must be updated and given as input as +well, producing an effective stacked shape of ``[6,100,128]``. Similarly, the ``__post_init__`` method is not updated with the command line inputs. In the ``LocomotionVelocityRoughEnvCfg``, for example, the post init update is as follows: diff --git a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst index 97f4b98bb8c..24720d30587 100644 --- a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst +++ b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst @@ -398,6 +398,18 @@ the repository. The logs directory follows the pattern ``logs///< is the name of the learning framework, ```` is the task name, and ```` is the timestamp at which the training command was executed. +New training runs also store a ``run.json`` manifest in their run directory. This manifest allows the unified +``train`` and ``play`` commands to resolve a checkpoint without copying its path manually. Pass +``--checkpoint latest`` to select the highest-step checkpoint from the newest compatible run: + +.. code:: bash + + ./isaaclab.sh play --rl_library rsl_rl --task Isaac-Cartpole --checkpoint latest + +Pass ``--checkpoint best`` to prefer the library-specific best or final checkpoint. For libraries without a +distinct best checkpoint, ``best`` resolves to the same checkpoint as ``latest``. These selectors are supported +by RL-Games, RSL-RL, skrl, and Stable-Baselines3. RSL-RL training resume continues to require ``--resume``. + To view the logs, run: .. tab-set:: diff --git a/scripts/reinforcement_learning/common.py b/scripts/reinforcement_learning/common.py index 5c77889f448..f99a7577952 100644 --- a/scripts/reinforcement_learning/common.py +++ b/scripts/reinforcement_learning/common.py @@ -9,10 +9,13 @@ import argparse import importlib.util +import json import logging import os +import re import runpy import sys +from datetime import datetime, timezone from pathlib import Path from types import ModuleType from typing import Any @@ -24,6 +27,10 @@ from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml +RUN_MANIFEST_FILENAME = "run.json" +RUN_MANIFEST_VERSION = 1 +CHECKPOINT_SELECTORS = frozenset({"latest", "best"}) + def dispatch_library_entrypoint( argv: list[str] | None, @@ -278,3 +285,128 @@ def dump_train_configs(log_dir: str, env_cfg: Any, agent_cfg: Any) -> None: """ dump_yaml(os.path.join(log_dir, "params", "env.yaml"), env_cfg) dump_yaml(os.path.join(log_dir, "params", "agent.yaml"), agent_cfg) + + +def write_run_manifest( + log_dir: str, + *, + library: str, + task: str, + metadata: dict[str, str] | None = None, +) -> None: + """Write metadata used to discover checkpoints from a training run. + + Args: + log_dir: Training run directory. + library: Reinforcement learning library that owns the run. + task: Task used for training. + metadata: Additional fields used to distinguish compatible runs. + """ + run_dir = Path(log_dir) + run_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "version": RUN_MANIFEST_VERSION, + "library": library, + "task": _normalize_task_name(task), + "created_at": datetime.now(timezone.utc).isoformat(), + "metadata": metadata or {}, + } + manifest_path = run_dir / RUN_MANIFEST_FILENAME + temporary_path = run_dir / f".{RUN_MANIFEST_FILENAME}.{os.getpid()}.tmp" + temporary_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.replace(temporary_path, manifest_path) + + +def resolve_checkpoint_selector( + log_root_path: str, + selector: str, + *, + library: str, + task: str, + checkpoint_pattern: str, + other_dirs: list[str] | None = None, + preferred_checkpoint_pattern: str | None = None, + metadata: dict[str, str] | None = None, +) -> str: + """Resolve a checkpoint selector using manifests from new training runs. + + ``latest`` selects the naturally last checkpoint in the newest compatible + run. ``best`` prefers the backend's canonical best or final checkpoint and + falls back to the same checkpoint used by ``latest``. + + Args: + log_root_path: Directory containing training run directories. + selector: Checkpoint selector, either ``"latest"`` or ``"best"``. + library: Reinforcement learning library expected in the run manifest. + task: Task expected in the run manifest. + checkpoint_pattern: Regular expression matching checkpoint filenames. + other_dirs: Intermediate directories below each run directory. + preferred_checkpoint_pattern: Regular expression for the backend's best or final checkpoint. + metadata: Additional manifest metadata required for compatibility. + + Returns: + Absolute path to the selected checkpoint. + + Raises: + ValueError: If the selector is invalid or no compatible manifested run has a checkpoint. + """ + if selector not in CHECKPOINT_SELECTORS: + raise ValueError(f"Unknown checkpoint selector '{selector}'. Expected one of: {sorted(CHECKPOINT_SELECTORS)}.") + + log_root = Path(log_root_path) + expected_task = _normalize_task_name(task) + expected_metadata = metadata or {} + runs: list[tuple[datetime, Path]] = [] + if log_root.is_dir(): + for run_dir in log_root.iterdir(): + if not run_dir.is_dir(): + continue + manifest_path = run_dir / RUN_MANIFEST_FILENAME + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + created_at = datetime.fromisoformat(manifest["created_at"]) + except (FileNotFoundError, KeyError, TypeError, ValueError, json.JSONDecodeError): + continue + if manifest.get("version") != RUN_MANIFEST_VERSION: + continue + if manifest.get("library") != library or manifest.get("task") != expected_task: + continue + manifest_metadata = manifest.get("metadata", {}) + if not isinstance(manifest_metadata, dict): + continue + if any(manifest_metadata.get(key) != value for key, value in expected_metadata.items()): + continue + runs.append((created_at, run_dir)) + + for _, run_dir in sorted(runs, reverse=True): + checkpoint_dir = run_dir.joinpath(*(other_dirs or [])) + if not checkpoint_dir.is_dir(): + continue + checkpoints = [ + path for path in checkpoint_dir.iterdir() if path.is_file() and re.fullmatch(checkpoint_pattern, path.name) + ] + if not checkpoints: + continue + if selector == "best" and preferred_checkpoint_pattern is not None: + preferred = [ + path for path in checkpoints if re.fullmatch(preferred_checkpoint_pattern, path.name) is not None + ] + if preferred: + checkpoints = preferred + checkpoints.sort(key=lambda path: _natural_sort_key(path.name)) + return str(checkpoints[-1].resolve()) + + raise ValueError( + f"No compatible manifested run with a checkpoint was found in '{log_root}'. " + f"Run training with the current unified training entrypoint before using '--checkpoint {selector}'." + ) + + +def _normalize_task_name(task: str) -> str: + """Normalize training and play variants to the same task name.""" + return task.split(":")[-1].removesuffix("-Play") + + +def _natural_sort_key(value: str) -> list[int | str]: + """Return a key that sorts numeric filename components by value.""" + return [int(token) if token.isdigit() else token for token in re.split(r"(\d+)", value)] diff --git a/scripts/reinforcement_learning/rl_games/play_rl_games.py b/scripts/reinforcement_learning/rl_games/play_rl_games.py index 171f07730d3..e9fb02af9a1 100644 --- a/scripts/reinforcement_learning/rl_games/play_rl_games.py +++ b/scripts/reinforcement_learning/rl_games/play_rl_games.py @@ -10,11 +10,13 @@ import math import os import random +import re import sys import time import gymnasium as gym import torch +from common import CHECKPOINT_SELECTORS, resolve_checkpoint_selector from rl_games.common import env_configurations, vecenv from rl_games.common.player import BasePlayer from rl_games.torch_runner import Runner @@ -50,7 +52,7 @@ 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("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") parser.add_argument( "--use_pretrained_checkpoint", @@ -101,6 +103,18 @@ def main(): if not resume_path: print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") return + elif args_cli.checkpoint in CHECKPOINT_SELECTORS: + config_name = agent_cfg["params"]["config"]["name"] + resume_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="rl_games", + task=train_task_name, + checkpoint_pattern=r".*\.pth", + other_dirs=["nn"], + preferred_checkpoint_pattern=rf"{re.escape(config_name)}\.pth", + metadata={"agent": args_cli.agent}, + ) elif args_cli.checkpoint is None: run_dir = agent_cfg["params"]["config"].get("full_experiment_name", ".*") # prefer the best-reward checkpoint (``.pth``); fall back to the latest checkpoint when it has diff --git a/scripts/reinforcement_learning/rl_games/train_rl_games.py b/scripts/reinforcement_learning/rl_games/train_rl_games.py index eb96f62ed87..7a487479609 100644 --- a/scripts/reinforcement_learning/rl_games/train_rl_games.py +++ b/scripts/reinforcement_learning/rl_games/train_rl_games.py @@ -13,11 +13,13 @@ import math import os import random +import re import time from datetime import datetime from distutils.util import strtobool from common import ( + CHECKPOINT_SELECTORS, add_common_train_args, add_isaaclab_launcher_args, apply_env_overrides, @@ -25,9 +27,11 @@ create_isaaclab_env, dump_train_configs, enable_cameras_for_video, + resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, wrap_record_video, + write_run_manifest, ) import isaaclab_tasks # noqa: F401 @@ -47,7 +51,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: 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("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") 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") @@ -101,7 +105,7 @@ def run(argv: list[str]) -> None: if args_cli.max_iterations is not None else agent_cfg["params"]["config"]["max_epochs"] ) - if args_cli.checkpoint is not None: + if args_cli.checkpoint is not None and args_cli.checkpoint not in CHECKPOINT_SELECTORS: resume_path = retrieve_file_path(args_cli.checkpoint) agent_cfg["params"]["load_checkpoint"] = True agent_cfg["params"]["load_path"] = resume_path @@ -124,6 +128,20 @@ def run(argv: list[str]) -> None: log_root_path = os.path.abspath(log_root_path) print(f"[INFO] Logging experiment in directory: {log_root_path}") + if args_cli.checkpoint in CHECKPOINT_SELECTORS: + resume_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="rl_games", + task=args_cli.task, + checkpoint_pattern=r".*\.pth", + other_dirs=["nn"], + preferred_checkpoint_pattern=rf"{re.escape(config_name)}\.pth", + metadata={"agent": args_cli.agent}, + ) + agent_cfg["params"]["load_checkpoint"] = True + agent_cfg["params"]["load_path"] = resume_path + print(f"[INFO]: Loading model checkpoint from: {resume_path}") log_dir = agent_cfg["params"]["config"].get( "full_experiment_name", datetime.now().strftime("%Y-%m-%d_%H-%M-%S") ) @@ -133,6 +151,12 @@ def run(argv: list[str]) -> None: 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) + write_run_manifest( + run_log_dir, + library="rl_games", + task=args_cli.task, + metadata={"agent": args_cli.agent}, + ) dump_train_configs(run_log_dir, env_cfg, agent_cfg) print(f"Exact experiment name requested from command line: {run_log_dir}") diff --git a/scripts/reinforcement_learning/rsl_rl/cli_args.py b/scripts/reinforcement_learning/rsl_rl/cli_args.py index 10edbe2e17f..94fde0b707f 100644 --- a/scripts/reinforcement_learning/rsl_rl/cli_args.py +++ b/scripts/reinforcement_learning/rsl_rl/cli_args.py @@ -29,7 +29,7 @@ def add_rsl_rl_args(parser: argparse.ArgumentParser): # -- load arguments arg_group.add_argument("--resume", action="store_true", default=False, help="Whether to resume from a checkpoint.") arg_group.add_argument("--load_run", type=str, default=None, help="Name of the run folder to resume from.") - arg_group.add_argument("--checkpoint", type=str, default=None, help="Checkpoint file to resume from.") + arg_group.add_argument("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") # -- logger arguments arg_group.add_argument( "--logger", type=str, default=None, choices={"wandb", "tensorboard", "neptune"}, help="Logger module to use." diff --git a/scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py b/scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py index d4236186f65..6f04485f71a 100644 --- a/scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py +++ b/scripts/reinforcement_learning/rsl_rl/play_rsl_rl.py @@ -14,6 +14,7 @@ import gymnasium as gym import torch +from common import CHECKPOINT_SELECTORS, resolve_checkpoint_selector from packaging import version from rsl_rl.runners import DistillationRunner, OnPolicyRunner @@ -120,6 +121,15 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen if not resume_path: print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") return + elif args_cli.checkpoint in CHECKPOINT_SELECTORS: + resume_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="rsl_rl", + task=train_task_name, + checkpoint_pattern=r"model_.*\.pt", + metadata={"agent": args_cli.agent}, + ) elif args_cli.checkpoint: resume_path = retrieve_file_path(args_cli.checkpoint) else: diff --git a/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py b/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py index 69b26b1a4e8..6d30f9faa68 100644 --- a/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py +++ b/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py @@ -18,6 +18,7 @@ from pathlib import Path from common import ( + CHECKPOINT_SELECTORS, add_common_train_args, add_isaaclab_launcher_args, apply_env_overrides, @@ -26,9 +27,11 @@ dump_train_configs, enable_cameras_for_video, import_local_module, + resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, wrap_record_video, + write_run_manifest, ) from packaging import version @@ -143,6 +146,12 @@ def run(argv: list[str]) -> None: if agent_cfg.run_name: log_dir += f"_{agent_cfg.run_name}" log_dir = os.path.join(log_root_path, log_dir) + write_run_manifest( + log_dir, + library="rsl_rl", + task=args_cli.task, + metadata={"agent": args_cli.agent}, + ) configure_io_descriptors(env_cfg, args_cli, logger) env_cfg.log_dir = log_dir @@ -155,7 +164,17 @@ def run(argv: list[str]) -> None: ) 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) + if args_cli.checkpoint in CHECKPOINT_SELECTORS: + resume_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="rsl_rl", + task=args_cli.task, + checkpoint_pattern=r"model_.*\.pt", + metadata={"agent": args_cli.agent}, + ) + else: + 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) diff --git a/scripts/reinforcement_learning/sb3/play_sb3.py b/scripts/reinforcement_learning/sb3/play_sb3.py index bfbd07483f9..87af5950d24 100644 --- a/scripts/reinforcement_learning/sb3/play_sb3.py +++ b/scripts/reinforcement_learning/sb3/play_sb3.py @@ -15,6 +15,7 @@ import gymnasium as gym import torch +from common import CHECKPOINT_SELECTORS, resolve_checkpoint_selector from stable_baselines3 import PPO from stable_baselines3.common.vec_env import VecNormalize @@ -48,7 +49,7 @@ 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("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") parser.add_argument( "--use_pretrained_checkpoint", @@ -97,6 +98,16 @@ def main(): if not checkpoint_path: print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") return + elif args_cli.checkpoint in CHECKPOINT_SELECTORS: + checkpoint_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="sb3", + task=train_task_name, + checkpoint_pattern=r"model(?:_.*)?\.zip", + preferred_checkpoint_pattern=r"model\.zip", + metadata={"agent": args_cli.agent}, + ) elif args_cli.checkpoint is None: # prefer the final model (``model.zip``); fall back to the latest periodic checkpoint when it has # not been written yet (e.g. short or interrupted runs) diff --git a/scripts/reinforcement_learning/sb3/train_sb3.py b/scripts/reinforcement_learning/sb3/train_sb3.py index 282c40a255d..32032c9be8a 100644 --- a/scripts/reinforcement_learning/sb3/train_sb3.py +++ b/scripts/reinforcement_learning/sb3/train_sb3.py @@ -19,6 +19,7 @@ from pathlib import Path from common import ( + CHECKPOINT_SELECTORS, add_common_train_args, add_isaaclab_launcher_args, apply_env_overrides, @@ -26,8 +27,10 @@ create_isaaclab_env, dump_train_configs, enable_cameras_for_video, + resolve_checkpoint_selector, set_hydra_args, wrap_record_video, + write_run_manifest, ) import isaaclab_tasks # noqa: F401 @@ -60,7 +63,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: 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("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") parser.add_argument( "--keep_all_info", action="store_true", @@ -113,6 +116,12 @@ def run(argv: list[str]) -> None: 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) + write_run_manifest( + log_dir, + library="sb3", + task=args_cli.task, + metadata={"agent": args_cli.agent}, + ) dump_train_configs(log_dir, env_cfg, agent_cfg) command = " ".join(sys.orig_argv) @@ -155,7 +164,18 @@ def run(argv: list[str]) -> None: ) agent = PPO(policy_arch, env, verbose=1, tensorboard_log=log_dir, **agent_cfg) - if args_cli.checkpoint is not None: + if args_cli.checkpoint in CHECKPOINT_SELECTORS: + checkpoint_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="sb3", + task=args_cli.task, + checkpoint_pattern=r"model(?:_.*)?\.zip", + preferred_checkpoint_pattern=r"model\.zip", + metadata={"agent": args_cli.agent}, + ) + agent = agent.load(checkpoint_path, env, print_system_info=True) + elif 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) diff --git a/scripts/reinforcement_learning/skrl/play_skrl.py b/scripts/reinforcement_learning/skrl/play_skrl.py index 00775968819..49659285dca 100644 --- a/scripts/reinforcement_learning/skrl/play_skrl.py +++ b/scripts/reinforcement_learning/skrl/play_skrl.py @@ -20,6 +20,7 @@ import gymnasium as gym import skrl import torch +from common import CHECKPOINT_SELECTORS, resolve_checkpoint_selector from packaging import version from isaaclab.app import add_launcher_args, launch_simulation @@ -59,7 +60,7 @@ "--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("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") parser.add_argument( "--use_pretrained_checkpoint", @@ -147,6 +148,20 @@ def main(): if not resume_path: print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") return + elif args_cli.checkpoint in CHECKPOINT_SELECTORS: + resume_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="skrl", + task=train_task_name, + checkpoint_pattern=r".*", + other_dirs=["checkpoints"], + metadata={ + "agent": agent_cfg_entry_point, + "algorithm": algorithm, + "ml_framework": args_cli.ml_framework, + }, + ) elif args_cli.checkpoint: resume_path = os.path.abspath(args_cli.checkpoint) else: diff --git a/scripts/reinforcement_learning/skrl/train_skrl.py b/scripts/reinforcement_learning/skrl/train_skrl.py index e63b4172f06..1be36b42e84 100644 --- a/scripts/reinforcement_learning/skrl/train_skrl.py +++ b/scripts/reinforcement_learning/skrl/train_skrl.py @@ -16,6 +16,7 @@ from datetime import datetime from common import ( + CHECKPOINT_SELECTORS, add_common_train_args, add_isaaclab_launcher_args, apply_env_overrides, @@ -23,9 +24,11 @@ create_isaaclab_env, dump_train_configs, enable_cameras_for_video, + resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, wrap_record_video, + write_run_manifest, ) from packaging import version @@ -51,7 +54,7 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: "--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("--checkpoint", type=str, default=None, help="Checkpoint path, or latest/best.") parser.add_argument( "--ml_framework", type=str, @@ -155,10 +158,35 @@ def run(argv: list[str]) -> None: 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) + write_run_manifest( + log_dir, + library="skrl", + task=args_cli.task, + metadata={ + "agent": agent_cfg_entry_point, + "algorithm": algorithm, + "ml_framework": args_cli.ml_framework, + }, + ) dump_train_configs(log_dir, env_cfg, agent_cfg) - resume_path = retrieve_file_path(args_cli.checkpoint) if args_cli.checkpoint else None + if args_cli.checkpoint in CHECKPOINT_SELECTORS: + resume_path = resolve_checkpoint_selector( + log_root_path, + args_cli.checkpoint, + library="skrl", + task=args_cli.task, + checkpoint_pattern=r".*", + other_dirs=["checkpoints"], + metadata={ + "agent": agent_cfg_entry_point, + "algorithm": algorithm, + "ml_framework": args_cli.ml_framework, + }, + ) + else: + 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 diff --git a/scripts/reinforcement_learning/test/test_checkpoint_manifest.py b/scripts/reinforcement_learning/test/test_checkpoint_manifest.py new file mode 100644 index 00000000000..9aa3d6548dc --- /dev/null +++ b/scripts/reinforcement_learning/test/test_checkpoint_manifest.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 + +import json +import sys +from pathlib import Path + +import pytest + +RL_SCRIPTS_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RL_SCRIPTS_DIR)) + +from common import RUN_MANIFEST_FILENAME, resolve_checkpoint_selector, write_run_manifest # noqa: E402 + + +def _set_created_at(run_dir: Path, created_at: str) -> None: + manifest_path = run_dir / RUN_MANIFEST_FILENAME + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["created_at"] = created_at + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + +def test_write_run_manifest_records_normalized_run_identity(tmp_path: Path) -> None: + run_dir = tmp_path / "run" + + write_run_manifest( + str(run_dir), + library="rsl_rl", + task="example:Isaac-Cartpole-Direct-Play", + metadata={"agent": "rsl_rl_cfg_entry_point"}, + ) + + manifest = json.loads((run_dir / RUN_MANIFEST_FILENAME).read_text(encoding="utf-8")) + assert manifest["version"] == 1 + assert manifest["library"] == "rsl_rl" + assert manifest["task"] == "Isaac-Cartpole-Direct" + assert manifest["metadata"] == {"agent": "rsl_rl_cfg_entry_point"} + assert manifest["created_at"] + + +def test_latest_selects_naturally_last_checkpoint_from_newest_compatible_run(tmp_path: Path) -> None: + old_run = tmp_path / "old" + new_run = tmp_path / "new" + incompatible_run = tmp_path / "incompatible" + for run_dir in (old_run, new_run, incompatible_run): + (run_dir / "checkpoints").mkdir(parents=True) + + write_run_manifest(str(old_run), library="skrl", task="Isaac-Cartpole", metadata={"algorithm": "ppo"}) + write_run_manifest(str(new_run), library="skrl", task="Isaac-Cartpole", metadata={"algorithm": "ppo"}) + write_run_manifest(str(incompatible_run), library="skrl", task="Isaac-Cartpole", metadata={"algorithm": "mappo"}) + _set_created_at(old_run, "2026-01-01T00:00:00+00:00") + _set_created_at(new_run, "2026-01-02T00:00:00+00:00") + _set_created_at(incompatible_run, "2026-01-03T00:00:00+00:00") + + (old_run / "checkpoints" / "agent_100.pt").touch() + (new_run / "checkpoints" / "agent_9.pt").touch() + expected = new_run / "checkpoints" / "agent_10.pt" + expected.touch() + (incompatible_run / "checkpoints" / "agent_20.pt").touch() + + checkpoint = resolve_checkpoint_selector( + str(tmp_path), + "latest", + library="skrl", + task="Isaac-Cartpole-Play", + checkpoint_pattern=r".*\.pt", + other_dirs=["checkpoints"], + metadata={"algorithm": "ppo"}, + ) + + assert checkpoint == str(expected.resolve()) + + +def test_best_prefers_final_checkpoint(tmp_path: Path) -> None: + run_dir = tmp_path / "run" + run_dir.mkdir() + write_run_manifest(str(run_dir), library="sb3", task="Isaac-Cartpole") + (run_dir / "model_2000_steps.zip").touch() + expected = run_dir / "model.zip" + expected.touch() + + checkpoint = resolve_checkpoint_selector( + str(tmp_path), + "best", + library="sb3", + task="Isaac-Cartpole", + checkpoint_pattern=r"model(?:_.*)?\.zip", + preferred_checkpoint_pattern=r"model\.zip", + ) + + assert checkpoint == str(expected.resolve()) + + +def test_latest_skips_newer_run_without_checkpoint(tmp_path: Path) -> None: + complete_run = tmp_path / "complete" + incomplete_run = tmp_path / "incomplete" + complete_run.mkdir() + incomplete_run.mkdir() + write_run_manifest(str(complete_run), library="rsl_rl", task="Isaac-Cartpole") + write_run_manifest(str(incomplete_run), library="rsl_rl", task="Isaac-Cartpole") + _set_created_at(complete_run, "2026-01-01T00:00:00+00:00") + _set_created_at(incomplete_run, "2026-01-02T00:00:00+00:00") + expected = complete_run / "model_10.pt" + expected.touch() + + checkpoint = resolve_checkpoint_selector( + str(tmp_path), + "latest", + library="rsl_rl", + task="Isaac-Cartpole", + checkpoint_pattern=r"model_.*\.pt", + ) + + assert checkpoint == str(expected.resolve()) + + +def test_latest_rejects_unmanifested_historical_run(tmp_path: Path) -> None: + historical_run = tmp_path / "2025-01-01_00-00-00" + historical_run.mkdir() + (historical_run / "model_10.pt").touch() + + with pytest.raises(ValueError, match="current unified training entrypoint"): + resolve_checkpoint_selector( + str(tmp_path), + "latest", + library="rsl_rl", + task="Isaac-Cartpole", + checkpoint_pattern=r"model_.*\.pt", + ) diff --git a/source/isaaclab/changelog.d/checkpoint-selectors.rst b/source/isaaclab/changelog.d/checkpoint-selectors.rst new file mode 100644 index 00000000000..5313cdb8e7d --- /dev/null +++ b/source/isaaclab/changelog.d/checkpoint-selectors.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added ``latest`` and ``best`` checkpoint selectors to unified reinforcement learning training and play commands. diff --git a/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py b/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py index a8008da887d..308978077c2 100644 --- a/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py +++ b/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py @@ -17,9 +17,11 @@ import isaaclab.sim as sim_utils from isaaclab.envs import ManagerBasedRLEnv +from isaaclab.managers import ObservationGroupCfg from isaaclab_tasks.contrib.velocity.config.anymal_c.rough_env_cfg import AnymalCRoughEnvCfg from isaaclab_tasks.core.cartpole.cartpole_manager_camera_env_cfg import CartpoleCameraEnvCfg +from isaaclab_tasks.core.cartpole.cartpole_manager_env_cfg import CartpoleEnvCfg @pytest.mark.parametrize("device", ["cpu", "cuda"]) @@ -29,80 +31,34 @@ def test_non_concatenated_obs_groups_contain_all_terms(device): Before the fix, only the last term in each non-concatenated group would be present in the observation space Dict. This test ensures all terms are correctly included. """ - from isaaclab_tasks.contrib.stack.config.franka.stack_joint_pos_env_cfg import ( - FrankaCubeStackEnvCfg, - ) - # new USD stage sim_utils.create_new_stage() - # configure the stack env - it has multiple non-concatenated observation groups - env_cfg = FrankaCubeStackEnvCfg() + # configure the policy group to return its terms separately + env_cfg = CartpoleEnvCfg() env_cfg.scene.num_envs = 2 # keep num_envs small for testing + env_cfg.observations.policy.concatenate_terms = False env_cfg.sim.device = device env = ManagerBasedRLEnv(cfg=env_cfg) + try: + assert isinstance(env.observation_space, gym.spaces.Dict) + policy_space = env.observation_space.spaces["policy"] + assert isinstance(policy_space, gym.spaces.Dict) - # Verify that observation space is properly structured - assert isinstance(env.observation_space, gym.spaces.Dict), "Top-level observation space should be Dict" - - # Test 'policy' group - should have 9 terms (not just the last one due to the bug) - assert "policy" in env.observation_space.spaces, "Policy group missing from observation space" - policy_space = env.observation_space.spaces["policy"] - assert isinstance(policy_space, gym.spaces.Dict), "Policy group should be Dict space" - - expected_policy_terms = [ - "actions", - "joint_pos", - "joint_vel", - "object", - "cube_positions", - "cube_orientations", - "eef_pos", - "eef_quat", - "gripper_pos", - ] - - # This is the key test - before the fix, only "gripper_pos" (last term) would be present - assert len(policy_space.spaces) == len(expected_policy_terms), ( - f"Policy group should have {len(expected_policy_terms)} terms, got {len(policy_space.spaces)}:" - f" {list(policy_space.spaces.keys())}" - ) - - for term_name in expected_policy_terms: - assert term_name in policy_space.spaces, f"Term '{term_name}' missing from policy group" - assert isinstance(policy_space.spaces[term_name], gym.spaces.Box), f"Term '{term_name}' should be Box space" - - # Test 'subtask_terms' group - should have 3 terms (not just the last one) - assert "subtask_terms" in env.observation_space.spaces, "Subtask_terms group missing from observation space" - subtask_space = env.observation_space.spaces["subtask_terms"] - assert isinstance(subtask_space, gym.spaces.Dict), "Subtask_terms group should be Dict space" + expected_policy_terms = ["joint_pos_rel", "joint_vel_rel"] - expected_subtask_terms = ["grasp_1", "stack_1", "grasp_2"] + assert list(policy_space.spaces) == expected_policy_terms + for term_name in expected_policy_terms: + assert isinstance(policy_space.spaces[term_name], gym.spaces.Box) - # Before the fix, only "grasp_2" (last term) would be present - assert len(subtask_space.spaces) == len(expected_subtask_terms), ( - f"Subtask_terms group should have {len(expected_subtask_terms)} terms, got {len(subtask_space.spaces)}:" - f" {list(subtask_space.spaces.keys())}" - ) - - for term_name in expected_subtask_terms: - assert term_name in subtask_space.spaces, f"Term '{term_name}' missing from subtask_terms group" - assert isinstance(subtask_space.spaces[term_name], gym.spaces.Box), f"Term '{term_name}' should be Box space" - - # Test that we can get observations and they match the space structure - env.reset() - action = torch.tensor(env.action_space.sample(), device=env.device) - obs, reward, terminated, truncated, info = env.step(action) - - # Verify all terms are present in actual observations - for term_name in expected_policy_terms: - assert term_name in obs["policy"], f"Term '{term_name}' missing from policy observation" - - for term_name in expected_subtask_terms: - assert term_name in obs["subtask_terms"], f"Term '{term_name}' missing from subtask_terms observation" - - env.close() + # Test that observations match the space structure. + env.reset() + action = torch.tensor(env.action_space.sample(), device=env.device) + obs, _, _, _, _ = env.step(action) + assert list(obs["policy"]) == expected_policy_terms + finally: + env.close() @pytest.mark.parametrize( @@ -125,19 +81,23 @@ def test_obs_space_follows_clip_contraint(env_cfg_cls, presets, device): env_cfg = resolve_presets(env_cfg_cls(), presets) env_cfg.scene.num_envs = 2 # keep num_envs small for testing - env_cfg.observations.policy.concatenate_terms = False + for group_cfg in vars(env_cfg.observations).values(): + if isinstance(group_cfg, ObservationGroupCfg): + group_cfg.concatenate_terms = False env_cfg.sim.device = device env = ManagerBasedRLEnv(cfg=env_cfg) - for group_name, group_space in env.observation_space.spaces.items(): - for term_name, term_space in group_space.spaces.items(): - term_cfg = getattr(getattr(env_cfg.observations, group_name), term_name) - low = -np.inf if term_cfg.clip is None else term_cfg.clip[0] - high = np.inf if term_cfg.clip is None else term_cfg.clip[1] - assert isinstance(term_space, gym.spaces.Box), ( - f"Expected Box space for {term_name} in {group_name}, got {type(term_space)}" - ) - assert np.all(term_space.low == low) - assert np.all(term_space.high == high) - - env.close() + try: + for group_name, group_space in env.observation_space.spaces.items(): + assert isinstance(group_space, gym.spaces.Dict) + for term_name, term_space in group_space.spaces.items(): + term_cfg = getattr(getattr(env_cfg.observations, group_name), term_name) + low = -np.inf if term_cfg.clip is None else term_cfg.clip[0] + high = np.inf if term_cfg.clip is None else term_cfg.clip[1] + assert isinstance(term_space, gym.spaces.Box), ( + f"Expected Box space for {term_name} in {group_name}, got {type(term_space)}" + ) + assert np.all(term_space.low == low) + assert np.all(term_space.high == high) + finally: + env.close() diff --git a/source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst b/source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst new file mode 100644 index 00000000000..7411f890bac --- /dev/null +++ b/source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst @@ -0,0 +1,14 @@ +Changed +^^^^^^^ + +* Bumped the ``rsl-rl-lib`` dependency to ``5.4.1``, which natively supports image-only policies + (observation sets with no 1D groups). +* Changed :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name` to default to rsl-rl's + ``CNNModel`` now that it supports image-only observations out of the box. + +Removed +^^^^^^^ + +* Removed the Isaac Lab ``CNNModel`` override of rsl-rl's ``CNNModel`` that previously added + image-only observation support. Use rsl-rl's ``CNNModel`` (the new default of + :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name`) instead. diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/__init__.pyi b/source/isaaclab_rl/isaaclab_rl/rsl_rl/__init__.pyi index 074ffca4054..3eae1d119b5 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/__init__.pyi +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/__init__.pyi @@ -11,7 +11,6 @@ __all__ = [ "export_policy_as_jit", "export_policy_as_onnx", "handle_deprecated_rsl_rl_cfg", - "CNNModel", "RslRlBaseRunnerCfg", "RslRlCNNModelCfg", "RslRlMLPModelCfg", @@ -32,7 +31,6 @@ from .distillation_cfg import ( RslRlDistillationStudentTeacherRecurrentCfg, ) from .exporter import export_policy_as_jit, export_policy_as_onnx -from .models import CNNModel from .rl_cfg import ( RslRlBaseRunnerCfg, RslRlCNNModelCfg, diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/models.py b/source/isaaclab_rl/isaaclab_rl/rsl_rl/models.py deleted file mode 100644 index 89907518a81..00000000000 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/models.py +++ /dev/null @@ -1,31 +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 - -"""RSL-RL neural models customized for Isaac Lab.""" - -from __future__ import annotations - -import torch -from rsl_rl.models.cnn_model import CNNModel as _CNNModel -from rsl_rl.models.mlp_model import MLPModel -from rsl_rl.modules import HiddenState -from tensordict import TensorDict - - -class CNNModel(_CNNModel): - """CNN model that supports pure image-only observations. - - The rsl_rl CNN model does not support image-only observations as it calls - :meth:`get_latent` without checking whether the observation groups are empty. - """ - - def get_latent( - self, obs: TensorDict, masks: torch.Tensor | None = None, hidden_state: HiddenState = None - ) -> torch.Tensor: - latent_cnn = torch.cat([self.cnns[group](obs[group]) for group in self.obs_groups_2d], dim=-1) - if not self.obs_groups: - return latent_cnn - latent_1d = MLPModel.get_latent(self, obs, masks, hidden_state) - return torch.cat([latent_1d, latent_cnn], dim=-1) 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 0541096f894..d7575ce258c 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py @@ -114,9 +114,8 @@ class RslRlRNNModelCfg(RslRlMLPModelCfg): class RslRlCNNModelCfg(RslRlMLPModelCfg): """Configuration for CNN model.""" - class_name: str = "isaaclab_rl.rsl_rl.models:CNNModel" - """The model class name. Defaults to Isaac Lab's :class:`~isaaclab_rl.rsl_rl.models.CNNModel`. - """ + class_name: str = "CNNModel" + """The model class name. Defaults to CNNModel.""" @configclass class CNNCfg: diff --git a/source/isaaclab_rl/pyproject.toml b/source/isaaclab_rl/pyproject.toml index fdae2be6e2e..9fc5f43ba20 100644 --- a/source/isaaclab_rl/pyproject.toml +++ b/source/isaaclab_rl/pyproject.toml @@ -46,7 +46,7 @@ rl-games = [ "gym", "standard-distutils", ] -rsl-rl = ["rsl-rl-lib==5.0.1", "onnxscript>=0.5"] +rsl-rl = ["rsl-rl-lib==5.4.1", "onnxscript>=0.5"] all = [ "stable-baselines3>=2.6", "tqdm", "rich", "skrl>=2.1.0", @@ -54,7 +54,7 @@ all = [ "rl-games @ git+https://github.com/isaac-sim/rl_games.git@python3.11", "gym", "standard-distutils", - "rsl-rl-lib==5.0.1", + "rsl-rl-lib==5.4.1", "onnxscript>=0.5", ] diff --git a/source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst b/source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst new file mode 100644 index 00000000000..390b96365a9 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst @@ -0,0 +1,14 @@ +Added +^^^^^ + +* Added the ``ovphysx`` physics preset to the manager-based Cartpole tasks. + +Changed +^^^^^^^ + +* **Breaking:** Aligned the direct and manager-based Cartpole MDPs, including state observations, reset distributions, + termination conditions, episode horizon, reward convention, camera frame stacking, and camera lighting. Retrain + policies previously trained on the Cartpole tasks. + +* Changed all Cartpole camera renderer variants to stack two frames by default, and reduced the RSL-RL camera CNN + size while retaining reliable convergence. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/cartpole_showcase/cartpole_camera/cartpole_camera_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/cartpole_showcase/cartpole_camera/cartpole_camera_env_cfg.py index 6ad66465e48..5f6c7fd2355 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/cartpole_showcase/cartpole_camera/cartpole_camera_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/cartpole_showcase/cartpole_camera/cartpole_camera_env_cfg.py @@ -5,6 +5,8 @@ from __future__ import annotations +import math + from gymnasium import spaces import isaaclab.sim as sim_utils @@ -57,6 +59,7 @@ class CartpoleCameraEnvCfg(DirectRLEnvCfg): # camera tiled_camera: CameraCfg = get_tiled_camera_cfg("rgb") write_image_to_file = False + frame_stack = 1 # spaces action_space = 1 @@ -71,7 +74,10 @@ class CartpoleCameraEnvCfg(DirectRLEnvCfg): # reset max_cart_pos = 3.0 # the cart is reset if it exceeds that position [m] + initial_cart_position_range = (-1.0, 1.0) # [m] + initial_cart_velocity_range = (-0.5, 0.5) # [m/s] initial_pole_angle_range = [-0.125, 0.125] # the range in which the pole angle is sampled from on reset [rad] + initial_pole_velocity_range = (-0.25 * math.pi, 0.25 * math.pi) # [rad/s] # reward scales rew_scale_alive = 1.0 diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/agents/rsl_rl_ppo_cfg.py index e0022a5dcfb..c527c748888 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/agents/rsl_rl_ppo_cfg.py @@ -82,48 +82,43 @@ class CartpolePPORunnerWithSymmetryCfg(CartpolePPORunnerCfg): class CartpoleCameraPPORunnerCfg(RslRlOnPolicyRunnerCfg): """CNN policy for the raw RGB/depth camera observation pipelines.""" - num_steps_per_env = 16 - max_iterations = 500 + num_steps_per_env = 64 + max_iterations = 200 save_interval = 50 experiment_name = "cartpole_camera" - obs_groups = {"actor": ["policy"], "critic": ["policy"]} + obs_groups = {"actor": ["policy"], "critic": ["critic"]} + clip_actions = 1.0 actor = RslRlCNNModelCfg( cnn_cfg=RslRlCNNModelCfg.CNNCfg( - output_channels=[32, 64, 64], - kernel_size=[8, 4, 3], - stride=[4, 2, 1], + output_channels=[8, 16, 16], + kernel_size=[5, 3, 3], + stride=[2, 2, 2], activation="relu", ), - hidden_dims=[512], + hidden_dims=[64], activation="elu", obs_normalization=False, distribution_cfg=RslRlCNNModelCfg.GaussianDistributionCfg(init_std=1.0), ) - critic = RslRlCNNModelCfg( - cnn_cfg=RslRlCNNModelCfg.CNNCfg( - output_channels=[32, 64, 64], - kernel_size=[8, 4, 3], - stride=[4, 2, 1], - activation="relu", - ), - hidden_dims=[512], + critic = RslRlMLPModelCfg( + hidden_dims=[32, 32], activation="elu", obs_normalization=False, ) algorithm = RslRlPpoAlgorithmCfg( - value_loss_coef=1.0, + value_loss_coef=2.0, use_clipped_value_loss=True, clip_param=0.2, - entropy_coef=0.005, - num_learning_epochs=5, - num_mini_batches=4, - learning_rate=1.0e-4, - schedule="adaptive", + entropy_coef=0.0, + num_learning_epochs=4, + num_mini_batches=16, + learning_rate=3.0e-4, + schedule="fixed", gamma=0.99, lam=0.95, - desired_kl=0.01, + desired_kl=0.008, max_grad_norm=1.0, - share_cnn_encoders=True, + share_cnn_encoders=False, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py index 8791d107154..bee7f168468 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py @@ -11,72 +11,24 @@ import isaaclab.sim as sim_utils from isaaclab import cloner from isaaclab.assets import Articulation -from isaaclab.renderers.renderer import Renderer from isaaclab.sensors import Camera, save_images_to_file from isaaclab.utils.buffers import CircularBuffer -from isaaclab.utils.configclass import resolve_cfg_presets from isaaclab.utils.images import is_rgb_like, normalize_camera_image -from isaaclab.utils.string import string_to_callable from isaaclab_tasks.core.cartpole.cartpole_direct_env import CartpoleEnv -from isaaclab_tasks.core.cartpole.constants import ( - CARTPOLE_DISTANT_LIGHT_COLOR, - CARTPOLE_DISTANT_LIGHT_INTENSITY, - CARTPOLE_DISTANT_LIGHT_ORIENTATION, -) if TYPE_CHECKING: from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env_cfg import CartpoleCameraEnvCfg class CartpoleCameraEnv(CartpoleEnv): - """Cartpole environment driven by camera observations. - - Stacks frames to supply the temporal cue Newton needs when the render lacks one; see - :meth:`_resolve_frame_stack_default`. - """ + """Cartpole environment driven by stacked camera observations.""" cfg: CartpoleCameraEnvCfg - @staticmethod - def _resolve_frame_stack_default(camera_cfg, physics_cfg) -> int: - """Default frame-stack size from the backend capability classmethods. - - Stack ``2`` frames when the policy needs a temporal cue to infer velocity but the - observation carries none -- the physics backend has no implicit damping AND the renderer - provides no temporal data for this data type. Otherwise ``1``. The capability lives on the - runtime backend classes (resolved from the cfg without building the sim): - :meth:`~isaaclab.physics.physics_manager.PhysicsManager.provides_implicit_damping` and - :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.provides_temporal_camera_data`. - """ - # ``class_type`` may be a class or a ``"module:Class"`` ResolvableString. - class_type = getattr(physics_cfg, "class_type", None) - if class_type is None: - return 1 - physics_manager_cls = string_to_callable(str(class_type)) if isinstance(class_type, str) else class_type - if physics_manager_cls.provides_implicit_damping(): - return 1 - - renderer_cfg = getattr(camera_cfg, "renderer_cfg", None) - if renderer_cfg is None: - return 2 - data_types = getattr(camera_cfg, "data_types", None) or [] - data_type = data_types[0] if data_types else "" - renderer_cls = Renderer.resolve_class(renderer_cfg) - return 1 if renderer_cls.provides_temporal_camera_data(data_type) else 2 - def __init__(self, cfg: CartpoleCameraEnvCfg, render_mode: str | None = None, **kwargs): - # Flatten preset wrappers so the frame-stack resolution below sees concrete types. - # Idempotent — base ``DirectRLEnv.__init__`` calls this again with no effect. - resolve_cfg_presets(cfg) - - frame_stack = getattr(cfg, "frame_stack", 1) - if frame_stack < 0: - frame_stack = self._resolve_frame_stack_default(cfg.tiled_camera, cfg.sim.physics) - elif frame_stack == 0: - frame_stack = 1 - if hasattr(cfg, "frame_stack"): - cfg.frame_stack = frame_stack + frame_stack = max(1, cfg.frame_stack) + cfg.frame_stack = frame_stack if frame_stack > 1: single_channels = int(cfg.observation_space[0]) cfg.observation_space = [single_channels * frame_stack, *cfg.observation_space[1:]] @@ -112,10 +64,10 @@ def _setup_scene(self): self.scene.articulations["cartpole"] = self.cartpole self.scene.sensors["tiled_camera"] = self._tiled_camera # add lights - light_cfg = sim_utils.DistantLightCfg( - intensity=CARTPOLE_DISTANT_LIGHT_INTENSITY, color=CARTPOLE_DISTANT_LIGHT_COLOR - ) - light_cfg.func("/World/Light", light_cfg, orientation=CARTPOLE_DISTANT_LIGHT_ORIENTATION) + light_cfg = sim_utils.DistantLightCfg(intensity=2000.0, color=(1.0, 1.0, 1.0)) + # quaternion for euler angles (roll, pitch, yaw) = (0, -45, -45) degrees + light_orientation = (-0.14644663035869598, -0.3535534143447876, -0.3535534143447876, 0.8535533547401428) + light_cfg.func("/World/Light", light_cfg, orientation=light_orientation) def _get_observations(self) -> dict: data_type = self.cfg.tiled_camera.data_types[0] @@ -158,7 +110,8 @@ def _get_observations(self) -> dict: if self.cfg.write_image_to_file: save_images_to_file(self._tiled_camera.data.output[data_type] / 255.0, f"cartpole_{data_type}.png") - return {"policy": obs} + critic_obs = super()._get_observations()["policy"] + return {"policy": obs, "critic": critic_obs} def _reset_idx(self, env_ids: Sequence[int] | None): super()._reset_idx(env_ids) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env_cfg.py index a1afbd5d094..8f55242ba4b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env_cfg.py @@ -5,6 +5,8 @@ from __future__ import annotations +import math + import isaaclab.sim as sim_utils from isaaclab.envs import ViewerCfg from isaaclab.scene import InteractiveSceneCfg @@ -52,17 +54,15 @@ class BaseCartpoleCameraEnvCfg(CartpoleEnvCfg): tiled_camera: CartpoleTiledCameraCfg = CartpoleTiledCameraCfg() write_image_to_file = False - frame_stack: int = -1 - """Number of frames to stack along the channel dim. + frame_stack: int = 2 + """Number of frames to stack along the channel dimension. - ``-1`` (default) auto-resolves to ``2`` when the physics lacks damping and the render - carries no temporal cue, else ``1``; see - :meth:`~isaaclab_tasks.core.cartpole.cartpole_direct_camera_env.CartpoleCameraEnv._resolve_frame_stack_default`. - Set to ``1`` to force single-frame; set to ``N > 1`` to force an explicit stack size. + Values less than two disable stacking. """ # spaces: an image instead of the 4-dim joint-state vector observation_space = [3, 100, 100] + state_space = 4 # change viewer settings viewer = ViewerCfg(eye=(20.0, 20.0, 20.0)) @@ -71,7 +71,7 @@ class BaseCartpoleCameraEnvCfg(CartpoleEnvCfg): scene: InteractiveSceneCfg = InteractiveSceneCfg(num_envs=512, env_spacing=20.0, replicate_physics=True) # reset: smaller initial pole angle than the proprioceptive task - initial_pole_angle_range = [-0.125, 0.125] + initial_pole_angle_range = (-0.125 * math.pi, 0.125 * math.pi) # [rad] default = BaseCartpoleCameraEnvCfg() depth = BaseCartpoleCameraEnvCfg(observation_space=[1, 100, 100]) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py index e5598de6400..494423e291a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py @@ -5,7 +5,6 @@ from __future__ import annotations -import math from collections.abc import Sequence from typing import TYPE_CHECKING @@ -16,13 +15,7 @@ from isaaclab.assets import Articulation from isaaclab.envs import DirectRLEnv from isaaclab.sim.spawners.from_files import GroundPlaneCfg, spawn_ground_plane -from isaaclab.utils.math import sample_uniform - -from isaaclab_tasks.core.cartpole.constants import ( - CARTPOLE_DISTANT_LIGHT_COLOR, - CARTPOLE_DISTANT_LIGHT_INTENSITY, - CARTPOLE_DISTANT_LIGHT_ORIENTATION, -) +from isaaclab.utils.math import sample_uniform, wrap_to_pi if TYPE_CHECKING: from isaaclab_tasks.core.cartpole.cartpole_direct_env_cfg import CartpoleEnvCfg @@ -58,10 +51,10 @@ def _setup_scene(self): self.scene.articulations["cartpole"] = self.cartpole # add lights - light_cfg = sim_utils.DistantLightCfg( - intensity=CARTPOLE_DISTANT_LIGHT_INTENSITY, color=CARTPOLE_DISTANT_LIGHT_COLOR - ) - light_cfg.func("/World/Light", light_cfg, orientation=CARTPOLE_DISTANT_LIGHT_ORIENTATION) + light_cfg = sim_utils.DistantLightCfg(intensity=2000.0, color=(1.0, 1.0, 1.0)) + # quaternion for euler angles (roll, pitch, yaw) = (0, -45, -45) degrees + light_orientation = (-0.14644663035869598, -0.3535534143447876, -0.3535534143447876, 0.8535533547401428) + light_cfg.func("/World/Light", light_cfg, orientation=light_orientation) def _pre_physics_step(self, actions: torch.Tensor) -> None: self.actions = self.action_scale * actions.clone() @@ -70,12 +63,14 @@ def _apply_action(self) -> None: self.cartpole.set_joint_effort_target_index(target=self.actions, joint_ids=self._cart_dof_idx) def _get_observations(self) -> dict: + joint_pos_rel = self.joint_pos - self.cartpole.data.default_joint_pos.torch + joint_vel_rel = self.joint_vel - self.cartpole.data.default_joint_vel.torch obs = torch.cat( ( - self.joint_pos[:, self._pole_dof_idx[0]].unsqueeze(dim=1), - self.joint_vel[:, self._pole_dof_idx[0]].unsqueeze(dim=1), - self.joint_pos[:, self._cart_dof_idx[0]].unsqueeze(dim=1), - self.joint_vel[:, self._cart_dof_idx[0]].unsqueeze(dim=1), + joint_pos_rel[:, self._cart_dof_idx[0]].unsqueeze(dim=1), + joint_pos_rel[:, self._pole_dof_idx[0]].unsqueeze(dim=1), + joint_vel_rel[:, self._cart_dof_idx[0]].unsqueeze(dim=1), + joint_vel_rel[:, self._pole_dof_idx[0]].unsqueeze(dim=1), ), dim=-1, ) @@ -93,6 +88,7 @@ def _get_rewards(self) -> torch.Tensor: self.joint_vel[:, self._pole_dof_idx[0]], self.joint_vel[:, self._cart_dof_idx[0]], self.reset_terminated, + self.step_dt, ) return total_reward @@ -100,9 +96,8 @@ def _get_dones(self) -> tuple[torch.Tensor, torch.Tensor]: self.joint_pos = self.cartpole.data.joint_pos.torch self.joint_vel = self.cartpole.data.joint_vel.torch - time_out = self.episode_length_buf >= self.max_episode_length - 1 + time_out = self.episode_length_buf >= self.max_episode_length out_of_bounds = torch.any(torch.abs(self.joint_pos[:, self._cart_dof_idx]) > self.cfg.max_cart_pos, dim=1) - out_of_bounds = out_of_bounds | torch.any(torch.abs(self.joint_pos[:, self._pole_dof_idx]) > math.pi / 2, dim=1) return out_of_bounds, time_out def _reset_idx(self, env_ids: Sequence[int] | None): @@ -116,13 +111,37 @@ def _reset_idx(self, env_ids: Sequence[int] | None): super()._reset_idx(env_ids) joint_pos = self.cartpole.data.default_joint_pos.torch[env_ids].clone() + joint_pos[:, self._cart_dof_idx] += sample_uniform( + self.cfg.initial_cart_position_range[0], + self.cfg.initial_cart_position_range[1], + joint_pos[:, self._cart_dof_idx].shape, + joint_pos.device, + ) + joint_vel = self.cartpole.data.default_joint_vel.torch[env_ids].clone() + joint_vel[:, self._cart_dof_idx] += sample_uniform( + self.cfg.initial_cart_velocity_range[0], + self.cfg.initial_cart_velocity_range[1], + joint_vel[:, self._cart_dof_idx].shape, + joint_vel.device, + ) joint_pos[:, self._pole_dof_idx] += sample_uniform( - self.cfg.initial_pole_angle_range[0] * math.pi, - self.cfg.initial_pole_angle_range[1] * math.pi, + self.cfg.initial_pole_angle_range[0], + self.cfg.initial_pole_angle_range[1], joint_pos[:, self._pole_dof_idx].shape, joint_pos.device, ) - joint_vel = self.cartpole.data.default_joint_vel.torch[env_ids].clone() + joint_vel[:, self._pole_dof_idx] += sample_uniform( + self.cfg.initial_pole_velocity_range[0], + self.cfg.initial_pole_velocity_range[1], + joint_vel[:, self._pole_dof_idx].shape, + joint_vel.device, + ) + + # clamp the sampled state to the joint limits (matches the manager-based reset_joints_by_offset) + joint_pos_limits = self.cartpole.data.soft_joint_pos_limits.torch[env_ids] + joint_pos = joint_pos.clamp_(joint_pos_limits[..., 0], joint_pos_limits[..., 1]) + joint_vel_limits = self.cartpole.data.soft_joint_vel_limits.torch[env_ids] + joint_vel = joint_vel.clamp_(-joint_vel_limits, joint_vel_limits) default_root_pose = self.cartpole.data.default_root_pose.torch[env_ids].clone() default_root_pose[:, :3] += self.scene.env_origins[env_ids] @@ -148,11 +167,13 @@ def compute_rewards( pole_vel: torch.Tensor, cart_vel: torch.Tensor, reset_terminated: torch.Tensor, + step_dt: float, ): + pole_pos = wrap_to_pi(pole_pos) rew_alive = rew_scale_alive * (1.0 - reset_terminated.float()) rew_termination = rew_scale_terminated * reset_terminated.float() rew_pole_pos = rew_scale_pole_pos * torch.sum(torch.square(pole_pos).unsqueeze(dim=1), dim=-1) rew_cart_vel = rew_scale_cart_vel * torch.sum(torch.abs(cart_vel).unsqueeze(dim=1), dim=-1) rew_pole_vel = rew_scale_pole_vel * torch.sum(torch.abs(pole_vel).unsqueeze(dim=1), dim=-1) - total_reward = rew_alive + rew_termination + rew_pole_pos + rew_cart_vel + rew_pole_vel + total_reward = (rew_alive + rew_termination + rew_pole_pos + rew_cart_vel + rew_pole_vel) * step_dt return total_reward diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py index 11720f0c238..7637e42ab7d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py @@ -5,12 +5,14 @@ from __future__ import annotations +import math + from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_ovphysx.physics import OvPhysxCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.assets import ArticulationCfg -from isaaclab.envs import DirectRLEnvCfg +from isaaclab.envs import DirectRLEnvCfg, ViewerCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg from isaaclab.utils.configclass import configclass @@ -72,6 +74,9 @@ class CartpoleEnvCfg(DirectRLEnvCfg): observation_space = 4 state_space = 0 + # viewer + viewer: ViewerCfg = ViewerCfg(eye=(8.0, 0.0, 5.0)) + # simulation sim: SimulationCfg = SimulationCfg(dt=1 / 120, render_interval=decimation, physics=CartpolePhysicsCfg()) @@ -87,8 +92,10 @@ class CartpoleEnvCfg(DirectRLEnvCfg): # reset max_cart_pos = 3.0 # the cart is reset if it exceeds that position [m] - initial_pole_angle_range = [-0.25, 0.25] # the range in which the pole angle is sampled from on reset [rad] - + initial_cart_position_range = (-1.0, 1.0) # [m] + initial_cart_velocity_range = (-0.5, 0.5) # [m/s] + initial_pole_angle_range = (-0.25 * math.pi, 0.25 * math.pi) # [rad] + initial_pole_velocity_range = (-0.25 * math.pi, 0.25 * math.pi) # [rad/s] # reward scales rew_scale_alive = 1.0 rew_scale_terminated = -2.0 diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_camera_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_camera_env_cfg.py index bd06fd78030..5b8054abdfb 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_camera_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_camera_env_cfg.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause +import math + import isaaclab.sim as sim_utils from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm @@ -11,7 +13,7 @@ from isaaclab.utils.configclass import configclass import isaaclab_tasks.core.cartpole.mdp as mdp -from isaaclab_tasks.core.cartpole.cartpole_manager_env_cfg import CartpoleEnvCfg, CartpoleSceneCfg +from isaaclab_tasks.core.cartpole.cartpole_manager_env_cfg import CartpoleEnvCfg, CartpoleSceneCfg, ObservationsCfg from isaaclab_tasks.utils import PresetCfg from isaaclab_tasks.utils.presets import MultiBackendRendererCfg @@ -77,7 +79,7 @@ def image_observations_cfg(data_type: str): data_type: Camera data type to read from the tiled camera (e.g. ``"rgb"``, ``"depth"``). Returns: - An observations config whose ``policy`` group emits the permuted camera image. + An observations config with camera policy observations and privileged state critic observations. """ @configclass @@ -85,8 +87,8 @@ class ImageObservationsCfg: @configclass class PolicyCfg(ObsGroup): image = ObsTerm( - func=mdp.image, - params={"sensor_cfg": SceneEntityCfg("tiled_camera"), "data_type": data_type, "permute": True}, + func=mdp.CameraImageStack, + params={"sensor_cfg": SceneEntityCfg("tiled_camera"), "data_type": data_type}, ) def __post_init__(self): @@ -94,6 +96,7 @@ def __post_init__(self): self.concatenate_terms = True policy: ObsGroup = PolicyCfg() + critic: ObsGroup = ObservationsCfg.PolicyCfg() return ImageObservationsCfg() @@ -154,6 +157,12 @@ class CartpoleCameraEnvCfg(PresetCfg): class BaseCartpoleCameraEnvCfg(CartpoleEnvCfg): """Camera variant of :class:`CartpoleEnvCfg` -- only the fields that differ are overridden.""" + frame_stack: int = 2 + """Number of frames to stack along the channel dimension. + + Values less than two disable stacking. + """ + # scene: fewer, more-spaced envs so each camera renders cleanly scene: CartpoleCameraSceneCfg = CartpoleCameraSceneCfg(num_envs=512, env_spacing=20.0) @@ -161,9 +170,10 @@ def __post_init__(self): super().__post_init__() # remove ground as it obstructs the camera self.scene.ground = None + self.events.reset_pole_position.params["position_range"] = (-0.125 * math.pi, 0.125 * math.pi) # viewer settings - self.viewer.eye = (7.0, 0.0, 2.5) - self.viewer.lookat = (0.0, 0.0, 2.5) + self.viewer.eye = (20.0, 20.0, 20.0) + self.viewer.lookat = (0.0, 0.0, 0.0) rgb = BaseCartpoleCameraEnvCfg(observations=image_observations_cfg("rgb")) depth = BaseCartpoleCameraEnvCfg(observations=image_observations_cfg("depth")) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py index ad1658e0f8b..73f59e6885b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py @@ -6,6 +6,7 @@ import math from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg +from isaaclab_ovphysx.physics import OvPhysxCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.sim as sim_utils @@ -21,11 +22,6 @@ from isaaclab.utils.configclass import configclass import isaaclab_tasks.core.cartpole.mdp as mdp -from isaaclab_tasks.core.cartpole.constants import ( - CARTPOLE_DISTANT_LIGHT_COLOR, - CARTPOLE_DISTANT_LIGHT_INTENSITY, - CARTPOLE_DISTANT_LIGHT_ORIENTATION, -) from isaaclab_tasks.utils import PresetCfg from isaaclab_assets.robots.cartpole import CARTPOLE_CFG # isort:skip @@ -75,6 +71,7 @@ class CartpolePhysicsCfg(PresetCfg): debug_mode=False, use_cuda_graph=True, ) + ovphysx: OvPhysxCfg = OvPhysxCfg() ## @@ -96,10 +93,13 @@ class CartpoleSceneCfg(InteractiveSceneCfg): robot: ArticulationCfg = CARTPOLE_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") # lights + # rot quaternion for euler angles (roll, pitch, yaw) = (0, -45, -45) degrees distant_light = AssetBaseCfg( prim_path="/World/DistantLight", - init_state=AssetBaseCfg.InitialStateCfg(rot=CARTPOLE_DISTANT_LIGHT_ORIENTATION), - spawn=sim_utils.DistantLightCfg(color=CARTPOLE_DISTANT_LIGHT_COLOR, intensity=CARTPOLE_DISTANT_LIGHT_INTENSITY), + init_state=AssetBaseCfg.InitialStateCfg( + rot=(-0.14644663035869598, -0.3535534143447876, -0.3535534143447876, 0.8535533547401428) + ), + spawn=sim_utils.DistantLightCfg(color=(1.0, 1.0, 1.0), intensity=2000.0), ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/constants.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/constants.py deleted file mode 100644 index 3404127edf5..00000000000 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/constants.py +++ /dev/null @@ -1,20 +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 - -import math - -import torch - -from isaaclab.utils.math import quat_from_euler_xyz - -CARTPOLE_DISTANT_LIGHT_INTENSITY: float = 2000.0 -CARTPOLE_DISTANT_LIGHT_COLOR: tuple[float, float, float] = (1.0, 1.0, 1.0) -CARTPOLE_DISTANT_LIGHT_ORIENTATION: tuple[float, float, float, float] = tuple( - quat_from_euler_xyz( - torch.tensor([0.0]), - torch.tensor([math.radians(-45.0)]), - torch.tensor([math.radians(-45.0)]), - )[0].tolist() -) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/mdp/__init__.pyi index c5601c1fd94..7947b2623a8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/mdp/__init__.pyi +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/mdp/__init__.pyi @@ -4,9 +4,11 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "CameraImageStack", "joint_pos_target_l2", "survival_success_rate", ] -from .rewards import joint_pos_target_l2, survival_success_rate +from isaaclab_tasks.core.cartpole.mdp.observations import CameraImageStack +from isaaclab_tasks.core.cartpole.mdp.rewards import joint_pos_target_l2, survival_success_rate from isaaclab.envs.mdp import * diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/mdp/observations.py new file mode 100644 index 00000000000..cae91b79b5b --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/mdp/observations.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 + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import ManagerTermBase, ObservationTermCfg, SceneEntityCfg +from isaaclab.utils.buffers import CircularBuffer +from isaaclab.utils.images import is_rgb_like, normalize_camera_image + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + from isaaclab.sensors import Camera + + +class CameraImageStack(ManagerTermBase): + """Return normalized channel-first camera images with optional frame stacking.""" + + def __init__(self, cfg: ObservationTermCfg, env: ManagerBasedRLEnv): + super().__init__(cfg, env) + + frame_stack = max(1, env.cfg.frame_stack) + env.cfg.frame_stack = frame_stack + + self._stack = None + if frame_stack > 1: + self._stack = CircularBuffer( + max_len=frame_stack, + batch_size=env.num_envs, + device=env.device, + stack_dim=1, + ) + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + if self._stack is not None: + self._stack.reset(env_ids) + + def __call__(self, env: ManagerBasedRLEnv, sensor_cfg: SceneEntityCfg, data_type: str) -> torch.Tensor: + camera: Camera = env.scene.sensors[sensor_cfg.name] + camera_data = camera.data.output[data_type] + + rgb_like = is_rgb_like(data_type) + defer_normalize = self._stack is not None and rgb_like + if data_type == "albedo": + camera_data = camera_data[..., :3] + if rgb_like and not defer_normalize: + camera_data = normalize_camera_image(camera_data, data_type) + elif data_type == "depth": + camera_data[camera_data == float("inf")] = 0 + + observation = camera_data.permute(0, 3, 1, 2).contiguous() + if self._stack is not None: + self._stack.append(observation) + observation = self._stack.stacked + + if defer_normalize: + observation = normalize_camera_image(observation, data_type, channel_dim=1) + elif self._stack is not None: + observation = observation.clone() + return observation diff --git a/source/isaaclab_tasks/test/core/test_cartpole_camera_presets_frame_stack.py b/source/isaaclab_tasks/test/core/test_cartpole_camera_presets_frame_stack.py deleted file mode 100644 index 171a31feb9a..00000000000 --- a/source/isaaclab_tasks/test/core/test_cartpole_camera_presets_frame_stack.py +++ /dev/null @@ -1,269 +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 - -"""Integration tests for the cartpole-camera-presets frame-stacking policy. - -Cfg-level: for each ``(physics, renderer)`` preset combo, the resolved cfg combined with -:meth:`CartpoleCameraEnv._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 ``CartpoleCameraEnv`` 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.core.cartpole.cartpole_direct_camera_env import CartpoleCameraEnv # noqa: E402 -from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env_cfg import ( - CartpoleCameraEnvCfg, # noqa: E402 -) -from isaaclab_tasks.utils.hydra import resolve_presets # noqa: E402 - -pytestmark = pytest.mark.isaacsim_ci - - -def _resolve(*presets: str): - """Build a fresh CartpoleCameraEnvCfg and resolve with the given preset names. - - Returns the resolved root cfg (a ``BaseCartpoleCameraEnvCfg`` instance). - """ - outer = CartpoleCameraEnvCfg() - return resolve_presets(outer, selected=set(presets)) - - -def _policy_default(cfg) -> int: - """Run the task's policy helper on a resolved cfg.""" - return CartpoleCameraEnv._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 + RTX + default AOV (rgb) — rgb gets DLSS temporal info, so resolves to 1 (non-rgb AOVs → 2).""" - cfg = _resolve("newton_mjwarp") - assert isinstance(cfg.sim.physics, NewtonCfg) - assert isinstance(cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) - assert cfg.tiled_camera.data_types == ["rgb"] - 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 - - # Under Newton + RTX the default splits by data type: rgb gets a DLSS temporal cue - # (stack=1); depth/albedo/simple_shading do not (stack=2). - - def test_newton_rtx_depth_stacks(self): - """Newton + RTX + depth — depth bypasses DLSS, so it needs explicit stacking.""" - cfg = _resolve("newton_mjwarp", "depth") - assert isinstance(cfg.sim.physics, NewtonCfg) - assert isinstance(cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) - assert cfg.tiled_camera.data_types == ["depth"] - assert _policy_default(cfg) == 2 - - def test_newton_rtx_albedo_stacks(self): - """Newton + RTX + albedo — albedo bypasses DLSS, so it needs explicit stacking.""" - cfg = _resolve("newton_mjwarp", "albedo") - assert isinstance(cfg.sim.physics, NewtonCfg) - assert isinstance(cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) - assert cfg.tiled_camera.data_types == ["albedo"] - assert _policy_default(cfg) == 2 - - def test_newton_rtx_simple_shading_stacks(self): - """Newton + RTX + simple_shading — bypasses DLSS, so it needs explicit stacking.""" - cfg = _resolve("newton_mjwarp", "simple_shading_diffuse_mdl") - assert isinstance(cfg.sim.physics, NewtonCfg) - assert isinstance(cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) - assert cfg.tiled_camera.data_types == ["simple_shading_diffuse_mdl"] - assert _policy_default(cfg) == 2 - - def test_newton_rtx_rgb_does_not_stack(self): - """Newton + RTX + rgb — DLSS supplies the temporal cue, so rgb stays single-frame - even though the other RTX AOVs stack.""" - cfg = _resolve("newton_mjwarp", "rgb") - assert isinstance(cfg.sim.physics, NewtonCfg) - assert isinstance(cfg.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) - assert cfg.tiled_camera.data_types == ["rgb"] - assert _policy_default(cfg) == 1 - - def test_physx_rtx_depth_does_not_stack(self): - """PhysX + RTX + depth — implicit damping means even a non-temporal AOV stays at 1.""" - cfg = _resolve("physx", "depth") - assert isinstance(cfg.sim.physics, PhysxCfg) - assert cfg.tiled_camera.data_types == ["depth"] - assert _policy_default(cfg) == 1 - - -class TestObsSpaceBumpArithmetic: - """The env class bumps ``observation_space[0] *= frame_stack`` (channel-first) 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[0] == expected_single_channels - - -# --------------------------------------------------------------------------- -# End-to-end: construct the real env and verify the obs pipeline -# --------------------------------------------------------------------------- - - -class TestEnvConstructionEndToEnd: - """Construct ``CartpoleCameraEnv`` 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), - # Newton + RTX + depth (non-rgb AOV): policy resolves to 2 → buffer active → 1ch × 2 = 2 channels. - (frozenset({"newton_mjwarp", "depth"}), -1, 2, 2), - # 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 = CartpoleCameraEnvCfg() - 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 = CartpoleCameraEnv(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, expected_channels, env_cfg.tiled_camera.height, env_cfg.tiled_camera.width) - 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 = CartpoleCameraEnvCfg() - env_cfg = resolve_presets(outer, selected={"newton_mjwarp", "newton_renderer"}) - env_cfg.scene.num_envs = 2 - - env = None - try: - env = CartpoleCameraEnv(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[0] // env.cfg.frame_stack - obs, _ = env.reset() - # channel-first [B, C, H, W]: newest frame occupies the last ``c`` channels - 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() diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index c2bcef398a1..a4424f645b4 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -795,12 +795,26 @@ class _CartpoleCameraTestEnvCfg(CartpoleCameraEnvCfg): try: env = CartpoleCameraEnv(env_cfg) + camera_outputs = env._tiled_camera.data.output + if renderer == "ovrtx_renderer": + # The first output access creates the selected OVRTX render-variable mapping. Give + # it a few frames to compile and populate instead of validating its zeroed buffer. + for _ in range(10): + has_valid_outputs = all( + torch.count_nonzero(output if isinstance(output, torch.Tensor) else output.torch).item() > 0 + for output in camera_outputs.values() + ) + if has_valid_outputs: + break + env.sim.render() + env.scene.update(dt=env.physics_dt) + camera_outputs = env._tiled_camera.data.output maybe_save_stage("cartpole", physics_backend, renderer, data_type) validate_camera_outputs( "cartpole", physics_backend, renderer, - env._tiled_camera.data.output, + camera_outputs, max_different_pixels_percentage=MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME["cartpole"], comparison_scores=comparison_scores, ) diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index 3fecbfdc84b..8f1bcb6a426 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -97,8 +97,8 @@ pyproject.optional-dependencies.all = [ # RL libraries { "sb3" = ["stable-baselines3>=2.6", "tqdm", "rich"] }, { "skrl" = ["skrl>=2.1.0"] }, - { "rsl-rl" = ["rsl-rl-lib==5.0.1", "onnxscript>=0.5"] }, - { "rsl_rl" = ["rsl-rl-lib==5.0.1", "onnxscript>=0.5"] }, + { "rsl-rl" = ["rsl-rl-lib==5.4.1", "onnxscript>=0.5"] }, + { "rsl_rl" = ["rsl-rl-lib==5.4.1", "onnxscript>=0.5"] }, # ================================================================================ # https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab_visualizers/pyproject.toml # ================================================================================ @@ -112,7 +112,7 @@ pyproject.optional-dependencies.all = [ "tqdm", "rich", "skrl>=2.1.0", - "rsl-rl-lib==5.0.1", + "rsl-rl-lib==5.4.1", "onnxscript>=0.5", ] }, ] From 6717d014ff2a3ab399d0727a01b46c8224ee8f26 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Thu, 2 Jul 2026 15:56:36 +0200 Subject: [PATCH 066/139] Migrate OVPhysX RigidObjectCollection onto OvPhysxView (view series, part 4) (#6227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ### 🔗 Stacked on top of #6224 (Part 1 — `OvPhysxView`) > This PR depends on **#6224** and is branched off it (a *sibling* of the Articulation #6225 > and RigidObject #6226 migrations, not stacked on them). It targets `develop`, but because > #6224 has not merged yet, the diff below includes the Part 1 `OvPhysxView` commits **as well > as** the Part 4 migration. **Please review #6224 first.** Once #6224 merges this branch will > be rebased onto `develop` and the diff will shrink to the collection migration only. # Description Part 4 of the OVPhysX view-migration series. It migrates the OVPhysX **`RigidObjectCollection`** onto `OvPhysxView` (introduced in **#6224**), following Parts 2 (#6225) and 3 (#6226). This is the part that exercises the view's **fused multi-prim path** — `prim_paths=[...]` plus `key_aliases` — which the earlier parts did not touch, so it dogfoods that slice of the API. ### What changed * `RigidObjectCollection._initialize_impl` builds a single `OvPhysxView(prim_paths=[g0, …], key_aliases={LINK_POSE: RIGID_BODY_POSE, …})`. The view creates each fused binding from its underlying `RIGID_BODY_*` type but stores it under the collection's `LINK_*`/`BODY_*` data-class key via `key_aliases` (every alias stays within its CPU/GPU residency class, which the view requires). Eager creation is fail-loud. * `RigidObjectCollectionData` is built from the view; both `_get_binding` helpers delegate to `OvPhysxView.try_binding_for`, and the dead `_binding_getter` callback is removed. * **Reads** route through `root_view.read_into(...)` on the native fused path (cached reinterpret); the articulation-mode mock read path (iface tests) keeps its raw `binding.read` because its buffer device intentionally bypasses the view's strict device check. * **Writes** route through `root_view.set_attribute(...)` — the wrench plus the body-major reshape write helper, including CPU-only mass/COM/inertia (pre-staged to pinned CPU). * `RigidObjectCollection.root_view` now returns the `OvPhysxView` instead of a raw `dict[TensorType, TensorBinding]` (**breaking**). ### Breaking change / migration `RigidObjectCollection.root_view` returns an `OvPhysxView`, not a dict: * `root_view[tensor_type]` → `root_view.try_binding_for(tensor_type)` * full-tensor read → `root_view.get_attribute(tensor_type)` ## Type of change - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] This change requires a documentation update ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have added tests that prove my fix is effective or that my feature works (collection tests exercise the migrated read/write paths) - [x] I have updated the changelog (`source/isaaclab_ovphysx/changelog.d/`) - [ ] I have updated the documentation accordingly - [x] I have added my changes to the right `changelog.d` fragment(s) ### Testing Run kitless; cpu and cuda must be separate invocations (process-global device lock): ``` 72 passed, 4 xfailed # test_rigid_object_collection.py -k cpu 72 passed, 4 xfailed # test_rigid_object_collection.py -k cuda ``` --------- Co-authored-by: Kelly Guo --- ...physx_rigidobjectcollection_view.major.rst | 9 +++ .../rigid_object_collection.py | 65 +++++++++++-------- .../rigid_object_collection_data.py | 31 ++++----- .../assets/test_rigid_object_collection.py | 5 +- 4 files changed, 62 insertions(+), 48 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst new file mode 100644 index 00000000000..9f467944463 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst @@ -0,0 +1,9 @@ +Changed +^^^^^^^ + +* **Breaking:** :attr:`~isaaclab_ovphysx.assets.RigidObjectCollection.root_view` now returns an + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw ``dict`` + mapping ``TensorType`` to ``TensorBinding``. The view wraps the fused multi-prim bindings + (``prim_paths=[...]``) and stores each under the collection's ``LINK_*``/``BODY_*`` data-class + key via ``key_aliases`` (mapped from the underlying ``RIGID_BODY_*`` type). Replace + ``root_view[tensor_type]`` with ``root_view.try_binding_for(tensor_type)``. 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 index 87876bc43eb..731b2a0a280 100644 --- 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 @@ -8,7 +8,7 @@ import re import warnings from collections.abc import Sequence -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import numpy as np import torch @@ -26,6 +26,7 @@ from isaaclab_ovphysx.assets.kernels import _body_wrench_to_world, resolve_view_ids from isaaclab_ovphysx.cloner import queue_ovphysx_replication from isaaclab_ovphysx.physics import OvPhysxManager +from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView from .rigid_object_collection_data import RigidObjectCollectionData @@ -91,8 +92,8 @@ def __init__(self, cfg: RigidObjectCollectionCfg): queue_ovphysx_replication(cfg.rigid_objects[rigid_body_name]) # stores object names self._body_names_list: list[str] = [] - # one fused TensorBinding per tensor type, populated in _initialize_impl - self._bindings: dict[int, Any] = {} + # binding manager over the fused multi-prim bindings; created in _initialize_impl + self._root_view: OvPhysxView | None = None # register various callback functions self._register_callbacks() @@ -121,16 +122,19 @@ def body_names(self) -> list[str]: return list(self._body_names_list) @property - def root_view(self): + def root_view(self) -> OvPhysxView: """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. + On OVPhysX this is an :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` over a single + **fused** multi-prim binding per tensor type (created with ``prim_paths=[...]``), + spanning all bodies in the collection. The collection's ``LINK_*``/``BODY_*`` + data-class keys are mapped to the underlying ``RIGID_BODY_*`` types via the view's + ``key_aliases``. .. note:: Use this view with caution. It requires handling of tensors in a specific way. """ - return self._bindings + return self._root_view @property def instantaneous_wrench_composer(self) -> WrenchComposer: @@ -211,10 +215,10 @@ def write_data_to_sim(self) -> None: # ``(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) + self._root_view.set_attribute(TT.LINK_WRENCH, self._wrench_buf) else: view = self.reshape_data_to_view_3d(self._wrench_buf, 9, device=self._device) - binding.write(view) + self._root_view.set_attribute(TT.LINK_WRENCH, view) inst.reset() def update(self, dt: float) -> None: @@ -1094,29 +1098,33 @@ def has_rigid_body_api(prim) -> bool: # ``(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), + # The view creates each fused binding from the underlying RIGID_BODY_* type but stores + # it under the collection's LINK_*/BODY_* data-class key via key_aliases (every alias + # stays within its CPU/GPU residency class, which the view requires). + key_aliases = { + 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, + } + self._root_view = OvPhysxView( + self._ovphysx, prim_paths=self._prim_paths, device=self._device, key_aliases=key_aliases ) - for store_key, rb_tt in _TT_MAP: + for store_key in key_aliases: try: - self._bindings[store_key] = self._ovphysx.create_tensor_binding( - prim_paths=self._prim_paths, tensor_type=rb_tt - ) + self._root_view.binding_for(store_key) except Exception as e: raise RuntimeError( - f"OVPhysX could not create fused RIGID_BODY binding {rb_tt!r} for" + f"OVPhysX could not create the fused RIGID_BODY binding for {store_key!r} with" 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 + pose_count = self._root_view.binding_for(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" @@ -1125,7 +1133,7 @@ def has_rigid_body_api(prim) -> bool: self._num_instances = pose_count // self._num_bodies self._data = RigidObjectCollectionData( - root_view=self._bindings, + root_view=self._root_view, num_bodies=self._num_bodies, device=self._device, ) @@ -1143,7 +1151,7 @@ def _create_buffers(self) -> None: 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(). + # CPU copy of all-env indices used when writing CPU-only attributes via set_attribute. 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) @@ -1194,6 +1202,9 @@ def _invalidate_initialize_callback(self, event) -> None: """Invalidates the scene elements.""" # call parent super()._invalidate_initialize_callback(event) + # Drop the fused view (and the bindings it caches) on stop so a destroyed/stale binding + # is not held across the reset; ``_initialize_impl`` rebuilds a fresh view on the next play. + self._root_view = None """ Helper functions. @@ -1213,7 +1224,7 @@ def _get_binding(self, tensor_type: int): Returns: The cached :class:`TensorBinding`, or ``None`` if not found. """ - return self._bindings.get(tensor_type) + return self._root_view.try_binding_for(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. @@ -1326,7 +1337,7 @@ def _binding_write( 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) + self._root_view.set_attribute(tensor_type, 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: @@ -1334,7 +1345,7 @@ def _binding_write( 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) + self._root_view.set_attribute(tensor_type, view, indices=view_ids) """ Internal helper. 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 index d5976bff063..f5657f7970f 100644 --- 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 @@ -6,7 +6,6 @@ from __future__ import annotations import warnings -from typing import Any import numpy as np import torch @@ -21,6 +20,7 @@ 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 +from isaaclab_ovphysx.sim.views.ovphysx_view import OvPhysxView class RigidObjectCollectionData(BaseRigidObjectCollectionData): @@ -59,22 +59,22 @@ class RigidObjectCollectionData(BaseRigidObjectCollectionData): def __init__( self, - root_view: dict[int, Any], + root_view: OvPhysxView, 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. + root_view: The :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` over the collection's + fused multi-prim bindings (one per ``LINK_*``/``BODY_*`` data-class key, created + from the underlying ``RIGID_BODY_*`` type via ``key_aliases``). 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 + # The view owns the fused bindings and the CPU/GPU device policy. + self._view = root_view self.num_bodies = num_bodies self._num_bodies = num_bodies # Set initial time stamp @@ -89,7 +89,7 @@ def __init__( # 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] + pose_binding = self._view.binding_for(TT.LINK_POSE) if len(pose_binding.shape) >= 2 and pose_binding.shape[1] == num_bodies: self.num_instances = pose_binding.count else: @@ -943,15 +943,7 @@ def _get_binding(self, tensor_type: int): 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 + return self._view.try_binding_for(tensor_type) def _read_view_scratch(self, tensor_type: int, binding) -> wp.array: """Return a cached body-major scratch float32 buffer matching ``binding.shape``. @@ -1044,9 +1036,10 @@ def _read_binding_into_instance_major(self, tensor_type: int, buf: TimestampedBu buf.timestamp = self._sim_timestamp return - # Native fused path: read body-major scratch then strided-view reshape. + # Native fused path: read the body-major scratch through the view (cached reinterpret, + # scratch is on the binding's native device), then strided-view reshape. scratch = self._read_view_scratch(tensor_type, binding) - binding.read(scratch) + self._view.read_into(tensor_type, scratch) if floats_per_elem <= 1: reshaped = self._reshape_view_to_data_2d(scratch) else: diff --git a/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py b/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py index 4dae9628b6b..75353cf8e8a 100644 --- a/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py +++ b/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py @@ -106,8 +106,9 @@ def _ovphysx_sim_context(device: str, **kwargs): _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() " + "wheel side. RigidObjectCollection.root_view is an OvPhysxView over fused " + "per-tensor-type bindings 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." ) From a13d8d6c61c5ed19a7d9065928eccaf28f57c113 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Thu, 2 Jul 2026 16:55:26 +0200 Subject: [PATCH 067/139] Fetch OVPhysX wheelhouse CI through NGC + 0.5.1 bump (#6314) Trusted same-repository CI probe for the OVPhysX wheelhouse path. This draft validates an authenticated download of the pinned NGC resource, manifest and wheel hash checks, an offline OVPhysX install, and both `isaaclab_ov` and OVPhysX-backed kitless rendering tests against `develop`. Do not merge; this PR exists only for CI validation. --------- Co-authored-by: Marco Alesiani Co-authored-by: marcodiiga <1969828+marcodiiga@users.noreply.github.com> --- .github/actions/run-package-tests/action.yml | 138 ++++++++++++++++++ .github/actions/run-tests/action.yml | 62 +++++++- .github/workflows/build.yaml | 25 +++- .github/workflows/config.yaml | 1 + .../ovphysx-current-runtime-compat.rst | 7 + .../assets/articulation/articulation_data.py | 2 + .../physics/ovphysx_manager.py | 78 ++++------ .../test/assets/test_articulation.py | 14 +- .../test/assets/test_rigid_object.py | 6 +- .../assets/test_rigid_object_collection.py | 6 +- .../test/sensors/test_contact_sensor.py | 6 +- .../test/sensors/test_frame_transformer.py | 6 +- .../test/sensors/test_joint_wrench_sensor.py | 6 +- 13 files changed, 284 insertions(+), 73 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst diff --git a/.github/actions/run-package-tests/action.yml b/.github/actions/run-package-tests/action.yml index 211707e48c1..a88ec78c904 100644 --- a/.github/actions/run-package-tests/action.yml +++ b/.github/actions/run-package-tests/action.yml @@ -77,6 +77,13 @@ inputs: description: 'Space-separated pip packages to install inside the Docker container before pytest starts' default: '' required: false + wheelhouse-resource: + description: 'Optional NGC resource containing wheelhouse/ and manifest.json for offline pip installs' + default: '' + required: false + wheelhouse-packages: + description: 'Space-separated packages to install from the wheelhouse with pip --no-index' + default: '' omni-github-test-type: description: 'Test type stored on each uploaded omni-github test row' default: 'pytest' @@ -145,6 +152,130 @@ runs: printf "🔵 Image pull took %dm %ds\n" $((elapsed/60)) $((elapsed%60)) echo "🔵 Docker Image Pulled in ${elapsed}s" >> "$GITHUB_STEP_SUMMARY" + - name: Restore NGC CLI cache + if: inputs.wheelhouse-resource != '' && env.NGC_API_KEY != '' + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/ngc-cli-cache/ngccli_linux.zip + key: ngc-cli-${{ runner.os }}-${{ runner.arch }}-4.20.0-5cf084c88998c58ad8abf7849d2d1b41d578423886eb03018df10194e341d35b + + - name: Extract ovphysx wheelhouse + id: extract-wheelhouse + if: inputs.wheelhouse-resource != '' + shell: bash + env: + WHEELHOUSE_RESOURCE: ${{ inputs.wheelhouse-resource }} + run: | + set -euo pipefail + + if [ -z "${NGC_API_KEY:-}" ]; then + echo "::error::wheelhouse-resource is set but NGC_API_KEY is unavailable; cannot download configured wheelhouse resource" + exit 1 + fi + + NGC_CLI_VERSION="4.20.0" + NGC_CLI_SHA256="5cf084c88998c58ad8abf7849d2d1b41d578423886eb03018df10194e341d35b" + NGC_CLI_URL="https://api.ngc.nvidia.com/v2/resources/nvidia/ngc-apps/ngc_cli/versions/${NGC_CLI_VERSION}/files/ngccli_linux.zip" + + wheelhouse_root="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ovphysx-wheelhouse.XXXXXX")" + download_root="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ovphysx-wheelhouse-download.XXXXXX")" + ngc_home="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ovphysx-ngc-home.XXXXXX")" + ngc_unpack_dir="" + preserve_wheelhouse_root=false + cleanup() { + if [ "$preserve_wheelhouse_root" != "true" ]; then + rm -rf "$wheelhouse_root" + fi + rm -rf "$download_root" "$ngc_home" + if [ -n "$ngc_unpack_dir" ]; then + rm -rf "$ngc_unpack_dir" + fi + } + trap cleanup EXIT + + if ! command -v ngc >/dev/null 2>&1; then + cache_dir="${RUNNER_TEMP:-/tmp}/ngc-cli-cache" + ngc_zip="${cache_dir}/ngccli_linux.zip" + mkdir -p "$cache_dir" + if [ ! -f "$ngc_zip" ]; then + echo "Downloading NGC CLI ${NGC_CLI_VERSION}" + curl -fsSL -o "$ngc_zip" "$NGC_CLI_URL" + fi + echo "${NGC_CLI_SHA256} ${ngc_zip}" | sha256sum -c - + ngc_unpack_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ngc-cli.XXXXXX")" + unzip -q "$ngc_zip" -d "$ngc_unpack_dir" + export PATH="${ngc_unpack_dir}/ngc-cli:${PATH}" + fi + + export NGC_CLI_API_KEY="$NGC_API_KEY" + export NGC_CLI_ORG=nvidian + export NGC_CLI_TEAM=no-team + export NGC_CLI_HOME="$ngc_home" + + ngc --version + echo "Downloading wheelhouse resource: $WHEELHOUSE_RESOURCE" + ngc registry resource download-version "$WHEELHOUSE_RESOURCE" --dest "$download_root" + + mapfile -t manifests < <(find "$download_root" -type f -name manifest.json) + if [ "${#manifests[@]}" -ne 1 ]; then + echo "::error::expected exactly one manifest.json in downloaded wheelhouse resource, found ${#manifests[@]}" + printf '%s\n' "${manifests[@]}" + exit 1 + fi + + payload_dir="$(dirname "${manifests[0]}")" + if [ ! -d "${payload_dir}/wheelhouse" ]; then + echo "::error::downloaded wheelhouse resource is missing wheelhouse/ next to manifest.json" + exit 1 + fi + + mkdir -p "$wheelhouse_root/wheelhouse" + cp "${payload_dir}/manifest.json" "$wheelhouse_root/manifest.json" + cp "${payload_dir}/wheelhouse/"*.whl "$wheelhouse_root/wheelhouse/" + + python3 - <<'PY' "$wheelhouse_root/manifest.json" "$wheelhouse_root/wheelhouse" + import hashlib + import json + import pathlib + import sys + + manifest_path = pathlib.Path(sys.argv[1]) + wheelhouse_dir = pathlib.Path(sys.argv[2]) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + expected = { + "artifact": "ovphysx-wheelhouse", + "platform": "manylinux_2_35_x86_64", + } + for key, value in expected.items(): + actual = manifest.get(key) + if actual != value: + raise SystemExit(f"manifest {key!r} mismatch: expected {value!r}, got {actual!r}") + + wheels = manifest.get("wheels") + if not isinstance(wheels, list) or not wheels: + raise SystemExit("manifest has no wheels list") + + for wheel in wheels: + filename = wheel.get("file") + expected_sha = wheel.get("sha256") + if not filename or not expected_sha: + raise SystemExit(f"invalid wheel manifest entry: {wheel!r}") + wheel_path = wheelhouse_dir / filename + if not wheel_path.is_file(): + raise SystemExit(f"manifest wheel is missing from wheelhouse: {filename}") + actual_sha = hashlib.sha256(wheel_path.read_bytes()).hexdigest() + if actual_sha != expected_sha: + raise SystemExit(f"sha256 mismatch for {filename}: expected {expected_sha}, got {actual_sha}") + + print(f"Validated {len(wheels)} wheelhouse wheels from {manifest_path}") + PY + + echo "Wheelhouse contents:" + ls -l "$wheelhouse_root/wheelhouse" + preserve_wheelhouse_root=true + echo "wheelhouse_host_dir=$wheelhouse_root" >> "$GITHUB_OUTPUT" + - name: Run Tests uses: ./.github/actions/run-tests with: @@ -164,6 +295,8 @@ runs: test-node-ids-key: ${{ inputs.test-node-ids-key }} volume-mount-source: ${{ github.workspace }} extra-pip-packages: ${{ inputs.extra-pip-packages }} + wheelhouse-host-dir: ${{ steps.extract-wheelhouse.outputs.wheelhouse_host_dir }} + wheelhouse-packages: ${{ inputs.wheelhouse-packages }} omni-github-test-type: ${{ inputs.omni-github-test-type }} - name: Check Test Results @@ -180,6 +313,11 @@ runs: exit 1 fi + - name: Cleanup wheelhouse artifacts + if: always() && steps.extract-wheelhouse.outputs.wheelhouse_host_dir != '' + shell: bash + run: rm -rf "${{ steps.extract-wheelhouse.outputs.wheelhouse_host_dir }}" + - name: Kill container on cancellation if: cancelled() shell: bash diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index 9a335aeb9ee..c4a6859b639 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -77,6 +77,13 @@ inputs: description: 'Space-separated pip packages to install inside the Docker container before pytest starts' default: '' required: false + wheelhouse-host-dir: + description: 'Host directory containing wheelhouse/ and manifest.json for offline pip installs' + default: '' + required: false + wheelhouse-packages: + description: 'Space-separated packages to install from /tmp/ovphysx-wheelhouse with pip --no-index' + default: '' omni-github-test-type: description: 'Test type stored on each uploaded omni-github test row' default: 'pytest' @@ -107,6 +114,8 @@ runs: local extra_pip_packages="${15}" local test_node_ids_file="${16}" local test_node_ids_key="${17}" + local wheelhouse_host_dir="${18}" + local wheelhouse_packages="${19}" local logs_pid="" local wait_pid="" local docker_wait_file="/tmp/.docker_exit_${container_name}" @@ -132,6 +141,12 @@ runs: if [ -n "$extra_pip_packages" ]; then echo "With extra pip packages: $extra_pip_packages" fi + if [ -n "$wheelhouse_host_dir" ]; then + echo "With wheelhouse host directory: $wheelhouse_host_dir" + fi + if [ -n "$wheelhouse_packages" ]; then + echo "With wheelhouse packages: $wheelhouse_packages" + fi if [ -n "$filter_pattern" ]; then echo "With filter pattern: $filter_pattern" fi @@ -291,6 +306,31 @@ runs: echo "🔵 Running volume-mounted container as host uid:gid ${host_uid}:${host_gid} (${host_user})" fi + if [ -n "$wheelhouse_host_dir" ]; then + if [ -z "$wheelhouse_packages" ]; then + echo "::error::wheelhouse-host-dir was provided but wheelhouse-packages is empty" + return 1 + fi + if [ ! -d "${wheelhouse_host_dir}/wheelhouse" ]; then + echo "::error::wheelhouse directory not found: ${wheelhouse_host_dir}/wheelhouse" + return 1 + fi + if [ ! -f "${wheelhouse_host_dir}/manifest.json" ]; then + echo "::error::wheelhouse manifest not found: ${wheelhouse_host_dir}/manifest.json" + return 1 + fi + + export TEST_WHEELHOUSE_PACKAGES="$wheelhouse_packages" + docker_volume_args="$docker_volume_args \ + -v ${wheelhouse_host_dir}/wheelhouse:/tmp/ovphysx-wheelhouse:ro \ + -v ${wheelhouse_host_dir}/manifest.json:/tmp/ovphysx-wheelhouse-manifest.json:ro" + docker_env_vars="$docker_env_vars \ + -e TEST_WHEELHOUSE_PATH=/tmp/ovphysx-wheelhouse \ + -e TEST_WHEELHOUSE_MANIFEST=/tmp/ovphysx-wheelhouse-manifest.json \ + -e TEST_WHEELHOUSE_PACKAGES" + echo "Mounting wheelhouse at /tmp/ovphysx-wheelhouse" + fi + echo "Docker environment variables: '$docker_env_vars'" # Run tests in a detached container and follow logs. Running detached @@ -323,6 +363,26 @@ runs: # fall back to slow repeated retries. unset HUB__ARGS__DETECT_ONLY ./isaaclab.sh -p -m pip install pytest pytest-mock junitparser flatdict flaky \"coverage>=7.6.1\" + if [ -n \"\${TEST_WHEELHOUSE_PACKAGES:-}\" ]; then + if [ ! -d \"\${TEST_WHEELHOUSE_PATH:-}\" ]; then + echo \"Wheelhouse path is missing: \${TEST_WHEELHOUSE_PATH:-}\" + exit 1 + fi + if [ ! -f \"\${TEST_WHEELHOUSE_MANIFEST:-}\" ]; then + echo \"Wheelhouse manifest is missing: \${TEST_WHEELHOUSE_MANIFEST:-}\" + exit 1 + fi + + echo \"Installing wheelhouse packages offline: \${TEST_WHEELHOUSE_PACKAGES}\" + ./isaaclab.sh -p -m pip uninstall -y \${TEST_WHEELHOUSE_PACKAGES} || true + PIP_NO_INDEX=1 ./isaaclab.sh -p -m pip install --no-index --find-links=\"\${TEST_WHEELHOUSE_PATH}\" --upgrade --force-reinstall \${TEST_WHEELHOUSE_PACKAGES} + + case \" \${TEST_WHEELHOUSE_PACKAGES} \" in + *\" ovphysx \"*) + ./isaaclab.sh -p -c \"import importlib.metadata,json,os,pathlib; from packaging.version import Version; manifest=json.loads(pathlib.Path(os.environ['TEST_WHEELHOUSE_MANIFEST']).read_text(encoding='utf-8')); expected=manifest.get('ovphysx_version'); actual=importlib.metadata.version('ovphysx'); print(f'Resolved ovphysx package version: {actual}'); print(f'Wheelhouse manifest ovphysx version: {expected}'); import ovphysx; runtime=getattr(ovphysx, '__version__', actual); print(f'Imported ovphysx runtime version: {runtime}'); raise SystemExit(0 if Version(actual) == Version(expected) and Version(runtime) == Version(expected) else f'ovphysx version mismatch: installed {actual}, import {runtime}, manifest {expected}')\" + ;; + esac + fi 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} @@ -431,7 +491,7 @@ runs: } # Call the function with provided parameters - run_tests "${{ inputs.test-path }}" "${{ inputs.result-file }}" "${{ inputs.container-name }}" "${{ inputs.image-tag }}" "${{ inputs.reports-dir }}" "${{ inputs.pytest-options }}" "${{ inputs.filter-pattern }}" "${{ inputs.exclude-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" "${{ inputs.extra-pip-packages }}" "${{ inputs.test-node-ids-file }}" "${{ inputs.test-node-ids-key }}" + run_tests "${{ inputs.test-path }}" "${{ inputs.result-file }}" "${{ inputs.container-name }}" "${{ inputs.image-tag }}" "${{ inputs.reports-dir }}" "${{ inputs.pytest-options }}" "${{ inputs.filter-pattern }}" "${{ inputs.exclude-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" "${{ inputs.extra-pip-packages }}" "${{ inputs.test-node-ids-file }}" "${{ inputs.test-node-ids-key }}" "${{ inputs.wheelhouse-host-dir }}" "${{ inputs.wheelhouse-packages }}" - name: Kill container on cancellation if: cancelled() diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9bf03560cdd..1bef75f6b01 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -191,6 +191,7 @@ jobs: isaacsim_image_name: ${{ steps.load.outputs.isaacsim_image_name }} isaacsim_image_tag: ${{ steps.load.outputs.isaacsim_image_tag }} isaaclab_image_name: ${{ steps.load.outputs.isaaclab_image_name }} + ovphysx_wheelhouse_resource: ${{ steps.load.outputs.ovphysx_wheelhouse_resource }} ci_image_tag: ${{ steps.image_tag.outputs.ci_image_tag }} steps: - uses: actions/checkout@v6 @@ -205,6 +206,7 @@ jobs: echo "isaacsim_image_name=$(yq -r .isaacsim_image_name "$f")" >> "$GITHUB_OUTPUT" echo "isaacsim_image_tag=$(yq -r .isaacsim_image_tag "$f")" >> "$GITHUB_OUTPUT" echo "isaaclab_image_name=$(yq -r .isaaclab_image_name "$f")" >> "$GITHUB_OUTPUT" + echo "ovphysx_wheelhouse_resource=$(yq -r .ovphysx_wheelhouse_resource "$f")" >> "$GITHUB_OUTPUT" - id: image_tag shell: bash env: @@ -594,7 +596,12 @@ jobs: needs: [build, config] if: >- github.event_name != 'push' && - needs.build.result == 'success' + needs.build.result == 'success' && + (needs.config.outputs.ovphysx_wheelhouse_resource == '' || + (github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name != 'pull_request' && github.ref_name == 'develop')) + env: + USE_OVPHYSX_WHEELHOUSE: ${{ needs.config.outputs.ovphysx_wheelhouse_resource != '' && ((github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name != 'pull_request' && github.ref_name == 'develop')) }} steps: - uses: actions/checkout@v6 with: @@ -606,7 +613,9 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_ov" - extra-pip-packages: "ovrtx ovphysx" + extra-pip-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovrtx' || 'ovrtx ovphysx' }} + wheelhouse-resource: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && needs.config.outputs.ovphysx_wheelhouse_resource || '' }} + wheelhouse-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovphysx' || '' }} container-name: isaac-lab-ov-test # Folded from the former standalone verify-base-non-root job: reuses the @@ -768,7 +777,13 @@ jobs: timeout-minutes: 120 continue-on-error: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} needs: [build, config] - if: needs.build.result == 'success' + if: >- + needs.build.result == 'success' && + (needs.config.outputs.ovphysx_wheelhouse_resource == '' || + (github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name != 'pull_request' && github.ref_name == 'develop')) + env: + USE_OVPHYSX_WHEELHOUSE: ${{ needs.config.outputs.ovphysx_wheelhouse_resource != '' && ((github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name != 'pull_request' && github.ref_name == 'develop')) }} steps: - uses: actions/checkout@v6 with: @@ -780,7 +795,9 @@ 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 ovphysx==0.4.13" + extra-pip-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovrtx' || 'ovrtx ovphysx' }} + wheelhouse-resource: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && needs.config.outputs.ovphysx_wheelhouse_resource || '' }} + wheelhouse-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovphysx' || '' }} include-files: >- test_rendering_cartpole_kitless.py, test_rendering_dexsuite_kuka_homo_kitless.py, diff --git a/.github/workflows/config.yaml b/.github/workflows/config.yaml index aa3c1888b24..c71456fccc7 100644 --- a/.github/workflows/config.yaml +++ b/.github/workflows/config.yaml @@ -8,3 +8,4 @@ isaacsim_image_name: nvcr.io/nvidian/isaac-sim isaacsim_image_tag: latest-develop isaaclab_image_name: nvcr.io/nvidian/isaac-lab +ovphysx_wheelhouse_resource: "nvidian/ovphysx-wheelhouse:trunk-gf62c22207cfd" diff --git a/source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst b/source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst new file mode 100644 index 00000000000..35fe74f5978 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Updated :class:`~isaaclab_ovphysx.physics.OvPhysxManager` for the current + OVPhysX constructor, stage-reset, and synchronous-step APIs. +* Synchronized GPU-to-host property staging before OVPhysX consumes the pinned + host buffers. 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 b3d485e26a9..815c72c4a9d 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py @@ -1648,6 +1648,7 @@ def _binding_write( copy=False, ) wp.copy(staging, src_view) + wp.synchronize_stream(src.device) binding.write(staging, indices=indices, mask=mask) def _stage_to_pinned_cpu(self, tensor_type: int, role: str, src: wp.array) -> wp.array: @@ -1681,6 +1682,7 @@ def _stage_to_pinned_cpu(self, tensor_type: int, role: str, src: wp.array) -> wp 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) + wp.synchronize_stream(src.device) return staging def _read_initial_properties(self) -> None: diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index f21eeb09436..458f38f9b54 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -13,7 +13,6 @@ from __future__ import annotations import atexit -import inspect import logging import os import re @@ -339,8 +338,7 @@ def step(cls) -> None: if cls._physx is None: return dt = cls.get_physics_dt() - sim_time = PhysicsManager._sim_time - cls._physx.step_sync(dt=dt, sim_time=sim_time) + cls._physx.step_sync(dt=dt) cls._physx.update_articulations_kinematic() PhysicsManager._sim_time += dt @@ -353,7 +351,7 @@ def close(cls) -> 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. + # point into the wheel's prior scene which we just cleared. # The next :class:`SimulationContext` re-creates the backend in # :meth:`initialize`. Matches Newton's lifecycle. cls._scene_data_backend = None @@ -368,9 +366,9 @@ def close(cls) -> None: def _release_physx(cls) -> None: """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 + Calls ``physx.reset_stage()`` 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 @@ -385,7 +383,7 @@ def _release_physx(cls) -> None: namespace-isolated Carbonite (different soname / hidden visibility). """ if cls._physx is not None: - op = cls._physx.reset() + op = cls._physx.reset_stage() cls._physx.wait_op(op) @classmethod @@ -537,8 +535,8 @@ def _warmup_and_load(cls) -> None: 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." + f"cannot switch to {ovphysx_device!r} while its cached ovphysx.PhysX instance is active. " + "Restart the process to use a different device." ) scene_prim = sim.stage.GetPrimAtPath(sim.cfg.physics_prim_path) @@ -573,12 +571,12 @@ def _warmup_and_load(cls) -> None: 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 + # wheel allows only one loaded USD at a time). ``physx.reset_stage()`` + # 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() + op = cls._physx.reset_stage() cls._physx.wait_op(op) usd_handle, op_idx = cls._physx.add_usd(stage_file) @@ -651,41 +649,29 @@ def _construct_physx(cls, ovphysx_device: str, gpu_index: int) -> None: _sys.modules.update(_hidden_pxr) ovphysx = import_ovphysx() - - 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 + ovphysx.PhysX.set_cpu_mode(ovphysx_device == "cpu") + + carbonite_overrides = { + "/physics/physxDispatcher": True, + "/physics/updateToUsd": False, + "/physics/updateVelocitiesToUsd": False, + "/physics/updateParticlesToUsd": False, + } + if ovphysx_device == "gpu": + carbonite_overrides.update( + { + "/physics/suppressReadback": True, + "/physics/suppressFabricUpdate": True, + } + ) + physx_kwargs = { + "config": ovphysx.PhysXConfig(num_threads=8, carbonite_overrides=carbonite_overrides), + } + if ovphysx_device == "gpu": + physx_kwargs["active_cuda_gpus"] = str(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. - # - # COMPAT(ovphysx<=0.3.7): The public 0.3.7 wheel exposes typed config - # setters (set_config_int32 etc.) rather than the Carbonite-settings-based - # set_setting() added in newer internal builds. This guard keeps both - # working. REVERT once the public wheel ships set_setting(). - if hasattr(cls._physx, "set_setting"): - cls._physx.set_setting("/persistent/physics/numThreads", "8") - cls._physx.set_setting("/physics/physxDispatcher", "true") - cls._physx.set_setting("/physics/updateToUsd", "false") - cls._physx.set_setting("/physics/updateVelocitiesToUsd", "false") - cls._physx.set_setting("/physics/updateParticlesToUsd", "false") - else: - cls._physx.set_config_int32(ovphysx.ConfigInt32.NUM_THREADS, 8) - # FIXME(malesiani): re-evaluate this when carbonite ships an isolated copy. # At process exit, two Carbonite instances are in memory: # 1. ovphysx's bundled libcarb.so (RPATH $ORIGIN/../plugins/) @@ -731,7 +717,7 @@ def _configure_physx_scene_prim(scene_prim, cfg, device: str) -> None: 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"``. + CPU broadphase even when OVPhysX is configured for GPU execution. Args: scene_prim: The /World/PhysicsScene prim to configure. diff --git a/source/isaaclab_ovphysx/test/assets/test_articulation.py b/source/isaaclab_ovphysx/test/assets/test_articulation.py index 39c22080f53..9dff7ae6bca 100644 --- a/source/isaaclab_ovphysx/test/assets/test_articulation.py +++ b/source/isaaclab_ovphysx/test/assets/test_articulation.py @@ -31,9 +31,9 @@ Process-global device lock -------------------------- -The OVPhysX runtime 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 +The OVPhysX runtime fixes device mode (CPU vs GPU) when the process creates +its first ``ovphysx.PhysX`` instance and cannot switch 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 @@ -127,10 +127,10 @@ def _read_binding_to_torch(articulation: Articulation, tensor_type: int, device: def _ovphysx_skip_other_device(request): """Skip tests whose ``device`` parameter mismatches the session-locked device. - The OVPhysX runtime 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 + The OVPhysX runtime locks process-global device mode when the process + creates its first ``ovphysx.PhysX`` instance, so any test parametrized to a + different device after the first ``sim.reset()`` would hit the manager's + :exc:`RuntimeError`. 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. """ diff --git a/source/isaaclab_ovphysx/test/assets/test_rigid_object.py b/source/isaaclab_ovphysx/test/assets/test_rigid_object.py index b3012dfb397..165f89ee191 100644 --- a/source/isaaclab_ovphysx/test/assets/test_rigid_object.py +++ b/source/isaaclab_ovphysx/test/assets/test_rigid_object.py @@ -11,9 +11,9 @@ Run via ``./scripts/run_ovphysx.sh -m pytest`` (kitless, no ``AppLauncher``). -The OVPhysX runtime 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 +The OVPhysX runtime fixes device mode (CPU vs GPU) when the process creates +its first ``ovphysx.PhysX`` instance and cannot switch 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 diff --git a/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py b/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py index 75353cf8e8a..0657b30ad33 100644 --- a/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py +++ b/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py @@ -11,9 +11,9 @@ Run via ``./scripts/run_ovphysx.sh -m pytest`` (kitless, no ``AppLauncher``). -The OVPhysX runtime 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 +The OVPhysX runtime fixes device mode (CPU vs GPU) when the process creates +its first ``ovphysx.PhysX`` instance and cannot switch 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 diff --git a/source/isaaclab_ovphysx/test/sensors/test_contact_sensor.py b/source/isaaclab_ovphysx/test/sensors/test_contact_sensor.py index eae32ddb82c..424e3d9971e 100644 --- a/source/isaaclab_ovphysx/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_ovphysx/test/sensors/test_contact_sensor.py @@ -12,9 +12,9 @@ through the standard Kit Python entrypoint, so the older kitless ``./scripts/run_ovphysx.sh`` wrapper is no longer required. -The OVPhysX runtime 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 +The OVPhysX runtime fixes device mode (CPU vs GPU) when the process creates +its first ``ovphysx.PhysX`` instance and cannot switch 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 diff --git a/source/isaaclab_ovphysx/test/sensors/test_frame_transformer.py b/source/isaaclab_ovphysx/test/sensors/test_frame_transformer.py index 0c668e8faaf..aa7aaf2dc5d 100644 --- a/source/isaaclab_ovphysx/test/sensors/test_frame_transformer.py +++ b/source/isaaclab_ovphysx/test/sensors/test_frame_transformer.py @@ -67,9 +67,9 @@ def _ovphysx_skip_other_device(request): """Skip parametrized tests on the device the session is not pinned to. - ``ovphysx<=0.3.7`` binds device mode at the C++ layer on the first - ``ovphysx.PhysX(device=...)`` construction and cannot swap without a - process restart. Pin the session to whichever device is first used. + The OVPhysX runtime fixes device mode when the process creates its first + ``ovphysx.PhysX`` instance and cannot switch modes without a process + restart. Pin the session to whichever device is first used. """ callspec = getattr(request.node, "callspec", None) device = callspec.params.get("device") if callspec is not None else None diff --git a/source/isaaclab_ovphysx/test/sensors/test_joint_wrench_sensor.py b/source/isaaclab_ovphysx/test/sensors/test_joint_wrench_sensor.py index 2340974f43c..e335ff646c5 100644 --- a/source/isaaclab_ovphysx/test/sensors/test_joint_wrench_sensor.py +++ b/source/isaaclab_ovphysx/test/sensors/test_joint_wrench_sensor.py @@ -12,9 +12,9 @@ fixtures and raw-tensor read helper change. Physical assertions are kept byte-identical so the two backends report the same convention. -``ovphysx<=0.3.7`` binds device mode (CPU vs GPU) at the C++ layer on the -first ``ovphysx.PhysX(device=...)`` construction. Full coverage therefore -requires two pytest runs -- once with ``-k 'cpu'`` and once with +The OVPhysX runtime fixes device mode (CPU vs GPU) when the process creates +its first ``ovphysx.PhysX`` instance. Full coverage therefore requires two +pytest runs -- 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 b0dcdd86ca4a755ebbb0f74a75ca8adf970ab524 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 2 Jul 2026 12:48:45 -0700 Subject: [PATCH 068/139] Resolve articulation-root fixing via physics manager Replace the cfg-type fixed-root-joint creator registry with a fix_articulation_root capability on PhysicsManager. The registry keyed on the exact cfg type missed cfg subclasses (e.g. DeformableNewtonCfg) and backends that register no creator (OVPhysx), and depended on which backend schema module happened to be imported. Resolving the capability from cfg.physics.class_type dispatches through the normal method resolution order instead, so every backend and subclass inherits the correct behaviour. The base implementation authors a backend-neutral fixed joint; PhysX overrides it to relocate the articulation root to the parent prim. Fix the root link before writing fragments so relocation is resolved up front and every fragment is applied to the single resulting root. Previously fragments were written first and the PhysX relocation copied only its own schema, stranding a composed Newton articulation-root API on the former child and leaving two articulation roots. Route the spawner articulation_props slot by type even when the list is empty (an empty list no longer falls through to the legacy writer and raises), and honor the spawner-level fix_root_link flag independently of whether any schema properties were supplied. Scope the fixed-joint lookup to the supplied stage. --- .../vidurv-schema-frag-articulation.minor.rst | 5 + .../isaaclab/physics/physics_manager.py | 58 ++++ .../isaaclab/sim/schemas/_backend_hooks.py | 38 --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 70 ++--- .../sim/spawners/from_files/from_files.py | 27 +- .../test/sim/test_articulation_fragments.py | 263 +++++++++++++----- .../isaaclab_newton/sim/schemas/__init__.py | 48 +--- .../isaaclab_physx/physics/physx_manager.py | 32 +++ .../isaaclab_physx/sim/schemas/__init__.py | 86 +----- 9 files changed, 361 insertions(+), 266 deletions(-) diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst index ac26c8bafe9..dd740d58524 100644 --- a/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-articulation.minor.rst @@ -6,6 +6,11 @@ Added :func:`~isaaclab.sim.schemas.apply_articulation_root_properties`, which applies a list of articulation-root fragments with ``UsdPhysics.ArticulationRootAPI`` as a presence-gated anchor and reproduces the legacy ``fix_root_link`` fixed-joint logic via a spawner-level flag. +* Added the :meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root` capability, which fixes an + articulation base to the world frame and returns the resulting root prim. The base implementation + authors a backend-neutral fixed joint; backends whose parser relocates the articulation root (e.g. + PhysX) override it, so :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` applies every + fragment to the single resulting root regardless of backend. Changed ^^^^^^^ diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 5255af87360..0a12acb7229 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -99,6 +99,64 @@ def provides_implicit_damping(cls) -> bool: """ return True + @classmethod + def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: + """Fix an articulation base to the world frame and return the resulting root prim. + + Authors a world<->root fixed joint that pins the articulation base at its current world pose. + The base implementation authors a backend-neutral ``UsdPhysics.FixedJoint`` (via + :func:`~isaaclab.sim.schemas.create_fixed_root_joint`) and returns ``articulation_prim`` + unchanged -- sufficient for backends whose parser reads a fixed joint on the root directly + (e.g. Newton). Backends whose parser does not treat a fixed joint on a rigid body as a + fixed-base articulation (e.g. PhysX) override this to relocate the articulation root and + return the new root prim, so the caller applies articulation-root schemas to the actual root. + + This is a backend capability rather than a registry keyed by cfg type: the active backend's + manager class is resolved from ``cfg.physics.class_type``, so subclassed cfgs and every + supported backend inherit the correct behaviour through the normal method-resolution order. + + Args: + articulation_prim: The resolved articulation-root prim to fix to the world frame. + stage: The stage the prim lives on. Defaults to None, in which case the current stage is + used. + + Returns: + The articulation-root prim after fixing. May differ from ``articulation_prim`` when the + backend relocates the root (e.g. PhysX moves it to the parent prim). + + Raises: + NotImplementedError: When the root prim is not a rigid body and the first rigid-body link + cannot be determined to anchor the fixed joint. + """ + + from isaaclab.sim.schemas import create_fixed_root_joint # noqa: PLC0415 + + cls._require_rigid_body_root(articulation_prim) + create_fixed_root_joint(articulation_prim, stage) + return articulation_prim + + @staticmethod + def _require_rigid_body_root(articulation_prim: Any) -> None: + """Raise ``NotImplementedError`` when the articulation root is not a rigid body. + + A fixed root joint is anchored to the first rigid-body link of the articulation; when the + resolved root carries no ``RigidBodyAPI`` that link cannot be determined. + + Args: + articulation_prim: The resolved articulation-root prim. + + Raises: + NotImplementedError: When the prim does not have ``UsdPhysics.RigidBodyAPI`` applied. + """ + from pxr import UsdPhysics # noqa: PLC0415 + + if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): + raise NotImplementedError( + f"The articulation prim '{articulation_prim.GetPath().pathString}' does not have the" + " RigidBodyAPI applied. To create a fixed joint, we need to determine the first rigid" + " body link in the articulation tree. However, this is not implemented yet." + ) + @classmethod def register_callback( cls, diff --git a/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py index 433d98cf2dd..3c25d668a3f 100644 --- a/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py +++ b/source/isaaclab/isaaclab/sim/schemas/_backend_hooks.py @@ -16,44 +16,6 @@ from collections.abc import Callable -# Backend-registered creators that fix an articulation base by authoring a world<->root fixed joint, -# keyed by the backend's physics-cfg class (the type of ``SimulationContext.cfg.physics``). Creating it -# is backend-specific (PhysX relocates the articulation root to the parent prim to work around its -# parser; Newton reads the fixed joint directly), so each backend registers its own creator under its -# cfg type. The writer looks up the creator for the active simulation's ``cfg.physics`` type -- a plain -# dict lookup that never imports or executes a non-active backend, and "active" comes solely from the -# live ``cfg.physics`` rather than from which backends happen to be imported. -_FIXED_ROOT_JOINT_CREATORS: dict[type, Callable] = {} - - -def register_fixed_root_joint_creator(physics_cfg: type, creator: Callable) -> None: - """Register the creator that fixes an articulation base for a given physics-backend cfg type. - - Called by :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` when ``fix_root_link`` - is True and no fixed joint yet exists. Each physics backend (e.g. ``isaaclab_physx``, - ``isaaclab_newton``) registers its own creator keyed by its physics-cfg class; the writer looks up - the creator for the active simulation's ``cfg.physics`` type, so core selects the right backend - without importing or probing any of them. - - Args: - physics_cfg: The backend's physics-cfg class (the type of ``SimulationContext.cfg.physics`` for - that backend), used as the lookup key. - creator: A callable ``creator(articulation_prim, stage)`` that authors the fixed joint. - """ - _FIXED_ROOT_JOINT_CREATORS[physics_cfg] = creator - - -def _resolve_fixed_root_joint_creator(physics_cfg: type | None) -> Callable | None: - """Return the creator registered for the active backend's physics-cfg type, or None. - - Args: - physics_cfg: The type of the active simulation's ``cfg.physics`` (or None if no simulation). - """ - if physics_cfg is None: - return None - return _FIXED_ROOT_JOINT_CREATORS.get(physics_cfg) - - # Backend-registered predicates that exclude a joint prim from joint-drive authoring. Backends (e.g. # PhysX tendons) register here via :func:`register_joint_drive_skip_predicate` so the core joint-drive # writers can skip backend-controlled joints without core carrying any backend-specific schema name. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index dc498d75c4c..d85e68a91e5 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -29,7 +29,7 @@ safe_set_attribute_on_usd_schema, ) from . import schemas_cfg -from ._backend_hooks import _resolve_fixed_root_joint_creator, _skip_joint_drive +from ._backend_hooks import _skip_joint_drive # import logger logger = logging.getLogger(__name__) @@ -292,11 +292,16 @@ def apply_articulation_root_properties( itself (define-fresh, e.g. for primitive or programmatic spawns). This guarantees exactly one articulation root rather than stamping a duplicate on the input prim. - Each fragment is then dispatched to the resolved root via its - :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Backend fragments carry backend-specific - funcs, so core never imports a backend. Finally, if ``fix_root_link`` is not ``None``, the - world-to-root fixed-joint logic from the legacy :func:`modify_articulation_root_properties` - writer is applied to the resolved root. + When ``fix_root_link`` is not ``None`` the base is fixed to the world *before* any fragment is + written: an existing world-to-root fixed joint is enabled/disabled, otherwise (when True) one is + created via the active backend's + :meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root` capability. That capability returns + the resulting root prim (some backends relocate it, e.g. PhysX moves the root to the parent), and + every fragment is then dispatched to *that* root via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Writing fragments after the (possibly + relocating) fix keeps all backend schemas on the single resulting root -- so composing e.g. a + PhysX and a Newton fragment with ``fix_root_link=True`` never leaves a stray second root behind. + Backend fragments carry backend-specific funcs, so core never imports a backend. Args: prim_path: The prim path to search for the articulation root under (the root may be on a @@ -314,15 +319,14 @@ def apply_articulation_root_properties( Raises: ValueError: When the prim path is not valid. - RuntimeError: When ``fix_root_link`` is True and a fixed joint must be created, but no backend - creator has been registered (see :func:`register_fixed_root_joint_creator`) -- e.g. no - physics backend extension such as ``isaaclab_physx`` was imported. - NotImplementedError: Propagated from the registered creator when it cannot create the fixed + RuntimeError: When ``fix_root_link`` is True and a fixed joint must be created but there is no + active :class:`~isaaclab.sim.SimulationContext` to resolve the physics backend from. + NotImplementedError: Propagated from the backend capability when it cannot create the fixed joint (e.g. the resolved root prim is not a rigid body). """ if stage is None: stage = get_current_stage() - # tune the existing root in place (it may live on a child prim); instance proxies can't be + # resolve the existing root (it may live on a child prim in USD assets); instance proxies can't be # authored on, so don't traverse them articulation_prim = get_first_matching_child_prim( prim_path, @@ -334,42 +338,42 @@ def apply_articulation_root_properties( if articulation_prim is None: articulation_prim = stage.GetPrimAtPath(prim_path) UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) - root_path = articulation_prim.GetPath().pathString - # dispatch each fragment to the resolved root via its own applier - for cfg in fragments: - func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) - func(cfg, root_path, stage) - # fix root link based on input - # we do the fixed joint processing later to not interfere with setting other properties. - # this logic is reproduced from the legacy ``modify_articulation_root_properties`` writer. + # Fix the root link BEFORE writing fragments. Some backends relocate the root when fixing (PhysX + # moves the ``ArticulationRootAPI`` to the parent), so resolving the final root first lets every + # fragment land on that single root -- otherwise a relocation would strand fragment schemas on the + # former child and leave two articulation roots. + final_root = articulation_prim if fix_root_link is not None: - # check if a global fixed joint exists under the resolved root prim - existing_fixed_joint_prim = find_global_fixed_joint_prim(root_path) - - # if we found a fixed joint, enable/disable it based on the input - # otherwise, create a fixed joint between the world and the root link + root_path = articulation_prim.GetPath().pathString + # honor an explicit stage so the lookup does not fall back to the global current stage + existing_fixed_joint_prim = find_global_fixed_joint_prim(root_path, stage=stage) if existing_fixed_joint_prim is not None: + # a joint already exists: just enable/disable it in place (no relocation) logger.info( f"Found an existing fixed joint for the articulation: '{root_path}'. Setting it to: {fix_root_link}." ) existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) elif fix_root_link: logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") - # Creating a world<->root fixed joint to fix the base is backend-specific. Look up the - # creator registered for the active simulation's physics-cfg type so core carries no backend - # logic and never touches a non-active backend; see register_fixed_root_joint_creator. + # Creating (and possibly relocating) the root is backend-specific. Delegate to the active + # backend's manager capability so core carries no backend logic; the capability returns the + # resulting root prim. from isaaclab.sim import SimulationContext sim = SimulationContext.instance() - creator = _resolve_fixed_root_joint_creator(type(sim.cfg.physics) if sim is not None else None) - if creator is None: + if sim is None: raise RuntimeError( - f"Cannot fix the articulation root '{root_path}': no fixed-root-joint creator is" - " registered for the active physics backend. Import the matching backend extension" - " (e.g. isaaclab_physx or isaaclab_newton) so it can register one." + f"Cannot fix the articulation root '{root_path}': no active SimulationContext to" + " resolve the physics backend from. A simulation must be running to fix a base." ) - creator(articulation_prim, stage) + final_root = sim.physics_manager.fix_articulation_root(articulation_prim, stage) + + # dispatch each fragment to the resolved (possibly relocated) root via its own applier + final_root_path = final_root.GetPath().pathString + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, final_root_path, stage) return True 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 96d56733cae..fb05a60e2f9 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -368,17 +368,30 @@ def _spawn_from_usd_file( schemas.modify_mass_properties(prim_path, cfg.mass_props) # modify articulation root properties - if cfg.articulation_props is not None: - # transition shim, remove later: new fragment list -> apply_*; legacy single cfg -> modify_* + # ``fix_root_link`` is a spawner-level topology flag (not a schema property); it is honored on the + # fragment path independently of whether any articulation schema properties were supplied. + articulation_props = cfg.articulation_props + articulation_fix_root_link = getattr(cfg, "fix_root_link", None) + # transition shim, remove later: route a legacy single cfg (a dataclass, not a fragment) to the + # legacy writer -- it owns its own ``fix_root_link`` field; everything else goes to the fragment + # writer, routing by type so an empty list is still a valid (topology-only) fragment collection + # rather than being mis-sent to the legacy writer. + if ( + articulation_props is not None + and not isinstance(articulation_props, (list, tuple)) + and not isinstance(articulation_props, schemas.SchemaFragment) + ): + schemas.modify_articulation_root_properties(prim_path, articulation_props) + else: articulation_frags = ( - cfg.articulation_props if isinstance(cfg.articulation_props, (list, tuple)) else [cfg.articulation_props] + list(articulation_props) + if isinstance(articulation_props, (list, tuple)) + else ([articulation_props] if isinstance(articulation_props, schemas.SchemaFragment) else []) ) - if articulation_frags and all(isinstance(f, schemas.SchemaFragment) for f in articulation_frags): + if articulation_frags or articulation_fix_root_link is not None: schemas.apply_articulation_root_properties( - prim_path, articulation_frags, fix_root_link=getattr(cfg, "fix_root_link", None) + prim_path, articulation_frags, fix_root_link=articulation_fix_root_link ) - else: - schemas.modify_articulation_root_properties(prim_path, cfg.articulation_props) # modify tendon properties if cfg.fixed_tendons_props is not None: # transition shim, remove later: fragment(s) -> apply_*; legacy cfg -> modify_* diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 227deb10e2c..2e92548d50b 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -12,9 +12,11 @@ """Rest everything follows.""" +import os + import pytest -from pxr import Gf, UsdGeom, UsdPhysics +from pxr import Gf, Usd, UsdGeom, UsdPhysics import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext @@ -247,16 +249,13 @@ def test_apply_articulation_root_properties_fix_root_link_requires_rigid_body(): ) -def test_apply_articulation_root_properties_fix_root_link_without_backend_raises(monkeypatch): - """Creating a fixed root joint with no registered backend creator raises a clear error: the - backend-specific creation logic lives in the backends, so core cannot fix the base on its own. - - Uses monkeypatch to clear the module-global creator registry (other tests/backends register - session-wide); monkeypatch restores it afterwards so this stays isolated. +def test_apply_articulation_root_properties_fix_root_link_without_active_simulation_raises(monkeypatch): + """Creating a fixed root joint with no active ``SimulationContext`` raises a clear error: the + backend that authors (and possibly relocates) the root is resolved from the live simulation, so + without one core cannot fix the base on its own. """ - from isaaclab.sim.schemas import _backend_hooks, apply_articulation_root_properties - - monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", {}) + from isaaclab.sim import SimulationContext as _SimCtx + from isaaclab.sim.schemas import apply_articulation_root_properties sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -264,39 +263,81 @@ def test_apply_articulation_root_properties_fix_root_link_without_backend_raises root = _make_xform(stage, "/World/Robot3") UsdPhysics.RigidBodyAPI.Apply(root) UsdPhysics.ArticulationRootAPI.Apply(root) + # simulate "no active simulation" so the backend cannot be resolved + monkeypatch.setattr(_SimCtx, "instance", classmethod(lambda cls: None)) with pytest.raises(RuntimeError): apply_articulation_root_properties("/World/Robot3", [], stage, fix_root_link=True) -def test_register_fixed_root_joint_creator_selects_active_backend(monkeypatch): - """The writer selects the creator registered for the active simulation's ``cfg.physics`` type and - ignores creators registered for other backend cfg types -- no probing of inactive backends.""" - from isaaclab.sim.schemas import _backend_hooks, apply_articulation_root_properties +def test_physx_and_newton_fragments_fix_root_link_keeps_single_root(): + """Composing a PhysX and a Newton fragment with ``fix_root_link=True`` must leave exactly one + articulation root, with *every* backend's schema on that resulting root. + + Regression: PhysX relocates the root to the parent when fixing the base. Writing fragments after + the relocation lands both the PhysX and Newton schemas on the parent; the former child (root link) + must not retain ``NewtonArticulationRootAPI`` / ``newton:*`` (which would leave a stray second root + because that API composes ``PhysicsArticulationRootAPI``). + """ + from isaaclab_newton.sim.schemas import NewtonArticulationCfg + from isaaclab_physx.sim.schemas import PhysxArticulationCfg - monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", {}) - called = {} + from isaaclab.sim.schemas import apply_articulation_root_properties sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) - active_cfg_type = type(SimulationContext.instance().cfg.physics) - - class _OtherBackendCfg: - """Stand-in for an inactive backend's physics-cfg type.""" + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/Robot5") + child = _make_xform(stage, "/World/Robot5/base") + UsdPhysics.RigidBodyAPI.Apply(child) + UsdPhysics.ArticulationRootAPI.Apply(child) - # a creator keyed by an inactive backend's cfg type must be ignored; the active one's is used - _backend_hooks.register_fixed_root_joint_creator( - _OtherBackendCfg, lambda p, s: called.__setitem__("path", "INACTIVE") - ) - _backend_hooks.register_fixed_root_joint_creator( - active_cfg_type, lambda p, s: called.__setitem__("path", p.GetPath().pathString) + apply_articulation_root_properties( + "/World/Robot5", + [ + PhysxArticulationCfg(solver_position_iteration_count=8), + NewtonArticulationCfg(self_collision_enabled=True), + ], + stage, + fix_root_link=True, ) - stage = sim_utils.get_current_stage() - root = _make_xform(stage, "/World/Robot4") - UsdPhysics.RigidBodyAPI.Apply(root) + parent = stage.GetPrimAtPath("/World/Robot5") + # exactly one articulation root remains, and it is the (relocated) parent + roots = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert len(roots) == 1 and roots[0] == parent + # every backend's schema is authored on that single resulting root ... + assert parent.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 + assert parent.GetAttribute("newton:selfCollisionEnabled").Get() is True + # ... and nothing is stranded on the former child root link + assert not child.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not child.GetAttribute("newton:selfCollisionEnabled").HasAuthoredValue() + + +def test_apply_articulation_root_properties_honors_explicit_stage(): + """The writer authors to the explicitly supplied stage even when it is not the current stage -- + every lookup (root resolution, fixed-joint search) is scoped to that stage.""" + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + # an in-memory stage that is deliberately NOT the current stage + other_stage = Usd.Stage.CreateInMemory() + UsdGeom.Xform.Define(other_stage, "/World") + root = UsdGeom.Xform.Define(other_stage, "/World/AltStageRoot").GetPrim() UsdPhysics.ArticulationRootAPI.Apply(root) - apply_articulation_root_properties("/World/Robot4", [], stage, fix_root_link=True) - assert called["path"] == "/World/Robot4" + + apply_articulation_root_properties( + "/World/AltStageRoot", + [PhysxArticulationCfg(solver_position_iteration_count=4)], + other_stage, + ) + + # authored on the supplied stage ... + assert root.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 4 + # ... and not leaked onto the current stage + assert not sim_utils.get_current_stage().GetPrimAtPath("/World/AltStageRoot").IsValid() # ------------------------------------------------------------------------------------- @@ -357,65 +398,159 @@ def test_create_fixed_root_joint_uses_unique_names(): # ------------------------------------------------------------------------------------- -# active-backend detection: creator is resolved by the live cfg.physics type +# fix_articulation_root is a PhysicsManager capability (resolved by cfg.physics.class_type) # ------------------------------------------------------------------------------------- -def test_resolve_fixed_root_joint_creator_matches_cfg_type(monkeypatch): - """_resolve_fixed_root_joint_creator returns the creator registered for the given physics-cfg type, - and None for an unregistered type or no active simulation -- a pure dict lookup, no probing.""" - from isaaclab.sim.schemas import _backend_hooks +def test_base_manager_fix_articulation_root_authors_joint_without_reparenting(): + """The base :class:`~isaaclab.physics.PhysicsManager` capability authors a ``UsdPhysics.FixedJoint`` + and leaves the articulation root in place, returning the same prim. - monkeypatch.setattr(_backend_hooks, "_FIXED_ROOT_JOINT_CREATORS", {}) + This is the default inherited by backends whose parser reads a fixed joint directly (e.g. Newton, + OVPhysx) and by any manager subclass that does not override it -- no PhysX-style relocation. + """ + from isaaclab.physics import PhysicsManager - class _CfgA: - pass + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/BaseRobot") + root = _make_xform(stage, "/World/BaseRobot/base") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) - class _CfgB: - pass + result = PhysicsManager.fix_articulation_root(root, stage) + + assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + # base default returns the same prim and does NOT relocate the root to the parent + assert result == root + assert root.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not stage.GetPrimAtPath("/World/BaseRobot").HasAPI(UsdPhysics.ArticulationRootAPI) - def _creator_a(prim, stage): - pass - _backend_hooks.register_fixed_root_joint_creator(_CfgA, _creator_a) +def test_base_manager_fix_articulation_root_requires_rigid_body(): + """The capability raises NotImplementedError on a non-rigid-body root (cannot anchor the joint).""" + from isaaclab.physics import PhysicsManager + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + root = _make_xform(stage, "/World/BaseNoRB") + UsdPhysics.ArticulationRootAPI.Apply(root) # articulation root but NOT a rigid body + with pytest.raises(NotImplementedError): + PhysicsManager.fix_articulation_root(root, stage) - assert _backend_hooks._resolve_fixed_root_joint_creator(_CfgA) is _creator_a - assert _backend_hooks._resolve_fixed_root_joint_creator(_CfgB) is None - assert _backend_hooks._resolve_fixed_root_joint_creator(None) is None +def test_fix_articulation_root_capability_inherited_by_manager_subclass(): + """A backend manager subclass inherits its parent backend's ``fix_articulation_root`` through the + normal method-resolution order. -def test_newton_creator_authors_joint_without_reparenting(): - """The Newton fixed-root-joint creator authors a ``UsdPhysics.FixedJoint`` but, unlike PhysX, leaves - the articulation root in place (Newton reads the fixed joint directly), and pulls in no PhysX deps.""" - from isaaclab_newton.sim.schemas import _create_fixed_root_joint_newton + This is what makes the capability robust where the previous cfg-type registry was not: manager + dispatch is resolved from ``cfg.physics.class_type``, so a subclassed cfg (e.g. an in-tree + ``DeformableNewtonCfg(NewtonCfg)``) whose ``class_type`` is an unchanged or subclassed manager + inherits the correct behaviour, rather than missing an exact-type registry key. + """ + from isaaclab.physics import PhysicsManager sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) + + # a minimal concrete backend that OVERRIDES the capability (as PhysX does, relocating the root) ... + class _BackendLike(PhysicsManager): + @classmethod + def fix_articulation_root(cls, articulation_prim, stage=None): + from isaaclab.sim.schemas import create_fixed_root_joint + + cls._require_rigid_body_root(articulation_prim) + create_fixed_root_joint(articulation_prim, stage) + parent = articulation_prim.GetParent() + UsdPhysics.ArticulationRootAPI.Apply(parent) + articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) + return parent + + # ... and a subclass that does not override it (stand-in for a subclassed cfg's class_type) + class _SubBackend(_BackendLike): + pass + stage = sim_utils.get_current_stage() - _make_xform(stage, "/World/NewtonRobot") - root = _make_xform(stage, "/World/NewtonRobot/base") + _make_xform(stage, "/World/SubRobot") + root = _make_xform(stage, "/World/SubRobot/base") UsdPhysics.RigidBodyAPI.Apply(root) UsdPhysics.ArticulationRootAPI.Apply(root) - _create_fixed_root_joint_newton(root, stage) + result = _SubBackend.fix_articulation_root(root, stage) + parent = stage.GetPrimAtPath("/World/SubRobot") + # the subclass inherited the overriding backend's behaviour through the MRO assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) - # Newton does NOT relocate the articulation root to the parent (that is a PhysX-parser workaround) - assert root.HasAPI(UsdPhysics.ArticulationRootAPI) - assert not stage.GetPrimAtPath("/World/NewtonRobot").HasAPI(UsdPhysics.ArticulationRootAPI) + assert result == parent + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) + + # and the real active backend (PhysX) genuinely overrides the base -- so backends specialise via + # this same inheritance mechanism rather than a parallel registry + active_manager = SimulationContext.instance().physics_manager + assert active_manager.fix_articulation_root.__func__ is not PhysicsManager.fix_articulation_root.__func__ + + +# ------------------------------------------------------------------------------------- +# end-to-end: the from-files transition bridge routes articulation_props by type +# ------------------------------------------------------------------------------------- + + +def _author_articulation_usd(path: str) -> None: + """Author a minimal USD asset: a parent Xform with a rigid-body child carrying the articulation root.""" + asset_stage = Usd.Stage.CreateNew(path) + robot = UsdGeom.Xform.Define(asset_stage, "/Robot") + base = UsdGeom.Xform.Define(asset_stage, "/Robot/base").GetPrim() + UsdPhysics.RigidBodyAPI.Apply(base) + UsdPhysics.ArticulationRootAPI.Apply(base) + asset_stage.SetDefaultPrim(robot.GetPrim()) + asset_stage.Save() -def test_newton_creator_requires_rigid_body(): - """The Newton creator raises NotImplementedError on a non-rigid-body root (cannot anchor the joint).""" - from isaaclab_newton.sim.schemas import _create_fixed_root_joint_newton +def test_spawn_from_usd_file_empty_articulation_props_with_fix_root_link(tmp_path): + """An empty ``articulation_props=[]`` on a USD spawn is a valid (topology-only) fragment collection: + it must route to the fragment writer and honor ``fix_root_link`` -- not crash in the legacy writer. + + Regression: the legacy path called ``dataclasses.fields([])`` on the empty list and raised + ``TypeError``, so the direct-writer and spawner entry points disagreed on whether ``[]`` was valid. + """ + from isaaclab.sim.spawners.from_files.from_files import _spawn_from_usd_file + from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) + usd_path = os.path.join(tmp_path, "articulation.usda") + _author_articulation_usd(usd_path) + + cfg = UsdFileCfg(usd_path=usd_path, articulation_props=[], fix_root_link=True) + _spawn_from_usd_file("/World/FromUsdA", usd_path, cfg) + stage = sim_utils.get_current_stage() - root = _make_xform(stage, "/World/NewtonNoRB") - UsdPhysics.ArticulationRootAPI.Apply(root) # articulation root but NOT a rigid body - with pytest.raises(NotImplementedError): - _create_fixed_root_joint_newton(root, stage) + # the topology flag was honored: a fixed joint was created and the root relocated to the parent + assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + parent = stage.GetPrimAtPath("/World/FromUsdA") + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not stage.GetPrimAtPath("/World/FromUsdA/base").HasAPI(UsdPhysics.ArticulationRootAPI) + + +def test_spawn_from_usd_file_none_articulation_props_honors_fix_root_link(tmp_path): + """``fix_root_link`` is a spawner-level flag processed independently of ``articulation_props``: + with ``articulation_props=None`` it must still fix the base (previously it was silently ignored).""" + from isaaclab.sim.spawners.from_files.from_files import _spawn_from_usd_file + from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + usd_path = os.path.join(tmp_path, "articulation.usda") + _author_articulation_usd(usd_path) + + cfg = UsdFileCfg(usd_path=usd_path, articulation_props=None, fix_root_link=True) + _spawn_from_usd_file("/World/FromUsdB", usd_path, cfg) + + stage = sim_utils.get_current_stage() + assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) # ------------------------------------------------------------------------------------- diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index a15f8904bb5..f5a65d933f5 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -5,51 +5,11 @@ """Sub-module containing Newton schema configuration exports.""" -from isaaclab.sim.schemas._backend_hooks import register_fixed_root_joint_creator from isaaclab.utils.module import lazy_export -from isaaclab_newton.physics import NewtonCfg - lazy_export() - -def _create_fixed_root_joint_newton(articulation_prim, stage) -> None: - """Fix an articulation base by authoring a world<->root ``UsdPhysics.FixedJoint``. - - Backend creator registered with the core articulation-root writer for the Newton backend. Newton's - importer reads a ``UsdPhysics.FixedJoint`` directly as a fixed root joint (a jointless root would - otherwise default to floating), so -- unlike PhysX -- no articulation-root relocation is needed. The - joint is authored with the backend-neutral :func:`~isaaclab.sim.schemas.create_fixed_root_joint` - helper (pure USD, no Kit/``omni`` dependency), so the Newton backend pulls in nothing PhysX-flavored. - - Args: - articulation_prim: The resolved articulation-root prim to fix to the world frame. - stage: The stage the prim lives on. - - Raises: - NotImplementedError: When the root prim is not a rigid body (the first rigid-body link cannot be - determined to anchor the fixed joint). - """ - # Imports are deferred to call time on purpose: this creator only runs while a simulation is live - # (USD available), whereas this package is imported eagerly when a config references a Newton cfg - # name. ``pxr`` and ``schemas.schemas`` (which imports ``pxr`` at its top) must stay off that eager - # path to keep config import free of the USD/Omniverse runtime. - from pxr import UsdPhysics - - from isaaclab.sim.schemas.schemas import create_fixed_root_joint - - if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): - raise NotImplementedError( - f"The articulation prim '{articulation_prim.GetPath().pathString}' does not have the" - " RigidBodyAPI applied. To create a fixed joint, we need to determine the first rigid body" - " link in the articulation tree. However, this is not implemented yet." - ) - create_fixed_root_joint(articulation_prim, stage) - - -# Register the Newton fixed-root-joint creator with the core articulation-root writer keyed by -# ``NewtonCfg``, so the writer selects it only when Newton is the active simulation backend -# (``cfg.physics`` is a ``NewtonCfg``). This makes fixing an articulation base work in a Newton-only run -# without core carrying any backend logic. Registered on import of this package (which a caller does to -# construct Newton schema fragments). -register_fixed_root_joint_creator(NewtonCfg, _create_fixed_root_joint_newton) +# Fixing an articulation base is a backend capability on the physics manager +# (:meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root`). Newton reads a +# ``UsdPhysics.FixedJoint`` on the root directly, so it inherits the base manager behaviour (author the +# neutral fixed joint, leave the root in place) without a Newton-specific override here. diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index d399d95546b..23034cf2fdd 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -303,6 +303,38 @@ def initialize(cls, sim_context: SimulationContext) -> None: omni.kit.app.get_app().update() sim.set_setting("/app/player/playSimulations", True) # type: ignore[union-attr] + @classmethod + def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: + """Fix an articulation base for the PhysX parser and return the relocated root prim. + + The PhysX parser does not treat a fixed joint on a rigid body as a fixed-base articulation -- + it folds it into the maximal-coordinate tree. To work around that, this authors the + world<->root fixed joint and then relocates the ``UsdPhysics.ArticulationRootAPI`` anchor from + the root link to its parent prim, returning the parent as the new root. Articulation-root + fragments are applied by the caller *after* this call, so they land on the returned root; no + attribute copying is needed here. + + Args: + articulation_prim: The resolved articulation-root prim to fix to the world frame. + stage: The stage the prim lives on. Defaults to None, in which case the current stage is + used. + + Returns: + The parent prim, which becomes the articulation root after relocation. + + Raises: + NotImplementedError: When the root prim is not a rigid body. + """ + from isaaclab.sim.schemas import create_fixed_root_joint + + cls._require_rigid_body_root(articulation_prim) + create_fixed_root_joint(articulation_prim, stage) + # Relocate the articulation root to the parent so the PhysX parser recognises a fixed base. + parent_prim = articulation_prim.GetParent() + UsdPhysics.ArticulationRootAPI.Apply(parent_prim) + articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) + return parent_prim + @classmethod def reset(cls, soft: bool = False) -> None: """Reset the physics simulation.""" diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index c64ec59e949..d5cb72c6b81 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -5,90 +5,16 @@ """Sub-module containing PhysX schema configuration exports.""" -from isaaclab.sim.schemas._backend_hooks import ( - register_fixed_root_joint_creator, - register_joint_drive_skip_predicate, -) +from isaaclab.sim.schemas._backend_hooks import register_joint_drive_skip_predicate from isaaclab.utils.module import lazy_export -from isaaclab_physx.physics import PhysxCfg - lazy_export() - -def _create_fixed_root_joint(articulation_prim, stage) -> None: - """Fix an articulation base by authoring a world<->root fixed joint (PhysX parser semantics). - - Backend creator registered with the core articulation-root writer. Creating the joint relies on - the PhysX parser, which does not treat a fixed joint on a rigid body as a fixed-base articulation; - relocating the ``UsdPhysics.ArticulationRootAPI`` (and its ``physxArticulation:*`` attributes) to - the parent prim works around that. This PhysX-specific logic lives in the PhysX package so core - carries none of it. - - Args: - articulation_prim: The resolved articulation-root prim to fix to the world frame. - stage: The stage the prim lives on. - - Raises: - NotImplementedError: When the root prim is not a rigid body (the first rigid-body link cannot - be determined to attach the fixed joint). - """ - # Imports are deferred to call time on purpose: this creator only runs while a simulation is live - # (USD available), whereas this package is imported eagerly when a config references a PhysX cfg - # name. ``pxr`` and ``schemas.schemas`` (which imports ``pxr`` at its top) must stay off that eager - # path to keep config import free of the USD/Omniverse runtime. - from pxr import UsdPhysics - - from isaaclab.sim.schemas.schemas import create_fixed_root_joint - - # note: we assume the root prim is a rigid body; there is no obvious way to get the first rigid - # body link identified by the PhysX parser when it is not. - if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): - raise NotImplementedError( - f"The articulation prim '{articulation_prim.GetPath().pathString}' does not have the" - " RigidBodyAPI applied. To create a fixed joint, we need to determine the first rigid body" - " link in the articulation tree. However, this is not implemented yet." - ) - - # create a fixed joint between the root link and the world frame - create_fixed_root_joint(articulation_prim, stage) - - # Having a fixed joint on a rigid body is not treated as "fixed base articulation"; it is treated as - # part of the maximal coordinate tree. Moving the articulation root to the parent solves this (a - # limitation of the PhysX parser). - parent_prim = articulation_prim.GetParent() - UsdPhysics.ArticulationRootAPI.Apply(parent_prim) - if "PhysxArticulationAPI" not in parent_prim.GetAppliedSchemas(): - parent_prim.AddAppliedSchema("PhysxArticulationAPI") - - # copy the attributes to the parent - # -- usd attributes - usd_articulation_api = UsdPhysics.ArticulationRootAPI(articulation_prim) - for attr_name in usd_articulation_api.GetSchemaAttributeNames(): - attr = articulation_prim.GetAttribute(attr_name) - parent_attr = parent_prim.GetAttribute(attr_name) - if not parent_attr: - parent_attr = parent_prim.CreateAttribute(attr_name, attr.GetTypeName()) - parent_attr.Set(attr.Get()) - # -- physx attributes (copy by name prefix) - for attr in articulation_prim.GetAttributes(): - aname = attr.GetName() - if aname.startswith("physxArticulation:"): - parent_attr = parent_prim.GetAttribute(aname) - if not parent_attr: - parent_attr = parent_prim.CreateAttribute(aname, attr.GetTypeName()) - parent_attr.Set(attr.Get()) - - # remove the api from the (former) root - articulation_prim.RemoveAppliedSchema("PhysxArticulationAPI") - articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) - - -# Keep PhysX articulation-root logic out of core: register the creator with the core articulation-root -# writer keyed by ``PhysxCfg``, so the writer selects it only when PhysX is the active simulation -# backend (``cfg.physics`` is a ``PhysxCfg``). Registered on import of this package (which a caller does -# to construct PhysX schema fragments), inverting the dependency so core carries no PhysX logic. -register_fixed_root_joint_creator(PhysxCfg, _create_fixed_root_joint) +# Fixing an articulation base is a backend capability on the physics manager +# (:meth:`~isaaclab_physx.physics.PhysxManager.fix_articulation_root`): PhysX authors the fixed joint +# and relocates the articulation root to the parent prim (its parser does not treat a fixed joint on a +# rigid body as a fixed-base articulation). That PhysX-specific logic lives on the manager, so this +# schema package no longer registers a creator. def _is_physx_tendon_child(prim) -> bool: From 3c485d9e23b08688bac3f4613dc3c3ef04e36f1a Mon Sep 17 00:00:00 2001 From: ooctipus Date: Thu, 2 Jul 2026 13:38:32 -0700 Subject: [PATCH 069/139] Fix depth-only cameras blacking out GUI viewport (#6304) # Description Depth-only RTX cameras enable the global `/rtx/sdg/force/disableColorRender` optimization. The renderer already disables that optimization when `/isaaclab/has_gui` is true, but a visualizer refactor removed the last writer of that GUI setting. As a result, the setting remained unset and adding a depth-only camera could black out the GUI viewport. This change publishes `/isaaclab/has_gui` from `AppLauncher` before `SimulationContext`, PhysX, and RTX renderer initialization. It preserves the historical semantics: a local window, livestream, or XR session counts as a GUI. Truly headless camera runs retain the depth-only rendering optimization. Regression coverage verifies the launcher state matrix and both GUI and headless depth-only renderer behavior. No additional dependencies are required. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots Not applicable. The behavior is covered by focused setting and renderer contract tests. ## Testing - `./isaaclab.sh -p -m pytest source/isaaclab/test/app/test_kwarg_launch.py::test_load_extensions_publishes_has_gui_setting source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py -q` (7 passed) - `pre-commit run --files source/isaaclab/test/app/test_kwarg_launch.py source/isaaclab/isaaclab/app/app_launcher.py source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip` - `python3 tools/changelog/cli.py check develop` ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have run the pre-commit checks on every changed file - [x] Documentation changes are not required for this internal behavior fix - [x] My changes generate no new warnings - [x] I have added tests that prove the fix is effective - [x] I have added changelog fragments for every touched package - [x] My name is already present in `CONTRIBUTORS.md` --- .../zhengyuz-fix-depth-camera-viewport.rst | 5 ++ source/isaaclab/isaaclab/app/app_launcher.py | 4 ++ source/isaaclab/test/app/test_kwarg_launch.py | 35 ++++++++++++ ...depth-camera-viewport-color-rendering.skip | 1 + .../test_isaac_rtx_renderer_contract.py | 55 ++++++++++++++++++- 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst create mode 100644 source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip diff --git a/source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst b/source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst new file mode 100644 index 00000000000..9fc0987aa4a --- /dev/null +++ b/source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed depth-only RTX cameras disabling color rendering in the viewport by restoring GUI-state publication from + :class:`~isaaclab.app.AppLauncher`. diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 99215b696af..1428e0f763f 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -1263,6 +1263,10 @@ def _load_extensions(self): logging.getLogger().setLevel(logging.INFO) settings = get_settings_manager() + # Publish whether Kit has an interactive GUI (local window, livestream, or XR). + # SimulationContext and renderers consume this setting during their initialization. + settings.set_bool("/isaaclab/has_gui", not self._headless or self._livestream >= 1 or self._xr) + # set setting to indicate Isaac Lab's offscreen_render pipeline should be enabled settings.set_bool("/isaaclab/render/offscreen", self._offscreen_render) diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index 1eb5b59764a..e5437619c8c 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause import argparse +import logging import pytest @@ -54,6 +55,40 @@ def set_bool(self, path: str, value: bool) -> None: self.values[path] = value +@pytest.mark.parametrize( + ("headless", "livestream", "xr", "expected_has_gui"), + [ + pytest.param(False, 0, False, True, id="local-window"), + pytest.param(True, 0, False, False, id="headless"), + pytest.param(True, 1, False, True, id="livestream"), + pytest.param(True, 0, True, True, id="xr"), + ], +) +def test_load_extensions_publishes_has_gui_setting( + monkeypatch: pytest.MonkeyPatch, headless: bool, livestream: int, xr: bool, expected_has_gui: bool +): + """Publish the GUI state consumed by SimulationContext and RTX rendering.""" + launcher = AppLauncher.__new__(AppLauncher) + launcher._apply_rtx_determinism = False + launcher._python_logging_level = logging.ERROR + launcher._headless = headless + launcher._livestream = livestream + launcher._enable_cameras = False + launcher._offscreen_render = False + launcher._render_viewport = False + launcher._xr = xr + launcher._video_enabled = False + + settings = _DummySettings() + monkeypatch.setattr(app_launcher_module, "initialize_carb_settings", lambda: None) + monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) + monkeypatch.setattr(AppLauncher, "_apply_python_logging_level", lambda _level: None) + + launcher._load_extensions() + + assert settings.values["/isaaclab/has_gui"] is expected_has_gui + + def test_set_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): settings = _DummySettings() monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) diff --git a/source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip b/source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip new file mode 100644 index 00000000000..7c371952533 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip @@ -0,0 +1 @@ +Test-only: added regression coverage for GUI and headless depth-only color-render behavior. diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py index 7f16b2f8034..65cd093ce33 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py @@ -9,7 +9,8 @@ import sys import types -from unittest.mock import MagicMock, patch +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch import pytest import warp as wp @@ -52,3 +53,55 @@ def test_isaac_rtx_supported_output_types_include_rgb_hdr(monkeypatch): specs = renderer.supported_output_types() assert specs[RenderBufferKind.RGB_HDR] == RenderBufferSpec(3, wp.float32) + + +@pytest.mark.parametrize( + ("has_gui", "expected_disable_color_render"), + [ + pytest.param(True, False, id="gui-keeps-color-rendering"), + pytest.param(False, True, id="headless-uses-depth-only-rendering"), + ], +) +def test_depth_only_camera_color_render_setting(monkeypatch, has_gui, expected_disable_color_render): + """Depth-only cameras must not disable color rendering for an active GUI. + + ``disableColorRender`` is a global RTX setting, so enabling it for a depth-only + camera also blacks out the viewport. Headless execution should retain the + depth-only optimization. + """ + _, syntheticdata_module = _install_omni_stubs(monkeypatch) + monkeypatch.setattr(syntheticdata_module, "SyntheticData", MagicMock(), raising=False) + + import isaaclab_physx.renderers.isaac_rtx_renderer as rtx_renderer + from isaaclab_physx.renderers.isaac_rtx_renderer_cfg import IsaacRtxRendererCfg + + import isaaclab.sim.utils.stage as stage_utils + + settings = MagicMock() + settings.get.return_value = has_gui + + # Camera validation terminates create_render_data immediately after the + # color-render setting is selected, keeping this a lightweight unit test. + stage = MagicMock() + stage.GetPrimAtPath.return_value.IsA.return_value = False + spec = SimpleNamespace( + camera_prim_paths=["/World/NotACamera"], + cfg=SimpleNamespace(data_types=["depth"]), + ) + renderer = rtx_renderer.IsaacRtxRenderer.__new__(rtx_renderer.IsaacRtxRenderer) + renderer.cfg = IsaacRtxRendererCfg() + + with ( + patch.object(rtx_renderer, "get_settings_manager", return_value=settings), + patch.object(rtx_renderer, "get_isaac_sim_version", return_value=version.parse("6.0")), + patch.object(stage_utils, "get_current_stage", return_value=stage), + pytest.raises(RuntimeError, match="is not a Camera"), + ): + renderer.create_render_data(spec) + + color_render_calls = [ + setting_call + for setting_call in settings.set_bool.call_args_list + if setting_call.args[0] == "/rtx/sdg/force/disableColorRender" + ] + assert color_render_calls[-1] == call("/rtx/sdg/force/disableColorRender", expected_disable_color_render) From 4dc87bb8fc0871f8dbcbdbd13b673457055f2240 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 2 Jul 2026 14:51:20 -0700 Subject: [PATCH 070/139] Move create_fixed_root_joint to sim.utils The fixed-root-joint authoring helper lived in sim.schemas, but after the fix operation moved onto the physics manager its only callers are the managers. That left the manager reaching back into sim.schemas for the primitive, so the runtime chain was schemas.apply_articulation_root_properties -> manager.fix_articulation_root -> schemas.create_fixed_root_joint, and sim.schemas was the physics manager's sole reason to import from schemas at all. Relocate the helper to sim.utils.prims next to the other USD authoring utilities (and its find_global_fixed_joint_prim sibling), so the dependency runs one way: both sim.schemas and the physics managers depend downward on sim.utils, and the physics package no longer imports sim.schemas. The public entry point isaaclab.sim.create_fixed_root_joint is unchanged; it is dropped from isaaclab.sim.schemas. --- .../isaaclab/physics/physics_manager.py | 4 +- source/isaaclab/isaaclab/sim/__init__.pyi | 2 +- .../isaaclab/sim/schemas/__init__.pyi | 2 - .../isaaclab/isaaclab/sim/schemas/schemas.py | 54 ------------------- .../isaaclab/isaaclab/sim/utils/__init__.pyi | 2 + source/isaaclab/isaaclab/sim/utils/prims.py | 54 +++++++++++++++++++ .../test/sim/test_articulation_fragments.py | 10 ++-- .../isaaclab_physx/physics/physx_manager.py | 2 +- 8 files changed, 65 insertions(+), 65 deletions(-) diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 0a12acb7229..98e375e0331 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -105,7 +105,7 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any Authors a world<->root fixed joint that pins the articulation base at its current world pose. The base implementation authors a backend-neutral ``UsdPhysics.FixedJoint`` (via - :func:`~isaaclab.sim.schemas.create_fixed_root_joint`) and returns ``articulation_prim`` + :func:`~isaaclab.sim.utils.create_fixed_root_joint`) and returns ``articulation_prim`` unchanged -- sufficient for backends whose parser reads a fixed joint on the root directly (e.g. Newton). Backends whose parser does not treat a fixed joint on a rigid body as a fixed-base articulation (e.g. PhysX) override this to relocate the articulation root and @@ -129,7 +129,7 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any cannot be determined to anchor the fixed joint. """ - from isaaclab.sim.schemas import create_fixed_root_joint # noqa: PLC0415 + from isaaclab.sim.utils import create_fixed_root_joint # noqa: PLC0415 cls._require_rigid_body_root(articulation_prim) create_fixed_root_joint(articulation_prim, stage) diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 55e12015fc9..06bfd85cc16 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -266,7 +266,6 @@ from .schemas import ( UsdPhysicsRigidBodyCfg, activate_contact_sensors, apply_articulation_root_properties, - create_fixed_root_joint, apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, @@ -377,6 +376,7 @@ from .spawners import ( spawn_sphere, ) from .utils import ( + create_fixed_root_joint, add_labels, add_reference_to_stage, add_usd_reference, diff --git a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi index bfc4463400a..a735bf638e1 100644 --- a/source/isaaclab/isaaclab/sim/schemas/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/schemas/__init__.pyi @@ -9,7 +9,6 @@ __all__ = [ "USD_MESH_COLLISION_CFGS", "activate_contact_sensors", "apply_articulation_root_properties", - "create_fixed_root_joint", "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", @@ -77,7 +76,6 @@ from .schemas import ( USD_MESH_COLLISION_CFGS, activate_contact_sensors, apply_articulation_root_properties, - create_fixed_root_joint, apply_collision_properties, apply_fixed_tendon_properties, apply_mass_properties, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index d85e68a91e5..c2e8413f365 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -378,60 +378,6 @@ def apply_articulation_root_properties( return True -def create_fixed_root_joint(articulation_prim, stage: Usd.Stage | None = None): - """Author a world<->prim ``UsdPhysics.FixedJoint`` that pins a prim at its current world pose. - - Backend-neutral USD authoring with no Kit/``omni`` dependency: it mirrors the fixed, world-anchored - joint produced by Kit's ``omni.physx`` ``createJoint`` helper -- ``body0`` is left empty (the world - frame), ``body1`` targets the prim, and the joint's world-side local frame is set to the prim's - current world transform so the constraint pins the body where it is (otherwise it would be pinned to - the world origin). The joint prim is created under the first writable (non-instanced) ancestor. - - Args: - articulation_prim: The prim to fix to the world frame. - stage: The stage the prim lives on. Defaults to None, in which case the current stage is used. - - Returns: - The created ``UsdPhysics.FixedJoint`` prim. - """ - from pxr import Gf, UsdGeom - - if stage is None: - stage = get_current_stage() - - # host the joint under the first writable ancestor (an instanced/prototype prim is not writable) - base_prim = articulation_prim - while base_prim != stage.GetPseudoRoot(): - if base_prim.IsInPrototype() or base_prim.IsInstanceProxy() or base_prim.IsInstanceable(): - base_prim = base_prim.GetParent() - else: - break - base_path = str(base_prim.GetPrimPath()) - if base_path == "/": - base_path = "" - # pick a unique child name so repeated calls do not collide - name = "FixedJoint" - if stage.GetPrimAtPath(f"{base_path}/{name}").IsValid(): - index = 0 - while stage.GetPrimAtPath(f"{base_path}/{name}{index}").IsValid(): - index += 1 - name = f"{name}{index}" - joint = UsdPhysics.FixedJoint.Define(stage, f"{base_path}/{name}") - - # body0 empty -> world; body1 -> the prim, pinned at its current world pose - world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() - joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) - joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) - joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) - joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0)) - joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0)) - # disable joint breaking (matches Kit's createJoint defaults; float32 max) - max_float = 3.40282347e38 - joint.CreateBreakForceAttr().Set(max_float) - joint.CreateBreakTorqueAttr().Set(max_float) - return joint.GetPrim() - - def define_articulation_root_properties( prim_path: str, cfg: schemas_cfg.ArticulationRootBaseCfg, stage: Usd.Stage | None = None ): diff --git a/source/isaaclab/isaaclab/sim/utils/__init__.pyi b/source/isaaclab/isaaclab/sim/utils/__init__.pyi index cfe39d0933e..2046f41a776 100644 --- a/source/isaaclab/isaaclab/sim/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/utils/__init__.pyi @@ -13,6 +13,7 @@ __all__ = [ "define_prim", "get_prim_type_name", "get_next_free_path", + "create_fixed_root_joint", "create_prim", "delete_prim", "make_uninstanceable", @@ -73,6 +74,7 @@ from .legacy import ( get_next_free_path, ) from .prims import ( + create_fixed_root_joint, create_prim, delete_prim, make_uninstanceable, diff --git a/source/isaaclab/isaaclab/sim/utils/prims.py b/source/isaaclab/isaaclab/sim/utils/prims.py index d0f0e8d9042..b52e5ea1500 100644 --- a/source/isaaclab/isaaclab/sim/utils/prims.py +++ b/source/isaaclab/isaaclab/sim/utils/prims.py @@ -186,6 +186,60 @@ def create_prim( return prim +def create_fixed_root_joint(articulation_prim, stage: Usd.Stage | None = None): + """Author a world<->prim ``UsdPhysics.FixedJoint`` that pins a prim at its current world pose. + + Backend-neutral USD authoring with no Kit/``omni`` dependency: it mirrors the fixed, world-anchored + joint produced by Kit's ``omni.physx`` ``createJoint`` helper -- ``body0`` is left empty (the world + frame), ``body1`` targets the prim, and the joint's world-side local frame is set to the prim's + current world transform so the constraint pins the body where it is (otherwise it would be pinned to + the world origin). The joint prim is created under the first writable (non-instanced) ancestor. + + Args: + articulation_prim: The prim to fix to the world frame. + stage: The stage the prim lives on. Defaults to None, in which case the current stage is used. + + Returns: + The created ``UsdPhysics.FixedJoint`` prim. + """ + from pxr import Gf, UsdGeom, UsdPhysics + + if stage is None: + stage = get_current_stage() + + # host the joint under the first writable ancestor (an instanced/prototype prim is not writable) + base_prim = articulation_prim + while base_prim != stage.GetPseudoRoot(): + if base_prim.IsInPrototype() or base_prim.IsInstanceProxy() or base_prim.IsInstanceable(): + base_prim = base_prim.GetParent() + else: + break + base_path = str(base_prim.GetPrimPath()) + if base_path == "/": + base_path = "" + # pick a unique child name so repeated calls do not collide + name = "FixedJoint" + if stage.GetPrimAtPath(f"{base_path}/{name}").IsValid(): + index = 0 + while stage.GetPrimAtPath(f"{base_path}/{name}{index}").IsValid(): + index += 1 + name = f"{name}{index}" + joint = UsdPhysics.FixedJoint.Define(stage, f"{base_path}/{name}") + + # body0 empty -> world; body1 -> the prim, pinned at its current world pose + world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() + joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) + joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) + joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) + joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0)) + joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0)) + # disable joint breaking (matches Kit's createJoint defaults; float32 max) + max_float = 3.40282347e38 + joint.CreateBreakForceAttr().Set(max_float) + joint.CreateBreakTorqueAttr().Set(max_float) + return joint.GetPrim() + + def delete_prim(prim_path: str | Sequence[str], stage: Usd.Stage | None = None) -> bool: """Removes the USD Prim and its descendants from the scene if able. diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 2e92548d50b..0516d474159 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -348,7 +348,7 @@ def test_apply_articulation_root_properties_honors_explicit_stage(): def test_create_fixed_root_joint_authors_world_anchored_joint(): """create_fixed_root_joint authors a world<->prim ``UsdPhysics.FixedJoint``: ``body1`` targets the prim, ``body0`` is left empty (the world frame), and the body1-side local frame is identity.""" - from isaaclab.sim.schemas import create_fixed_root_joint + from isaaclab.sim.utils import create_fixed_root_joint sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -370,7 +370,7 @@ def test_create_fixed_root_joint_authors_world_anchored_joint(): def test_create_fixed_root_joint_pins_prim_at_current_world_pose(): """The world-side local frame is set to the prim's current world transform, so the constraint pins the body where it is rather than teleporting it to the world origin.""" - from isaaclab.sim.schemas import create_fixed_root_joint + from isaaclab.sim.utils import create_fixed_root_joint sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -385,7 +385,7 @@ def test_create_fixed_root_joint_pins_prim_at_current_world_pose(): def test_create_fixed_root_joint_uses_unique_names(): """Repeated calls author distinct joint prims (no path collision).""" - from isaaclab.sim.schemas import create_fixed_root_joint + from isaaclab.sim.utils import create_fixed_root_joint sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -459,7 +459,7 @@ def test_fix_articulation_root_capability_inherited_by_manager_subclass(): class _BackendLike(PhysicsManager): @classmethod def fix_articulation_root(cls, articulation_prim, stage=None): - from isaaclab.sim.schemas import create_fixed_root_joint + from isaaclab.sim.utils import create_fixed_root_joint cls._require_rigid_body_root(articulation_prim) create_fixed_root_joint(articulation_prim, stage) @@ -566,5 +566,5 @@ def test_public_imports(): ArticulationRootFragment, SchemaFragment, apply_articulation_root_properties, - create_fixed_root_joint, ) + from isaaclab.sim.utils import create_fixed_root_joint # noqa: F401 diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 23034cf2fdd..3e0f43628ba 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -325,7 +325,7 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any Raises: NotImplementedError: When the root prim is not a rigid body. """ - from isaaclab.sim.schemas import create_fixed_root_joint + from isaaclab.sim.utils import create_fixed_root_joint cls._require_rigid_body_root(articulation_prim) create_fixed_root_joint(articulation_prim, stage) From 3b9cf1cebf09b520bdb75d818c0971440cdbf381 Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Thu, 2 Jul 2026 15:08:13 -0700 Subject: [PATCH 071/139] Migrate pre-authored root schemas on PhysX relocation Writing fragments after the PhysX root relocation keeps supplied fragments on the resulting root, but asset-authored schemas were left behind. URDF/MJCF-imported assets can ship with a backend root API (e.g. NewtonArticulationRootAPI) already authored on the root link; that schema composes PhysicsArticulationRootAPI, so removing only the directly applied anchor left the former root link a second articulation root, with its authored values stranded on the wrong prim. Pre-authored PhysxArticulationAPI solver attributes were likewise dropped instead of moving with the root as the legacy writer did. Migrate them in the PhysX relocation: any directly authored API schema that the USD schema registry reports as composing PhysicsArticulationRootAPI moves to the parent together with its authored attributes (backend-agnostic detection, no backend names), and the PhysxArticulationAPI companion migrates the same way. Regression tests verified to fail without the migration. Also harden the fragment writer: reject non-fragment list items with a clear TypeError instead of an AttributeError deep in dispatch, gate the define-fresh ArticulationRootAPI anchor on fragments actually being supplied so a topology-only call cannot stamp a root as a side effect (the flag is ignored with a warning when no root exists), and warn when a spawner-level fix_root_link is ignored because articulation_props is a legacy cfg that owns its own flag. --- .../isaaclab/isaaclab/sim/schemas/schemas.py | 77 ++++++++++----- .../isaaclab/sim/schemas/schemas_cfg.py | 2 +- .../sim/spawners/from_files/from_files.py | 6 ++ .../test/sim/test_articulation_fragments.py | 99 ++++++++++++++++++- .../isaaclab_physx/physics/physx_manager.py | 59 ++++++++++- 5 files changed, 212 insertions(+), 31 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index c2e8413f365..e16a5a875c4 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -318,12 +318,24 @@ def apply_articulation_root_properties( True if the properties were successfully set. Raises: + TypeError: When an item in ``fragments`` is not an + :class:`~isaaclab.sim.schemas.ArticulationRootFragment` (e.g. a legacy single cfg, which + goes through :func:`modify_articulation_root_properties` instead). ValueError: When the prim path is not valid. RuntimeError: When ``fix_root_link`` is True and a fixed joint must be created but there is no active :class:`~isaaclab.sim.SimulationContext` to resolve the physics backend from. NotImplementedError: Propagated from the backend capability when it cannot create the fixed joint (e.g. the resolved root prim is not a rigid body). """ + # validate early: a clear error beats an ``AttributeError`` deep inside fragment dispatch + fragments = list(fragments) + for frag in fragments: + if not isinstance(frag, schemas_cfg.ArticulationRootFragment): + raise TypeError( + "apply_articulation_root_properties: expected ArticulationRootFragment instances in" + f" 'fragments', got '{type(frag).__name__}'. Pass a legacy single cfg to" + " modify_articulation_root_properties instead." + ) if stage is None: stage = get_current_stage() # resolve the existing root (it may live on a child prim in USD assets); instance proxies can't be @@ -334,8 +346,10 @@ def apply_articulation_root_properties( stage, traverse_instance_prims=False, ) - # no existing root in the subtree: define one on the input prim - if articulation_prim is None: + # No existing root in the subtree: define one on the input prim -- but only when fragments are + # supplied (the anchor is presence-gated). A topology-only call (empty fragments, just + # ``fix_root_link``) must not stamp an articulation root as a side effect. + if articulation_prim is None and fragments: articulation_prim = stage.GetPrimAtPath(prim_path) UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) @@ -345,35 +359,44 @@ def apply_articulation_root_properties( # former child and leave two articulation roots. final_root = articulation_prim if fix_root_link is not None: - root_path = articulation_prim.GetPath().pathString - # honor an explicit stage so the lookup does not fall back to the global current stage - existing_fixed_joint_prim = find_global_fixed_joint_prim(root_path, stage=stage) - if existing_fixed_joint_prim is not None: - # a joint already exists: just enable/disable it in place (no relocation) - logger.info( - f"Found an existing fixed joint for the articulation: '{root_path}'. Setting it to: {fix_root_link}." + if articulation_prim is None: + logger.warning( + f"No articulation root found under '{prim_path}': ignoring fix_root_link={fix_root_link}." + " The flag only applies to articulations." ) - existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) - elif fix_root_link: - logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") - # Creating (and possibly relocating) the root is backend-specific. Delegate to the active - # backend's manager capability so core carries no backend logic; the capability returns the - # resulting root prim. - from isaaclab.sim import SimulationContext - - sim = SimulationContext.instance() - if sim is None: - raise RuntimeError( - f"Cannot fix the articulation root '{root_path}': no active SimulationContext to" - " resolve the physics backend from. A simulation must be running to fix a base." + else: + root_path = articulation_prim.GetPath().pathString + # honor an explicit stage so the lookup does not fall back to the global current stage + existing_fixed_joint_prim = find_global_fixed_joint_prim(root_path, stage=stage) + if existing_fixed_joint_prim is not None: + # a joint already exists: just enable/disable it in place (no relocation) + logger.info( + f"Found an existing fixed joint for the articulation: '{root_path}'. Setting it to:" + f" {fix_root_link}." ) - final_root = sim.physics_manager.fix_articulation_root(articulation_prim, stage) + existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) + elif fix_root_link: + logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") + # Creating (and possibly relocating) the root is backend-specific. Delegate to the + # active backend's manager capability so core carries no backend logic; the capability + # returns the resulting root prim. + from isaaclab.sim import SimulationContext + + sim = SimulationContext.instance() + if sim is None: + raise RuntimeError( + f"Cannot fix the articulation root '{root_path}': no active SimulationContext" + " to resolve the physics backend from. A simulation must be running to fix a" + " base." + ) + final_root = sim.physics_manager.fix_articulation_root(articulation_prim, stage) # dispatch each fragment to the resolved (possibly relocated) root via its own applier - final_root_path = final_root.GetPath().pathString - for cfg in fragments: - func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) - func(cfg, final_root_path, stage) + if fragments: + final_root_path = final_root.GetPath().pathString + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, final_root_path, stage) return True diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py index ab9f35998f1..797b907ed55 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas_cfg.py @@ -192,7 +192,7 @@ class ArticulationRootFragment(SchemaFragment): ``UsdPhysics.ArticulationRootAPI`` anchor is applied by the articulation-root family writer (:func:`~isaaclab.sim.schemas.apply_articulation_root_properties`) only when the ``articulation_props`` slot carries fragments (presence-gated, matching the legacy - :meth:`~isaaclab.sim.schemas.modify_articulation_root_properties` behaviour). + :func:`~isaaclab.sim.schemas.modify_articulation_root_properties` behaviour). """ pass 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 fb05a60e2f9..356d91a3053 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -381,6 +381,12 @@ def _spawn_from_usd_file( and not isinstance(articulation_props, (list, tuple)) and not isinstance(articulation_props, schemas.SchemaFragment) ): + if articulation_fix_root_link is not None: + logger.warning( + f"Ignoring the spawner-level 'fix_root_link={articulation_fix_root_link}' because" + " 'articulation_props' is a legacy cfg, which owns its own 'fix_root_link' field. Set" + " it on that cfg instead." + ) schemas.modify_articulation_root_properties(prim_path, articulation_props) else: articulation_frags = ( diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 0516d474159..31403ad387f 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -16,7 +16,7 @@ import pytest -from pxr import Gf, Usd, UsdGeom, UsdPhysics +from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext @@ -313,6 +313,103 @@ def test_physx_and_newton_fragments_fix_root_link_keeps_single_root(): assert not child.GetAttribute("newton:selfCollisionEnabled").HasAuthoredValue() +def test_physx_fix_root_link_migrates_preauthored_newton_root_api(): + """A pre-authored backend root API (as on URDF/MJCF-imported assets) moves with the root when + PhysX relocates it, together with its authored attributes. + + Regression: ``NewtonArticulationRootAPI`` composes ``PhysicsArticulationRootAPI``, so removing + only the directly applied anchor from the former root link leaves that prim an articulation root + through schema composition -- two roots -- with the ``newton:*`` values stranded on the wrong prim. + """ + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/UrdfBot") + child = _make_xform(stage, "/World/UrdfBot/base") + UsdPhysics.RigidBodyAPI.Apply(child) + # the asset ships with the Newton root API (and its attribute) already authored on the root link + child.AddAppliedSchema("NewtonArticulationRootAPI") + child.CreateAttribute("newton:selfCollisionEnabled", Sdf.ValueTypeNames.Bool).Set(True) + # precondition: the composed schema makes the prim an articulation root without a direct anchor + assert child.HasAPI(UsdPhysics.ArticulationRootAPI) + + apply_articulation_root_properties("/World/UrdfBot", [], stage, fix_root_link=True) + + parent = stage.GetPrimAtPath("/World/UrdfBot") + # exactly one articulation root remains, and it is the (relocated) parent + roots = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert len(roots) == 1 and roots[0] == parent + # the pre-authored backend schema and its value moved with the root + assert "NewtonArticulationRootAPI" in parent.GetAppliedSchemas() + assert parent.GetAttribute("newton:selfCollisionEnabled").Get() is True + # the former root link no longer carries the composed root API + assert not child.HasAPI(UsdPhysics.ArticulationRootAPI) + + +def test_physx_fix_root_link_migrates_preauthored_physx_attrs(): + """Asset-authored ``PhysxArticulationAPI`` attributes move to the relocated root (legacy parity): + the legacy writer copied ``physxArticulation:*`` values to the parent, so the fragment path must + not silently drop them on the former root link where the PhysX parser ignores them.""" + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/UsdBot") + child = _make_xform(stage, "/World/UsdBot/base") + UsdPhysics.RigidBodyAPI.Apply(child) + UsdPhysics.ArticulationRootAPI.Apply(child) + # the asset ships with PhysX articulation solver settings authored on the root link + child.AddAppliedSchema("PhysxArticulationAPI") + child.CreateAttribute("physxArticulation:solverVelocityIterationCount", Sdf.ValueTypeNames.Int).Set(4) + + apply_articulation_root_properties("/World/UsdBot", [], stage, fix_root_link=True) + + parent = stage.GetPrimAtPath("/World/UsdBot") + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert "PhysxArticulationAPI" in parent.GetAppliedSchemas() + assert parent.GetAttribute("physxArticulation:solverVelocityIterationCount").Get() == 4 + + +def test_apply_articulation_root_properties_rejects_non_fragment_items(): + """A list containing a non-fragment (e.g. a legacy single cfg) raises a clear ``TypeError`` -- + not an ``AttributeError`` deep inside fragment dispatch.""" + from isaaclab_physx.sim.schemas import PhysxArticulationRootPropertiesCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + _make_xform(stage, "/World/BadList") + with pytest.raises(TypeError, match="ArticulationRootFragment"): + apply_articulation_root_properties( + "/World/BadList", + [PhysxArticulationRootPropertiesCfg(solver_position_iteration_count=8)], + stage, + ) + + +def test_apply_articulation_root_properties_topology_only_does_not_stamp_root(): + """A topology-only call (empty fragments) on a prim with no articulation root anywhere must not + apply ``UsdPhysics.ArticulationRootAPI`` as a side effect -- the anchor is presence-gated on + fragments. The topology flag is ignored with a warning instead.""" + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = _make_xform(stage, "/World/NoRoot") + UsdPhysics.RigidBodyAPI.Apply(prim) + + apply_articulation_root_properties("/World/NoRoot", [], stage, fix_root_link=False) + + assert not prim.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not any(p.HasAPI(UsdPhysics.ArticulationRootAPI) for p in stage.Traverse()) + + def test_apply_articulation_root_properties_honors_explicit_stage(): """The writer authors to the explicitly supplied stage even when it is not the current stage -- every lookup (root resolution, fixed-joint search) is scoped to that stage.""" diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 3e0f43628ba..0889ccb286d 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -311,8 +311,15 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any it folds it into the maximal-coordinate tree. To work around that, this authors the world<->root fixed joint and then relocates the ``UsdPhysics.ArticulationRootAPI`` anchor from the root link to its parent prim, returning the parent as the new root. Articulation-root - fragments are applied by the caller *after* this call, so they land on the returned root; no - attribute copying is needed here. + fragments are applied by the caller *after* this call, so they land on the returned root. + + Asset-authored root schemas move with the root: any API schema directly authored on the former + root that composes ``PhysicsArticulationRootAPI`` (e.g. Newton's root API on URDF/MJCF-imported + assets) is migrated to the parent together with its authored attributes -- removing only the + directly applied anchor would leave such a schema behind and the former root would remain a + second articulation root through schema composition. The ``PhysxArticulationAPI`` companion + and its ``physxArticulation:*`` attributes are migrated the same way, matching the legacy + writer's relocation. Args: articulation_prim: The resolved articulation-root prim to fix to the world frame. @@ -333,8 +340,56 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any parent_prim = articulation_prim.GetParent() UsdPhysics.ArticulationRootAPI.Apply(parent_prim) articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) + cls._migrate_authored_root_schemas(articulation_prim, parent_prim) return parent_prim + @staticmethod + def _migrate_authored_root_schemas(old_root: Usd.Prim, new_root: Usd.Prim) -> None: + """Move asset-authored articulation-root API schemas from the former root to the new root. + + Detection is backend-agnostic: a directly authored API schema is migrated when the USD schema + registry reports that it composes ``PhysicsArticulationRootAPI`` (so e.g. Newton's root API is + recognised without naming it). The PhysX ``PhysxArticulationAPI`` companion is migrated too -- + it does not compose the root API but carries the root's solver attributes. For each migrated + schema, its authored attributes are copied to the new root (attribute values already authored + on the new root are not overwritten by schema defaults; only authored values are copied). + + Args: + old_root: The former articulation-root prim (the root link being fixed). + new_root: The prim taking over the articulation-root role (the parent). + """ + registry = Usd.SchemaRegistry() + new_root_applied = set(new_root.GetPrimTypeInfo().GetAppliedAPISchemas()) + for schema_name in list(old_root.GetPrimTypeInfo().GetAppliedAPISchemas()): + # multi-apply instances carry an ":instance" suffix; the registry keys on the base name + base_name = schema_name.split(":", 1)[0] + prim_def = registry.FindAppliedAPIPrimDefinition(base_name) + composes_root = prim_def is not None and "PhysicsArticulationRootAPI" in prim_def.GetAppliedAPISchemas() + if not composes_root and base_name != "PhysxArticulationAPI": + continue + # move the applied schema to the new root + old_root.RemoveAppliedSchema(schema_name) + if schema_name not in new_root_applied: + new_root.AddAppliedSchema(schema_name) + new_root_applied.add(schema_name) + # copy the schema's authored attributes (property names come from the schema definition; + # fall back to the namespace prefix when the schema is not registered) + if prim_def is not None: + prop_names = list(prim_def.GetPropertyNames()) + else: + prefix = "physxArticulation:" if base_name == "PhysxArticulationAPI" else None + prop_names = [ + a.GetName() for a in old_root.GetAttributes() if prefix and a.GetName().startswith(prefix) + ] + for prop_name in prop_names: + attr = old_root.GetAttribute(prop_name) + if not attr or not attr.HasAuthoredValue(): + continue + new_attr = new_root.GetAttribute(prop_name) + if not new_attr: + new_attr = new_root.CreateAttribute(prop_name, attr.GetTypeName()) + new_attr.Set(attr.Get()) + @classmethod def reset(cls, soft: bool = False) -> None: """Reset the physics simulation.""" From 30a394ec0fbf0490170b41a7c60a6cdf593eedf7 Mon Sep 17 00:00:00 2001 From: Richard Lei Date: Fri, 3 Jul 2026 15:19:54 +1200 Subject: [PATCH 072/139] Consolidate OVRTX tile-extraction kernels (#6331) --- ...tx-consolidate-tile-extraction-kernels.rst | 5 + .../isaaclab_ov/renderers/ovrtx_renderer.py | 118 ++++++--------- .../renderers/ovrtx_renderer_kernels.py | 140 ++++++----------- .../test/test_ovrtx_renderer_contract.py | 33 ++++ .../test/test_ovrtx_renderer_kernels.py | 141 +++++++++++++++--- 5 files changed, 247 insertions(+), 190 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst diff --git a/source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst b/source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst new file mode 100644 index 00000000000..af7a7e95b0a --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Consolidated the OVRTX tile-extraction Warp kernels into a single generic + :func:`~isaaclab_ov.renderers.ovrtx_renderer_kernels.extract_all_tiles_kernel`. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 38ae6b41a1f..61bf985646a 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -60,11 +60,7 @@ from .ovrtx_renderer_cfg import OVRTXRendererCfg from .ovrtx_renderer_kernels import ( create_camera_transforms_kernel, - extract_all_depth_tiles_kernel, - extract_all_rgb_float_tiles_kernel, - extract_all_rgb_half_tiles_kernel, - extract_all_rgba_tiles_kernel, - extract_all_uint32_tiles_kernel, + extract_all_tiles_kernel, generate_random_colors_from_ids_kernel, sync_newton_transforms_kernel, ) @@ -728,18 +724,47 @@ def _process_id_segmentation_render_var( if data_torch.dim() == 2: data_torch = data_torch.unsqueeze(-1) tiled_data = wp.from_torch(data_torch, dtype=wp.uint32) - wp.launch( - kernel=extract_all_uint32_tiles_kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_data, - output_buffers[buffer_key], - render_data.num_cols, - render_data.width, - render_data.height, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_data, output_buffers[buffer_key]) + + def _launch_extract_all_tiles( + self, render_data: OVRTXRenderData, tiled_buffer: wp.array, output_buffer: wp.array + ) -> None: + """Launch ``extract_all_tiles_kernel`` for one tiled/output buffer pair. + + This is the only place that should launch ``extract_all_tiles_kernel``: it validates that + ``output_buffer`` cannot read past the end of ``tiled_buffer`` (the kernel derives its per-thread + channel loop bound from ``output_buffer``'s last dimension) before every launch, so callers cannot + accidentally skip the check. + + Args: + render_data: OVRTX render data for the current frame. + tiled_buffer: 3D array of shape (H, W, C) holding all tiles packed into one buffer. + output_buffer: 4D array of shape (num_envs, H, W, C) to receive the per-env tiles, with C no + greater than ``tiled_buffer``'s channel count. + + Raises: + ValueError: If ``output_buffer``'s channel count exceeds ``tiled_buffer``'s. + """ + tiled_channels = tiled_buffer.shape[-1] + output_channels = output_buffer.shape[-1] + if output_channels > tiled_channels: + raise ValueError( + f"Output buffer has {output_channels} channels but the tiled buffer only has {tiled_channels};" + " extract_all_tiles_kernel would read out of bounds." + ) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(render_data.num_envs, render_data.height, render_data.width), + inputs=[ + tiled_buffer, + output_buffer, + render_data.num_cols, + render_data.width, + render_data.height, + ], + device=self._device, + ) def _extract_rgba_tiles( self, @@ -755,19 +780,7 @@ def _extract_rgba_tiles( if num_channels not in (3, 4): raise ValueError(f"Expected RGB (3 channels) or RGBA (4 channels), got {num_channels}") - wp.launch( - kernel=extract_all_rgba_tiles_kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_data, - output_buffer, - render_data.num_cols, - render_data.width, - render_data.height, - num_channels, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_data, output_buffer) def _extract_depth_tiles( self, render_data: OVRTXRenderData, tiled_depth_data: wp.array, output_buffers: dict @@ -775,18 +788,7 @@ def _extract_depth_tiles( """Extract per-env depth tiles into output_buffers (single kernel launch).""" for depth_type in ["depth", "distance_to_image_plane", "distance_to_camera"]: if depth_type in output_buffers: - wp.launch( - kernel=extract_all_depth_tiles_kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_depth_data, - output_buffers[depth_type], - render_data.num_cols, - render_data.width, - render_data.height, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_depth_data, output_buffers[depth_type]) def _extract_hdr_color_tiles( self, render_data: OVRTXRenderData, tiled_data: wp.array, output_buffers: dict @@ -794,24 +796,9 @@ def _extract_hdr_color_tiles( """Extract per-env HdrColor tiles into output_buffers.""" if "rgb_hdr" not in output_buffers: return - if tiled_data.dtype == wp.float16: - kernel = extract_all_rgb_half_tiles_kernel - elif tiled_data.dtype == wp.float32: - kernel = extract_all_rgb_float_tiles_kernel - else: + if tiled_data.dtype not in (wp.float16, wp.float32): raise TypeError(f"Unsupported OVRTX HdrColor dtype: {tiled_data.dtype}.") - wp.launch( - kernel=kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_data, - output_buffers["rgb_hdr"], - render_data.num_cols, - render_data.width, - render_data.height, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_data, output_buffers["rgb_hdr"]) def _prepare_ppisp_hdr_source( self, render_data: OVRTXRenderData, tiled_data: wp.array, output_buffers: dict @@ -905,18 +892,7 @@ def _process_render_frame(self, render_data: OVRTXRenderData, frame, output_buff if "NormalSD" in frame.render_vars and "normals" in output_buffers: with frame.render_vars["NormalSD"].map(device=Device.CUDA) as mapping: tiled_normals_data = wp.from_dlpack(mapping.tensor) - wp.launch( - kernel=extract_all_rgb_float_tiles_kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_normals_data, - output_buffers["normals"], - render_data.num_cols, - render_data.width, - render_data.height, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_normals_data, output_buffers["normals"]) def render(self, render_data: OVRTXRenderData) -> None: """Render the scene into the provided RenderData.""" 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 e7f56eff04e..15037dd5d7c 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py @@ -5,6 +5,8 @@ """Warp kernels for OVRTX rendering pipeline.""" +from typing import Any + import warp as wp @@ -72,92 +74,32 @@ def extract_tile_from_tiled_buffer_kernel( @wp.kernel -def extract_all_rgba_tiles_kernel( - tiled_buffer: wp.array(dtype=wp.uint8, ndim=3), # type: ignore - output_buffer: wp.array(dtype=wp.uint8, ndim=4), # type: ignore - num_cols: int, - tile_width: int, - tile_height: int, - num_channels: int, -): - """Extract ALL RGBA or RGB tiles from a tiled buffer in a single kernel launch. - - Args: - tiled_buffer: 3D uint8 array of shape (H, W, 4) for RGBA or (H, W, 3) for RGB. - output_buffer: 4D uint8 array of shape (num_envs, H, W, 4) for RGBA or (num_envs, H, W, 3) for RGB. - num_cols: number of columns in the tiled buffer. - tile_width: width of each tile. - tile_height: height of each tile. - num_channels: number of channels in the output buffer. Use 3 for RGB or 4 for RGBA. - If a value other than 3 or 4 is given, it will be treated as 3 (RGB). - """ - env_idx, y, x = wp.tid() - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - src_x = tile_x * tile_width + x - src_y = tile_y * tile_height + y - - # RGB - output_buffer[env_idx, y, x, 0] = tiled_buffer[src_y, src_x, 0] - output_buffer[env_idx, y, x, 1] = tiled_buffer[src_y, src_x, 1] - output_buffer[env_idx, y, x, 2] = tiled_buffer[src_y, src_x, 2] - - # Alpha (if it is RGBA) - if num_channels == 4: - output_buffer[env_idx, y, x, 3] = tiled_buffer[src_y, src_x, 3] - - -@wp.kernel -def extract_all_rgb_float_tiles_kernel( - tiled_buffer: wp.array(dtype=wp.float32, ndim=3), # type: ignore - output_buffer: wp.array(dtype=wp.float32, ndim=4), # type: ignore (num_envs, H, W, 3) - num_cols: int, - tile_width: int, - tile_height: int, -): - """Extract ALL RGB float tiles from a tiled buffer in a single kernel launch.""" - env_idx, y, x = wp.tid() - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - src_x = tile_x * tile_width + x - src_y = tile_y * tile_height + y - output_buffer[env_idx, y, x, 0] = tiled_buffer[src_y, src_x, 0] - output_buffer[env_idx, y, x, 1] = tiled_buffer[src_y, src_x, 1] - output_buffer[env_idx, y, x, 2] = tiled_buffer[src_y, src_x, 2] - - -@wp.kernel -def extract_all_rgb_half_tiles_kernel( - tiled_buffer: wp.array(dtype=wp.float16, ndim=3), # type: ignore - output_buffer: wp.array(dtype=wp.float32, ndim=4), # type: ignore (num_envs, H, W, 3) +def extract_all_tiles_kernel( + tiled_buffer: wp.array(dtype=Any, ndim=3), # type: ignore + output_buffer: wp.array(dtype=Any, ndim=4), # type: ignore num_cols: int, tile_width: int, tile_height: int, ): - """Extract ALL RGB half tiles into float32 output in a single kernel launch.""" - env_idx, y, x = wp.tid() - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - src_x = tile_x * tile_width + x - src_y = tile_y * tile_height + y - output_buffer[env_idx, y, x, 0] = wp.float32(tiled_buffer[src_y, src_x, 0]) - output_buffer[env_idx, y, x, 1] = wp.float32(tiled_buffer[src_y, src_x, 1]) - output_buffer[env_idx, y, x, 2] = wp.float32(tiled_buffer[src_y, src_x, 2]) + """Extract ALL tiles from a tiled buffer into per-env tiles in a single kernel launch. + Generic over the tile channel layout (RGB, RGBA, or single-channel depth) and dtype (e.g. uint8 color, + float32 depth/HDR color, or float16 HDR color widened to float32 on output). The channel count is taken + from ``output_buffer`` rather than passed explicitly, and each element is cast to the output dtype, so the + same kernel body serves every tile-extraction case; see the :func:`warp.overload` registrations below for + the concrete dtype pairs this is compiled for. -@wp.kernel -def extract_all_depth_tiles_kernel( - tiled_buffer: wp.array(dtype=wp.float32, ndim=3), # type: ignore - output_buffer: wp.array(dtype=wp.float32, ndim=4), # type: ignore - num_cols: int, - tile_width: int, - tile_height: int, -): - """Extract all depth tiles from a tiled buffer in a single kernel launch. + Precondition: + ``output_buffer``'s channel count (last dimension) must not exceed ``tiled_buffer``'s (the per-thread + channel loop below indexes ``tiled_buffer`` up to that bound). In this package, always launch this + kernel through ``OVRTXRenderer._launch_extract_all_tiles``, which validates this before every launch + instead of relying on each call site to remember to check. Passing a wider output buffer than the + tiled input reads out of bounds on the GPU. Args: - tiled_buffer: 3D float32 array of shape (H, W, 1) for depth. - output_buffer: 4D float32 array of shape (num_envs, H, W, 1) for depth. + tiled_buffer: 3D array of shape (H, W, C) holding all tiles packed into one buffer. + output_buffer: 4D array of shape (num_envs, H, W, C) to receive the per-env tiles, with C no greater + than ``tiled_buffer``'s channel count. num_cols: number of columns in the tiled buffer. tile_width: width of each tile. tile_height: height of each tile. @@ -167,7 +109,30 @@ def extract_all_depth_tiles_kernel( tile_y = env_idx // num_cols src_x = tile_x * tile_width + x src_y = tile_y * tile_height + y - output_buffer[env_idx, y, x, 0] = tiled_buffer[src_y, src_x, 0] + for channel in range(output_buffer.shape[3]): + output_buffer[env_idx, y, x, channel] = output_buffer.dtype(tiled_buffer[src_y, src_x, channel]) + + +# uint8 color tiles (e.g. RGB/RGBA, semantic segmentation). +wp.overload( + extract_all_tiles_kernel, + [wp.array(dtype=wp.uint8, ndim=3), wp.array(dtype=wp.uint8, ndim=4), int, int, int], +) +# float32 tiles (e.g. depth, normals, HDR color). +wp.overload( + extract_all_tiles_kernel, + [wp.array(dtype=wp.float32, ndim=3), wp.array(dtype=wp.float32, ndim=4), int, int, int], +) +# float16 tiles (e.g. HDR color), widened to a float32 output buffer. +wp.overload( + extract_all_tiles_kernel, + [wp.array(dtype=wp.float16, ndim=3), wp.array(dtype=wp.float32, ndim=4), int, int, int], +) +# uint32 tiles (e.g. raw instance segmentation IDs). +wp.overload( + extract_all_tiles_kernel, + [wp.array(dtype=wp.uint32, ndim=3), wp.array(dtype=wp.uint32, ndim=4), int, int, int], +) @wp.kernel @@ -294,23 +259,6 @@ def random_color_from_id(input_id: wp.uint32) -> wp.uint32: return color -@wp.kernel -def extract_all_uint32_tiles_kernel( - tiled_buffer: wp.array(dtype=wp.uint32, ndim=3), # type: ignore (TH, TW, 1) - output_buffer: wp.array(dtype=wp.uint32, ndim=4), # type: ignore (num_envs, H, W, 1) - num_cols: int, - tile_width: int, - tile_height: int, -): - """Extract all single-channel uint32 tiles (e.g. raw instance segmentation IDs).""" - env_idx, y, x = wp.tid() - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - src_x = tile_x * tile_width + x - src_y = tile_y * tile_height + y - output_buffer[env_idx, y, x, 0] = tiled_buffer[src_y, src_x, 0] - - @wp.kernel def generate_random_colors_from_ids_kernel( input_ids: wp.array(dtype=wp.uint32, ndim=3), # type: ignore diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index 0c918f76ed3..3520a78bea3 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -210,6 +210,39 @@ def fake_clone(src, *, device): assert clone_calls == [(source, "cuda:0")] +class _FakeArray: + def __init__(self, shape): + self.shape = shape + + +def test_launch_extract_all_tiles_rejects_wider_output_channels(): + """An output wider than the tiled input would read out of bounds, so it must raise before launching.""" + renderer = _make_ovrtx_renderer_without_backend() + renderer._device = "cpu" + render_data = _make_ovrtx_render_data() + + with pytest.raises(ValueError, match="out of bounds"): + renderer._launch_extract_all_tiles(render_data, _FakeArray((8, 16, 3)), _FakeArray((2, 8, 16, 4))) + + +def test_launch_extract_all_tiles_launches_kernel_when_channels_are_compatible(monkeypatch): + """Equal or narrower output channel counts pass validation and reach the kernel launch.""" + renderer = _make_ovrtx_renderer_without_backend() + renderer._device = "cpu" + render_data = _make_ovrtx_render_data() + render_data.num_cols = 2 + + launch_calls = [] + monkeypatch.setattr(wp, "launch", lambda **kwargs: launch_calls.append(kwargs)) + + tiled_buffer = _FakeArray((8, 16, 4)) + output_buffer = _FakeArray((2, 8, 16, 3)) + renderer._launch_extract_all_tiles(render_data, tiled_buffer, output_buffer) + + assert len(launch_calls) == 1 + assert launch_calls[0]["inputs"][:2] == [tiled_buffer, output_buffer] + + def test_ovrtx_read_output_is_a_no_op_after_consolidation(): """OVRTXRenderer.read_output is a no-op once set_outputs wires up zero-copy.""" renderer = _make_ovrtx_renderer_without_backend() diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py index 301b7efa0c1..27099d25cd7 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py @@ -11,10 +11,7 @@ import pytest import warp as wp from isaaclab_ov.renderers.ovrtx_renderer_kernels import ( - extract_all_depth_tiles_kernel, - extract_all_rgb_float_tiles_kernel, - extract_all_rgb_half_tiles_kernel, - extract_all_rgba_tiles_kernel, + extract_all_tiles_kernel, generate_random_colors_from_ids_kernel, ) @@ -93,6 +90,26 @@ def _reference_extract_all_depth_tiles( return out +def _reference_extract_all_uint32_tiles( + tiled_np: np.ndarray, + num_envs: int, + num_cols: int, + tile_width: int, + tile_height: int, +) -> np.ndarray: + """NumPy reference for the uint32 (e.g. raw instance segmentation ID) case of ``extract_all_tiles_kernel``.""" + out = np.zeros((num_envs, tile_height, tile_width, 1), dtype=np.uint32) + for env_idx in range(num_envs): + tile_x = env_idx % num_cols + tile_y = env_idx // num_cols + for y in range(tile_height): + for x in range(tile_width): + src_y = tile_y * tile_height + y + src_x = tile_x * tile_width + x + out[env_idx, y, x, 0] = tiled_np[src_y, src_x, 0] + return out + + def _reference_extract_all_rgba_tiles( tiled_np: np.ndarray, num_envs: int, @@ -142,7 +159,7 @@ def _reference_extract_all_rgb_float_tiles( class TestExtractAllDepthTilesKernel: - """Tests for ``extract_all_depth_tiles_kernel``.""" + """Tests for the depth case of ``extract_all_tiles_kernel``.""" def test_two_by_two_tile_grid(self): num_cols = 2 @@ -160,7 +177,7 @@ def test_two_by_two_tile_grid(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.float32, device=DEVICE) wp.launch( - kernel=extract_all_depth_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, @@ -181,7 +198,7 @@ def test_single_tile(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.float32, device=DEVICE) wp.launch( - kernel=extract_all_depth_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, @@ -210,7 +227,7 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.float32, device=DEVICE) wp.launch( - kernel=extract_all_depth_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, @@ -221,8 +238,86 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height): np.testing.assert_allclose(output_wp.numpy(), expected, rtol=1e-6, atol=1e-6) +class TestExtractAllUint32TilesKernel: + """Tests for the uint32 (e.g. raw instance segmentation ID) case of ``extract_all_tiles_kernel``.""" + + def test_two_by_two_tile_grid(self): + num_cols = 2 + num_envs = 4 + tile_width = 2 + tile_height = 3 + tiled_h = (num_envs // num_cols) * tile_height + tiled_w = num_cols * tile_width + rng = np.random.default_rng(98765) + tiled_np = rng.integers(0, 2**31, size=(tiled_h, tiled_w, 1), dtype=np.uint32) + + tiled_wp = wp.array(tiled_np, dtype=wp.uint32, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.uint32, device=DEVICE) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_uint32_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_array_equal(output_wp.numpy(), expected) + + def test_single_tile(self): + num_cols = 1 + num_envs = 1 + tile_width = 4 + tile_height = 4 + tiled_np = np.arange(tile_height * tile_width, dtype=np.uint32).reshape(tile_height, tile_width, 1) + + tiled_wp = wp.array(tiled_np, dtype=wp.uint32, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.uint32, device=DEVICE) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_uint32_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_array_equal(output_wp.numpy(), expected) + + @pytest.mark.parametrize( + ("num_cols", "num_envs", "tile_width", "tile_height"), + [ + (3, 6, 2, 2), + (1, 3, 5, 1), + (4, 8, 1, 1), + ], + ) + def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height): + num_rows = (num_envs + num_cols - 1) // num_cols + tiled_h = num_rows * tile_height + tiled_w = num_cols * tile_width + rng = np.random.default_rng(13579) + tiled_np = rng.integers(0, 2**31, size=(tiled_h, tiled_w, 1), dtype=np.uint32) + + tiled_wp = wp.array(tiled_np, dtype=wp.uint32, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.uint32, device=DEVICE) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_uint32_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_array_equal(output_wp.numpy(), expected) + + class TestExtractAllRgbaTilesKernel: - """Tests for ``extract_all_rgba_tiles_kernel``.""" + """Tests for the RGB/RGBA case of ``extract_all_tiles_kernel``.""" def test_two_by_two_tile_grid_rgba(self): num_cols = 2 @@ -244,9 +339,9 @@ def test_two_by_two_tile_grid_rgba(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, num_channels), dtype=wp.uint8, device=DEVICE) wp.launch( - kernel=extract_all_rgba_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, num_channels], + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, ) wp.synchronize() @@ -268,9 +363,9 @@ def test_single_tile_rgb(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, num_channels), dtype=wp.uint8, device=DEVICE) wp.launch( - kernel=extract_all_rgba_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, num_channels], + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, ) wp.synchronize() @@ -280,8 +375,8 @@ def test_single_tile_rgb(self): ) np.testing.assert_array_equal(output_wp.numpy(), expected) - def test_num_channels_not_four_skips_alpha(self): - """Values other than 4 use the RGB-only path (same as RGB tiled input).""" + def test_three_channel_output_skips_alpha(self): + """A 3-channel output buffer only copies RGB, even if the tiled input has an alpha channel.""" num_cols = 1 num_envs = 1 tile_width = 2 @@ -298,14 +393,14 @@ def test_num_channels_not_four_skips_alpha(self): output_wp = wp.zeros(shape=(1, 2, 2, 3), dtype=wp.uint8, device=DEVICE) wp.launch( - kernel=extract_all_rgba_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(1, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, 2], + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, ) wp.synchronize() - expected = _reference_extract_all_rgba_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height, 2) + expected = _reference_extract_all_rgba_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height, 3) np.testing.assert_array_equal(output_wp.numpy(), expected) @pytest.mark.parametrize( @@ -333,9 +428,9 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height, num_ ) wp.launch( - kernel=extract_all_rgba_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, num_channels], + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, ) wp.synchronize() @@ -347,7 +442,7 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height, num_ class TestExtractAllRgbFloatTilesKernel: - """Tests for ``extract_all_rgb_float_tiles_kernel`` used by OVRTX HdrColor.""" + """Tests for the HdrColor (float32/float16) case of ``extract_all_tiles_kernel``.""" def test_two_by_two_tile_grid(self): num_cols = 2 @@ -367,7 +462,7 @@ def test_two_by_two_tile_grid(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 3), dtype=wp.float32, device=DEVICE) wp.launch( - kernel=extract_all_rgb_float_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, @@ -395,7 +490,7 @@ def test_half_input_writes_float_output(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 3), dtype=wp.float32, device=DEVICE) wp.launch( - kernel=extract_all_rgb_half_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, From 0ede6da7d1e093a2aefbc754653056a5ccd50869 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:15:41 +0000 Subject: [PATCH 073/139] [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: 8.0.2 → 8.0.3 - isaaclab_ov: 0.5.3 → 0.5.4 - isaaclab_ovphysx: 5.0.1 → 6.0.0 - isaaclab_rl: 0.6.2 → 0.7.0 - isaaclab_tasks: 8.1.2 → 8.1.3 --- .../changelog.d/checkpoint-selectors.rst | 4 --- .../zhengyuz-fix-depth-camera-viewport.rst | 5 ---- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 15 +++++++++++ source/isaaclab/pyproject.toml | 2 +- ...tx-consolidate-tile-extraction-kernels.rst | 5 ---- .../ovrtx-instance-segmentation-fast.rst | 13 ---------- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 24 ++++++++++++++++++ source/isaaclab_ov/pyproject.toml | 2 +- ...physx_rigidobjectcollection_view.major.rst | 9 ------- .../ovphysx-current-runtime-compat.rst | 7 ------ source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 22 ++++++++++++++++ source/isaaclab_ovphysx/pyproject.toml | 2 +- ...depth-camera-viewport-color-rendering.skip | 1 - .../changelog.d/mh-rslrl_bump.minor.rst | 14 ----------- source/isaaclab_rl/config/extension.toml | 2 +- source/isaaclab_rl/docs/CHANGELOG.rst | 19 ++++++++++++++ source/isaaclab_rl/pyproject.toml | 2 +- .../cartpole-state-mdp-alignment.rst | 14 ----------- .../ovrtx-instance-segmentation-fast.rst | 9 ------- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 25 +++++++++++++++++++ source/isaaclab_tasks/pyproject.toml | 2 +- 25 files changed, 115 insertions(+), 91 deletions(-) delete mode 100644 source/isaaclab/changelog.d/checkpoint-selectors.rst delete mode 100644 source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst delete mode 100644 source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst delete mode 100644 source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst delete mode 100644 source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip delete mode 100644 source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst delete mode 100644 source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst diff --git a/source/isaaclab/changelog.d/checkpoint-selectors.rst b/source/isaaclab/changelog.d/checkpoint-selectors.rst deleted file mode 100644 index 5313cdb8e7d..00000000000 --- a/source/isaaclab/changelog.d/checkpoint-selectors.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* Added ``latest`` and ``best`` checkpoint selectors to unified reinforcement learning training and play commands. diff --git a/source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst b/source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst deleted file mode 100644 index 9fc0987aa4a..00000000000 --- a/source/isaaclab/changelog.d/zhengyuz-fix-depth-camera-viewport.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed depth-only RTX cameras disabling color rendering in the viewport by restoring GUI-state publication from - :class:`~isaaclab.app.AppLauncher`. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index f333a9b1cdc..3617610fce2 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 = "8.0.2" +version = "8.0.3" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 0ec31d8f79d..48f84fcad40 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,21 @@ Changelog --------- +8.0.3 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``latest`` and ``best`` checkpoint selectors to unified reinforcement learning training and play commands. + +Fixed +^^^^^ + +* Fixed depth-only RTX cameras disabling color rendering in the viewport by restoring GUI-state publication from + :class:`~isaaclab.app.AppLauncher`. + + 8.0.2 (2026-07-02) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 989aedb9437..3d1b04ea4ab 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.0.2" +version = "8.0.3" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst b/source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst deleted file mode 100644 index af7a7e95b0a..00000000000 --- a/source/isaaclab_ov/changelog.d/ovrtx-consolidate-tile-extraction-kernels.rst +++ /dev/null @@ -1,5 +0,0 @@ -Changed -^^^^^^^ - -* Consolidated the OVRTX tile-extraction Warp kernels into a single generic - :func:`~isaaclab_ov.renderers.ovrtx_renderer_kernels.extract_all_tiles_kernel`. diff --git a/source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst b/source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst deleted file mode 100644 index 7a2d05faa43..00000000000 --- a/source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst +++ /dev/null @@ -1,13 +0,0 @@ -Added -^^^^^ - -* Added :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` and - :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` config fields - to :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`. -* Added support for the ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` - data types in the OVRTX renderer, via the ``NonStableInstanceSegmentation`` and - ``InstanceSegmentationSD`` AOVs respectively. When the corresponding - :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` / - :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` flag is - ``True`` (default), instance IDs are colorized and returned as ``uint8`` RGBA; when ``False``, - raw ``uint32`` instance IDs are returned. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 5acd5995397..316e8a798f9 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.5.3" +version = "0.5.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 29a6576b951..a288ea2cd35 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,30 @@ Changelog --------- +0.5.4 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` and + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` config fields + to :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`. +* Added support for the ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` + data types in the OVRTX renderer, via the ``NonStableInstanceSegmentation`` and + ``InstanceSegmentationSD`` AOVs respectively. When the corresponding + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` / + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` flag is + ``True`` (default), instance IDs are colorized and returned as ``uint8`` RGBA; when ``False``, + raw ``uint32`` instance IDs are returned. + +Changed +^^^^^^^ + +* Consolidated the OVRTX tile-extraction Warp kernels into a single generic + :func:`~isaaclab_ov.renderers.ovrtx_renderer_kernels.extract_all_tiles_kernel`. + + 0.5.3 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/pyproject.toml b/source/isaaclab_ov/pyproject.toml index 022cc51d789..ecbefa31d22 100644 --- a/source/isaaclab_ov/pyproject.toml +++ b/source/isaaclab_ov/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ov" -version = "0.5.3" +version = "0.5.4" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst deleted file mode 100644 index 9f467944463..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst +++ /dev/null @@ -1,9 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** :attr:`~isaaclab_ovphysx.assets.RigidObjectCollection.root_view` now returns an - :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw ``dict`` - mapping ``TensorType`` to ``TensorBinding``. The view wraps the fused multi-prim bindings - (``prim_paths=[...]``) and stores each under the collection's ``LINK_*``/``BODY_*`` data-class - key via ``key_aliases`` (mapped from the underlying ``RIGID_BODY_*`` type). Replace - ``root_view[tensor_type]`` with ``root_view.try_binding_for(tensor_type)``. diff --git a/source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst b/source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst deleted file mode 100644 index 35fe74f5978..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Updated :class:`~isaaclab_ovphysx.physics.OvPhysxManager` for the current - OVPhysX constructor, stage-reset, and synchronous-step APIs. -* Synchronized GPU-to-host property staging before OVPhysX consumes the pinned - host buffers. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index a3253f6b5fc..9c879827eb1 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 = "5.0.1" +version = "6.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 9b8010163d1..aed24188cee 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,28 @@ Changelog --------- +6.0.0 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* **Breaking:** :attr:`~isaaclab_ovphysx.assets.RigidObjectCollection.root_view` now returns an + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw ``dict`` + mapping ``TensorType`` to ``TensorBinding``. The view wraps the fused multi-prim bindings + (``prim_paths=[...]``) and stores each under the collection's ``LINK_*``/``BODY_*`` data-class + key via ``key_aliases`` (mapped from the underlying ``RIGID_BODY_*`` type). Replace + ``root_view[tensor_type]`` with ``root_view.try_binding_for(tensor_type)``. + +Fixed +^^^^^ + +* Updated :class:`~isaaclab_ovphysx.physics.OvPhysxManager` for the current + OVPhysX constructor, stage-reset, and synchronous-step APIs. +* Synchronized GPU-to-host property staging before OVPhysX consumes the pinned + host buffers. + + 5.0.1 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index e39a2a01bd9..3215f5c0dd8 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ovphysx" -version = "5.0.1" +version = "6.0.0" description = "Extension providing IsaacLab with ovphysx/TensorBindingsAPI specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip b/source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip deleted file mode 100644 index 7c371952533..00000000000 --- a/source/isaaclab_physx/changelog.d/fix-depth-camera-viewport-color-rendering.skip +++ /dev/null @@ -1 +0,0 @@ -Test-only: added regression coverage for GUI and headless depth-only color-render behavior. diff --git a/source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst b/source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst deleted file mode 100644 index 7411f890bac..00000000000 --- a/source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst +++ /dev/null @@ -1,14 +0,0 @@ -Changed -^^^^^^^ - -* Bumped the ``rsl-rl-lib`` dependency to ``5.4.1``, which natively supports image-only policies - (observation sets with no 1D groups). -* Changed :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name` to default to rsl-rl's - ``CNNModel`` now that it supports image-only observations out of the box. - -Removed -^^^^^^^ - -* Removed the Isaac Lab ``CNNModel`` override of rsl-rl's ``CNNModel`` that previously added - image-only observation support. Use rsl-rl's ``CNNModel`` (the new default of - :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name`) instead. diff --git a/source/isaaclab_rl/config/extension.toml b/source/isaaclab_rl/config/extension.toml index 01fd9d01f40..5ce250dea3e 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.6.2" +version = "0.7.0" # Description title = "Isaac Lab RL" diff --git a/source/isaaclab_rl/docs/CHANGELOG.rst b/source/isaaclab_rl/docs/CHANGELOG.rst index b27c41f5294..8598ede916e 100644 --- a/source/isaaclab_rl/docs/CHANGELOG.rst +++ b/source/isaaclab_rl/docs/CHANGELOG.rst @@ -1,6 +1,25 @@ Changelog --------- +0.7.0 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Bumped the ``rsl-rl-lib`` dependency to ``5.4.1``, which natively supports image-only policies + (observation sets with no 1D groups). +* Changed :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name` to default to rsl-rl's + ``CNNModel`` now that it supports image-only observations out of the box. + +Removed +^^^^^^^ + +* Removed the Isaac Lab ``CNNModel`` override of rsl-rl's ``CNNModel`` that previously added + image-only observation support. Use rsl-rl's ``CNNModel`` (the new default of + :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name`) instead. + + 0.6.2 (2026-06-28) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_rl/pyproject.toml b/source/isaaclab_rl/pyproject.toml index 9fc5f43ba20..ae351b10958 100644 --- a/source/isaaclab_rl/pyproject.toml +++ b/source/isaaclab_rl/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_rl" -version = "0.6.2" +version = "0.7.0" description = "Extension containing reinforcement learning related utilities." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst b/source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst deleted file mode 100644 index 390b96365a9..00000000000 --- a/source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst +++ /dev/null @@ -1,14 +0,0 @@ -Added -^^^^^ - -* Added the ``ovphysx`` physics preset to the manager-based Cartpole tasks. - -Changed -^^^^^^^ - -* **Breaking:** Aligned the direct and manager-based Cartpole MDPs, including state observations, reset distributions, - termination conditions, episode horizon, reward convention, camera frame stacking, and camera lighting. Retrain - policies previously trained on the Cartpole tasks. - -* Changed all Cartpole camera renderer variants to stack two frames by default, and reduced the RSL-RL camera CNN - size while retaining reliable convergence. diff --git a/source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst b/source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst deleted file mode 100644 index 7dba962376b..00000000000 --- a/source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst +++ /dev/null @@ -1,9 +0,0 @@ -Added -^^^^^ - -* Added ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` rendering test - presets to the rendering test utilities for Cartpole, ShadowHand, and Dexsuite environments, - covering both the OVRTX and Isaac RTX renderers. Because instance IDs are non-stable across - runs, golden images are saved with full RGBA colors but comparison is restricted to the alpha - channel only, which encodes the instance mask shape reliably. SSIM is also disabled for these - data types; only the per-pixel L2 gate is used to decide pass/fail. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index ccae571210e..c93829e05c3 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 = "8.1.2" +version = "8.1.3" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index a101818c164..a22dd0a0c47 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,31 @@ Changelog --------- +8.1.3 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` rendering test + presets to the rendering test utilities for Cartpole, ShadowHand, and Dexsuite environments, + covering both the OVRTX and Isaac RTX renderers. Because instance IDs are non-stable across + runs, golden images are saved with full RGBA colors but comparison is restricted to the alpha + channel only, which encodes the instance mask shape reliably. SSIM is also disabled for these + data types; only the per-pixel L2 gate is used to decide pass/fail. +* Added the ``ovphysx`` physics preset to the manager-based Cartpole tasks. + +Changed +^^^^^^^ + +* **Breaking:** Aligned the direct and manager-based Cartpole MDPs, including state observations, reset distributions, + termination conditions, episode horizon, reward convention, camera frame stacking, and camera lighting. Retrain + policies previously trained on the Cartpole tasks. + +* Changed all Cartpole camera renderer variants to stack two frames by default, and reduced the RSL-RL camera CNN + size while retaining reliable convergence. + + 8.1.2 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index f09c5c86a8f..4db1f1d78a3 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.1.2" +version = "8.1.3" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 861d343e24c329804911acdea80c353a516ed92e Mon Sep 17 00:00:00 2001 From: Agon Serifi <39410867+aserifi@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:20:57 +0200 Subject: [PATCH 074/139] [Kamino] Core (#5962) ## DR Legs closed-loop hold-pose and walk task Depends on https://github.com/newton-physics/newton/pull/3220 Simplified many things leaving the pure DR Legs task here. - Adds the Disney DR Legs closed-loop biped (Kamino solver) and a `Isaac-DrLegs-HoldPose-v0` hold-pose task as well as `Isaac-DrLegs-Walk-v0` walking task. - Uses the `ArticulationView` - Fixes Kamino environment resets directly in the kamion_manager - DR Legs USD is loaded from newton-assets --------- Co-authored-by: Ruben Grandia Co-authored-by: Cursor --- CONTRIBUTORS.md | 1 + .../changelog.d/aserifi-drlegs.rst | 5 + .../isaaclab_assets/robots/dr_legs.py | 116 ++++++++ .../changelog.d/rgrandia-fix-kamino-reset.rst | 27 ++ .../assets/articulation/articulation_data.py | 11 +- .../cloner/newton_clone_utils.py | 1 + .../isaaclab_newton/physics/kamino_manager.py | 204 ++++++------- .../physics/kamino_manager_cfg.py | 46 ++- .../isaaclab_newton/physics/newton_manager.py | 84 +++++- .../test/cloner/test_rename_builder_labels.py | 1 + .../test_newton_manager_abstraction.py | 69 +++++ .../changelog.d/aserifi-drlegs.rst | 14 + .../contrib/dr_legs/__init__.py | 30 ++ .../contrib/dr_legs/agents/__init__.py | 4 + .../contrib/dr_legs/agents/rsl_rl_ppo_cfg.py | 50 ++++ .../contrib/dr_legs/hold_pose_env_cfg.py | 279 ++++++++++++++++++ .../contrib/dr_legs/mdp/__init__.py | 14 + .../contrib/dr_legs/mdp/__init__.pyi | 38 +++ .../contrib/dr_legs/mdp/observations.py | 35 +++ .../contrib/dr_legs/mdp/rewards.py | 224 ++++++++++++++ .../contrib/dr_legs/walk_env_cfg.py | 151 ++++++++++ .../velocity/config/a1/flat_env_cfg.py | 3 +- .../velocity/config/anymal_b/flat_env_cfg.py | 3 +- .../velocity/config/anymal_c/flat_env_cfg.py | 3 +- .../velocity/config/go1/flat_env_cfg.py | 3 +- .../core/cabinet/cabinet_env_cfg.py | 7 +- .../core/reach/reach_env_cfg.py | 9 +- .../config/shadow_hand/shadow_hand_env_cfg.py | 7 +- .../velocity/config/cassie/flat_env_cfg.py | 3 +- .../core/velocity/config/g1/flat_env_cfg.py | 3 +- .../core/velocity/config/go2/flat_env_cfg.py | 3 +- .../core/velocity/config/h1/flat_env_cfg.py | 3 +- .../core/velocity/config/spot/flat_env_cfg.py | 10 +- .../core/velocity/velocity_env_cfg.py | 1 + 34 files changed, 1322 insertions(+), 140 deletions(-) create mode 100644 source/isaaclab_assets/changelog.d/aserifi-drlegs.rst create mode 100644 source/isaaclab_assets/isaaclab_assets/robots/dr_legs.py create mode 100644 source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst create mode 100644 source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/rsl_rl_ppo_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.pyi create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/observations.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/rewards.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/walk_env_cfg.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1aa1b57cc4e..c70ed2d3faf 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -44,6 +44,7 @@ Guidelines for modifications: ## Contributors +* Agon Serifi * Alessandro Assirelli * Alex Omar * Alexander Millane diff --git a/source/isaaclab_assets/changelog.d/aserifi-drlegs.rst b/source/isaaclab_assets/changelog.d/aserifi-drlegs.rst new file mode 100644 index 00000000000..92397541b02 --- /dev/null +++ b/source/isaaclab_assets/changelog.d/aserifi-drlegs.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added :data:`~isaaclab_assets.robots.dr_legs.DR_LEGS_IMPLICIT_PD_CFG` for the Disney DR Legs + closed-loop biped. diff --git a/source/isaaclab_assets/isaaclab_assets/robots/dr_legs.py b/source/isaaclab_assets/isaaclab_assets/robots/dr_legs.py new file mode 100644 index 00000000000..5fd8d3acbe5 --- /dev/null +++ b/source/isaaclab_assets/isaaclab_assets/robots/dr_legs.py @@ -0,0 +1,116 @@ +# 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 Disney DR Legs closed-loop biped. + +DR Legs is a parallel-linkage bipedal lower body with 30 joints (12 actuated + 18 passive +linkage DOFs) plus 6 loop-closing joints. The Kamino solver enforces the loop-closing joints +as bilateral constraints in maximal coordinates. + +The following configuration is available: + +* :data:`DR_LEGS_IMPLICIT_PD_CFG`: DR Legs with implicit (solver-side) PD on the + 12 actuated joints and zero-PD on the 18 passive linkage joints. +""" + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets.articulation import ArticulationCfg +from isaaclab.utils.assets import NEWTON_ASSET_DIR, retrieve_git_asset_path + +_DR_LEGS_USD_PATH = retrieve_git_asset_path(NEWTON_ASSET_DIR, "disneyresearch/dr_legs/usd/dr_legs.usda") + +DR_LEGS_JOINT_ORDER: list[str] = [ + "j1_l_i", + "j2_l_i", + "j3_l_i", + "j4_l_i", + "j6_l_i", + "j7_l_i", + "j8_l_i", + "j1_l_o", + "j2_l_o", + "j3_l_o", + "j4_l_o", + "j5_l_o", + "j6_l_o", + "j7_l_o", + "j8_l_o", + "j1_r_i", + "j2_r_i", + "j3_r_i", + "j4_r_i", + "j6_r_i", + "j7_r_i", + "j8_r_i", + "j1_r_o", + "j2_r_o", + "j3_r_o", + "j4_r_o", + "j5_r_o", + "j6_r_o", + "j7_r_o", + "j8_r_o", +] +"""Canonical ordering of the 30 (6 loop-closure joints are excluded) DR Legs joints.""" + +DR_LEGS_ACTUATED_JOINTS: list[str] = [ + "j1_l_i", + "j2_l_i", + "j6_l_i", + "j7_l_i", + "j2_l_o", + "j7_l_o", + "j1_r_i", + "j2_r_i", + "j6_r_i", + "j7_r_i", + "j2_r_o", + "j7_r_o", +] +"""The 12 servo-driven joints (6 per leg).""" + +DR_LEGS_PASSIVE_JOINTS: list[str] = [j for j in DR_LEGS_JOINT_ORDER if j not in DR_LEGS_ACTUATED_JOINTS] +"""The 18 closed-loop linkage DOFs that are not driven by an actuator on real hardware.""" + + +DR_LEGS_IMPLICIT_PD_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=_DR_LEGS_USD_PATH, + activate_contact_sensors=True, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + max_depenetration_velocity=10.0, + enable_gyroscopic_forces=True, + ), + articulation_props=sim_utils.NewtonArticulationRootPropertiesCfg(self_collision_enabled=True), + copy_from_source=False, + ), + init_state=ArticulationCfg.InitialStateCfg( + pos=(0.0, 0.0, 0.27), + # Closed-loop FK is only valid at the assembled reference (all joint coords zero). + joint_pos={".*": 0.0}, + joint_vel={".*": 0.0}, + ), + articulation_root_prim_path="/pelvis", + actuators={ + "driven_joints": ImplicitActuatorCfg( + joint_names_expr=DR_LEGS_ACTUATED_JOINTS, + stiffness=5.0, + damping=0.2, + effort_limit_sim=3.1, + ), + # Linkage DOFs are undriven: explicit zeros so the solver ignores USD drive defaults. + "passive_joints": ImplicitActuatorCfg( + joint_names_expr=DR_LEGS_PASSIVE_JOINTS, + stiffness=0.0, + damping=0.0, + armature=0.0, + friction=0.0, + effort_limit_sim=400.0, + ), + }, +) +"""DR Legs with implicit PD on the 12 actuated joints (Kamino solver, closed-loop).""" diff --git a/source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst b/source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst new file mode 100644 index 00000000000..fc9ba91c17f --- /dev/null +++ b/source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst @@ -0,0 +1,27 @@ +Changed +^^^^^^^ + +* **Breaking:** Changed :class:`~isaaclab_newton.physics.NewtonKaminoManager` to require exactly + one articulation per environment. :meth:`~isaaclab_newton.physics.NewtonKaminoManager._build_solver` + raises a ``RuntimeError`` at solver initialization when an environment contains multiple + articulations. Multiple articulations per environment are not yet supported in IsaacLab's Kamino integration. + +* Changed :class:`~isaaclab_newton.physics.NewtonManager` to route forward kinematics through a + solver-specialized hook bound during solver initialization. Kamino overrides this hook to call + :meth:`SolverKamino.reset` with :class:`SolverKamino.ResetConfig.from_joints` when + :attr:`~isaaclab_newton.physics.KaminoSolverCfg.use_fk_solver` is enabled. Environment resets + now share a single per-articulation mask for both :meth:`~isaaclab_newton.physics.NewtonManager.forward` + and pre-step reconcile, replacing the separate per-world Kamino reset mask. + +Fixed +^^^^^ + +* Fixed environment resets writing updated state into the wrong double-buffered simulation + state when ``use_cuda_graph`` was disabled. With an odd number of substeps the canonical input + state buffer flipped each step while asset write paths kept targeting the original binding, so + reset environments stayed inconsistent for solvers with separate input/output states (e.g. + :class:`~isaaclab_newton.physics.NewtonKaminoManager`). + +* Fixed Kamino forward kinematics on environment resets leaving incorrect body poses for + closed-loop systems. Reset environments are now always updated through Kamino's loop-closure FK + solver instead of Newton's articulated ``eval_fk``. 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 eb0af1c1043..be4c5c92e18 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -125,11 +125,12 @@ def update(self, dt: float) -> None: 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``). + Newton's ``state.body_q`` (per-body world transforms) is updated by the active + solver manager's ``forward()``, which calls a solver-specialized FK hook. + 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() diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py index 976d51dd201..e73632d656f 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py @@ -32,6 +32,7 @@ def build_source_builders( for source in sources: builder = create_builder() solvers.SolverMuJoCo.register_custom_attributes(builder) + solvers.SolverKamino.register_custom_attributes(builder) builder.add_usd( stage, root_path=source, diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py index 292c2c04714..85bc166b2c3 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py @@ -14,7 +14,6 @@ from newton.solvers import SolverKamino from isaaclab.physics import PhysicsManager -from isaaclab.utils.timer import Timer from .kamino_manager_cfg import KaminoSolverCfg from .newton_manager import NewtonManager @@ -22,6 +21,31 @@ logger = logging.getLogger(__name__) +def _model_has_loop_closing_joints(model: Model) -> bool: + """Return whether ``model`` contains converted loop-closing articulation joints. + + Newton stores regular tree joints in ``[articulation_start[i], articulation_end[i])`` and + loop-closing joints in ``[articulation_end[i], articulation_start[i + 1])``. Loop closures + are present when the next articulation sentinel exceeds the tree joint end for any + articulation. + + Args: + model: Finalized Newton model to inspect. + + Returns: + ``True`` if at least one articulation has loop-closing joints. + """ + articulation_start = model.articulation_start + articulation_end = model.articulation_end + if articulation_start is None or articulation_end is None: + return False + articulation_start_np = articulation_start.numpy() + articulation_end_np = articulation_end.numpy() + if articulation_end_np.shape[0] == 0: + return False + return bool((articulation_start_np[1:] > articulation_end_np).any()) + + class NewtonKaminoManager(NewtonManager): """:class:`NewtonManager` specialization for the Kamino solver. @@ -30,86 +54,51 @@ class NewtonKaminoManager(NewtonManager): Kamino's internal collision detector handles contact generation. """ + # Annotate the concrete solver type. + _solver: SolverKamino + @classmethod - def _forward_kamino(cls, world_mask: wp.array | None = None) -> None: - """Kamino-specific forward kinematics via ``solver.reset()``. + def _get_kamino_solver_cfg(cls) -> KaminoSolverCfg: + cfg = PhysicsManager._cfg + if cfg is None: + raise RuntimeError("Physics manager is not initialized.") + solver_cfg = getattr(cfg, "solver_cfg", None) + if not isinstance(solver_cfg, KaminoSolverCfg): + raise TypeError(f"Expected KaminoSolverCfg, got {type(solver_cfg).__name__}.") + return solver_cfg + + @classmethod + def _eval_fk_impl(cls, world_reset_mask: wp.array | None, fk_mask: wp.array | None) -> None: + """Update body states from joint coordinates. + + For the Kamino (maximal-coordinate) solver, body poses/velocities are the authoritative + simulation state. When :attr:`KaminoSolverCfg.use_fk_solver` is enabled, this calls + :meth:`SolverKamino.reset`, which runs Kamino's loop-closure forward kinematics: it reads + body poses/velocities from the joint coordinates (including the base body's pose/twist) + and writes back a consistent full joint and body state. - ``SolverKamino.ResetConfig.from_joints()`` consumes Newton's full - canonical state arrays directly, including free-joint coordinates. + When ``use_fk_solver`` is disabled, falls back to Newton's articulated ``eval_fk`` over + ``fk_mask``; the caller is then responsible for writing constraint-consistent joint values. Args: - world_mask: Per-world mask indicating which worlds to reset. - Shape ``(num_worlds,)``, dtype ``wp.bool``. If None, resets all worlds. + world_reset_mask: Per-world mask passed to :meth:`SolverKamino.reset` (``None`` means all). + fk_mask: Per-articulation mask of articulations to update (``None`` means all). """ - cls._solver.reset( - state=cls._state_0, - world_mask=world_mask, - config=SolverKamino.ResetConfig.from_joints(), - ) - - @classmethod - def step(cls) -> None: - """Step the physics simulation.""" - sim = PhysicsManager._sim - if sim is None or not sim.is_playing(): - return - - # Kamino: run solver.reset() with the accumulated world mask to reinitialise - # internal state (warm-start containers, constraint multipliers) for reset worlds. - # Note: runs every step. solver.reset() with an all-False world_mask is a no-op - # (kernels check mask per-world and skip). The cost of a no-op launch is negligible - # compared to the complexity of maintaining a separate boolean guard. - cls._forward_kamino(world_mask=cls._world_reset_mask) - - # Notify solver of model changes - if cls._model_changes: - with wp.ScopedDevice(PhysicsManager._device): - for change in cls._model_changes: - 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. - 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] - NewtonManager._graph_capture_pending = False - NewtonManager._graph = cls._capture_relaxed_graph(device) - if cls._graph is not None: - # 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. - wp.capture_launch(cls._graph) - logger.info("Newton CUDA graph captured (deferred relaxed mode, RTX-compatible)") - else: - logger.warning("Newton deferred CUDA graph capture failed; using eager execution") - - # Ensure body_q is up-to-date before collision detection. - # After env resets, joint_q is written but body_q (used by - # broadphase/narrowphase) is stale until FK runs. - # Only runs FK for dirtied articulations via the accumulated mask. - if cls._needs_collision_pipeline: - eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, cls._fk_reset_mask) - - # Zero both masks after consumption - 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._get_kamino_solver_cfg().use_fk_solver: + cls._solver.reset( + cls._state_0, + world_mask=world_reset_mask, + config=SolverKamino.ResetConfig.from_joints(), + ) else: - with wp.ScopedDevice(device): - cls._simulate_physics_only() - if cls._usdrt_stage is not None: - cls._mark_transforms_dirty() + eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, fk_mask) - # Launch solver-specific debug logging after stepping. - cls._log_solver_debug() - - PhysicsManager._sim_time += cls._solver_dt * cls._num_substeps + # Reset solver internals without performing Kamino's FK. + cls._solver.reset( + cls._state_0, + world_mask=world_reset_mask, + config=SolverKamino.ResetConfig.preserve(), + ) @classmethod def _build_solver(cls, model: Model, solver_cfg: KaminoSolverCfg) -> None: @@ -118,40 +107,41 @@ def _build_solver(cls, model: Model, solver_cfg: KaminoSolverCfg) -> None: Sets :attr:`NewtonManager._needs_collision_pipeline` to ``True`` only when ``use_collision_detector=False`` (Kamino's internal detector handles contacts otherwise). + + Sets :attr:`NewtonManager._needs_fk_before_step` because Kamino treats body state as + authoritative: reset worlds (written via joint coordinates) must be reconciled before each + step. The shared :attr:`NewtonManager._world_reset_mask` and + :attr:`NewtonManager._fk_reset_mask` restrict that pre-step reconcile and + :meth:`NewtonManager.forward` to reset worlds, so non-reset worlds keep their live, + authoritative body state through Kamino's :meth:`_eval_fk_impl` overwrite. + + Raises: + RuntimeError: If the model has more than one articulation per environment. The Kamino + interface in IsaacLab currently only supports one articulation per environment. """ + + # Set the max contacts per world if specified. + if solver_cfg.max_contacts_per_world is not None: + model.rigid_contact_max = int(solver_cfg.max_contacts_per_world) * model.world_count + logger.info( + "[KAMINO] Capping rigid_contact_max to %d (%d/world * %d worlds)", + model.rigid_contact_max, + solver_cfg.max_contacts_per_world, + model.world_count, + ) + + # Set the use_fk_solver flag based on the model's articulation structure if not specified by user. + if solver_cfg.use_fk_solver is None: + solver_cfg.use_fk_solver = _model_has_loop_closing_joints(model) + + if solver_cfg.use_fk_solver and model.articulation_count != model.world_count: + raise RuntimeError( + "The Kamino FK solver requires exactly one articulation per environment, but the model" + f" has {model.articulation_count} articulations across {model.world_count} environments." + " Multiple articulations per environment are not yet supported in Kamino's FK solver." + ) + NewtonManager._solver = SolverKamino(model, solver_cfg.to_solver_config()) NewtonManager._use_single_state = False NewtonManager._needs_collision_pipeline = not solver_cfg.use_collision_detector - - @classmethod - def _capture_or_defer_cuda_graph(cls) -> None: - """Capture the physics CUDA graph, or defer if RTX is initializing.""" - cfg = PhysicsManager._cfg - device = PhysicsManager._device - use_cuda_graph = cfg is not None and cfg.use_cuda_graph and "cuda" in device # type: ignore[union-attr] - - with Timer(name="newton_cuda_graph", msg="CUDA graph took:"): - if not use_cuda_graph: - NewtonManager._graph = None - return - if cls._usdrt_stage is None: - # No RTX active — use standard Warp capture (cudaStreamCaptureModeGlobal). - with wp.ScopedCapture() as capture: - cls._simulate_physics_only() - NewtonManager._graph = capture.graph - logger.info("Newton CUDA graph captured (standard Warp mode)") - - # TODO: streamline this with base NewtonManager - # 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. - 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)") + NewtonManager._needs_fk_before_step = True 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 e88e54e59ab..90fbb067aaf 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py @@ -48,13 +48,33 @@ class KaminoSolverCfg(NewtonSolverCfg): use_collision_detector: bool = False """Whether to use Kamino's internal collision detector instead of Newton's pipeline.""" - use_fk_solver: bool = True + use_fk_solver: bool | None = None """Whether to enable the forward kinematics solver for state resets. - Required for proper environment resets. The FK solver computes consistent body poses - from joint angles after state writes, which is essential for maximal-coordinate solvers. + When ``None``, Kamino will automatically determine whether to use the FK solver based on the model's + articulation structure. If the model has loop-closing joints, the FK solver will be used. + + When ``True``, :meth:`NewtonKaminoManager._eval_fk_impl` reconciles body state via + :meth:`SolverKamino.reset` with :class:`SolverKamino.ResetConfig.from_joints`. Kamino's FK + solver computes consistent body poses/velocities from the joint coordinates (including the + base joint for floating bases), resolves passive / loop-closure joints, and writes back a + consistent full joint state. Environment resets only need to write actuated DOFs in + ``joint_q``; passive values are filled in by FK. This is required for closed-loop systems. + + When ``False``, Newton's articulated ``eval_fk`` is used instead over the full + ``joint_q`` / ``joint_qd``. It is then up to the user to specify constraint-consistent + values. This is the faster option for purely articulated (tree-structured) systems. """ + fk_use_regularization: bool = True + """Whether to regularize the FK reset solve (Tikhonov term on body poses).""" + + fk_regularization_weight: float = 1e-5 + """Weight of the FK reset regularizer, used when :attr:`fk_use_regularization` is ``True``.""" + + fk_tolerance: float = 1e-5 + """Convergence tolerance of the FK reset solve.""" + sparse_jacobian: bool = False """Whether to use sparse Jacobian computation.""" @@ -145,6 +165,16 @@ class KaminoSolverCfg(NewtonSolverCfg): Only used when :attr:`use_collision_detector` is ``True``. If ``None``, Newton's default is used. """ + max_contacts_per_world: int | None = None + """Cap the per-world contact pre-allocation handed to Kamino. + + When ``None``, Kamino falls back to ``geoms.world_minimum_contacts`` derived from the + collision pipeline, which over-allocates dramatically for contact-rich assets. Set this + to bound GPU memory for multi-env training of contact-heavy tasks (e.g. legged + locomotion or manipulation). The total ``model.rigid_contact_max`` is computed as + ``max_contacts_per_world * model.world_count`` before solver construction. + """ + dynamics_preconditioning: bool = True """Whether to use preconditioning in the constrained dynamics solver. @@ -165,10 +195,15 @@ def to_solver_config(self) -> SolverKamino.Config: CollisionDetectorConfig, ConstrainedDynamicsConfig, ConstraintStabilizationConfig, + ForwardKinematicsSolverConfig, PADMMSolverConfig, ) from newton.solvers import SolverKamino + # Kamino Manager will set the automatic value before. This is a fallback to true if that mechanism was bypassed. + if self.use_fk_solver is None: + self.use_fk_solver = True + # Build collision detector config if using Kamino's internal detector collision_detector = None if self.use_collision_detector: @@ -190,6 +225,11 @@ def to_solver_config(self) -> SolverKamino.Config: collect_solver_info=self.collect_solver_info, compute_solution_metrics=self.compute_solution_metrics, collision_detector=collision_detector, + fk=ForwardKinematicsSolverConfig( + use_regularization=self.fk_use_regularization, + regularization_weight=self.fk_regularization_weight, + tolerance=self.fk_tolerance, + ), constraints=ConstraintStabilizationConfig( alpha=self.constraints_alpha, beta=self.constraints_beta, diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 7d04c6e0ea5..69fdc6fe1d0 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -190,6 +190,18 @@ def state(self) -> Model: return NewtonManager.get_state_0() +def _eval_fk_unbound(world_reset_mask: wp.array | None, fk_mask: wp.array | None) -> None: + """Default :attr:`NewtonManager._eval_fk` value before a solver is initialized. + + Raises so a stray ``forward()`` / ``step()`` before ``initialize_solver()`` fails loudly + instead of silently running a wrong (or no) FK. + """ + raise RuntimeError( + "FK hook is not bound. NewtonManager.initialize_solver() must run " + "(via reset()) before forward()/step() can run forward kinematics." + ) + + class NewtonManager(PhysicsManager): """Abstract Newton physics manager for Isaac Lab. @@ -254,9 +266,12 @@ def provides_implicit_damping(cls) -> bool: _pending_extended_state_attributes: set[str] = set() _pending_extended_contact_attributes: set[str] = set() _report_contacts: bool = False - # Per-world reset masks (allocated in start_simulation, consumed in step) + + # Per-world reset masks (allocated in start_simulation, consumed in step/forward). _world_reset_mask: wp.array | None = None # (num_envs,) wp.bool — for SolverKamino.reset(world_mask=...) _fk_reset_mask: wp.array | None = None # (articulation_count,) wp.bool — for eval_fk(mask=...) + # Solver-specialized FK delegate. Bound in initialize_solver() to the active subclass's choice of FK implementation. + _eval_fk: Callable[[wp.array | None, wp.array | None], None] = _eval_fk_unbound # Newton actuator adapter (owns actuators and double-buffered states) _adapter: NewtonActuatorAdapter | None = None @@ -363,17 +378,42 @@ def reset(cls, soft: bool = False) -> None: cls.start_simulation() cls.initialize_solver() + @classmethod + def _eval_fk_impl(cls, world_reset_mask: wp.array | None, fk_mask: wp.array | None) -> None: + """Update body states from joint coordinates. + + Solver-specialized FK implementation. The base implementation runs Newton's generic + ``eval_fk`` over the articulations selected by ``fk_mask``. Subclasses may override + this method to use a solver-specific FK. + + Args: + world_reset_mask: Per-world mask of environments to reset (``None`` means all). + Unused by the base implementation; consumed by solver-specific overrides such as + :meth:`NewtonKaminoManager._eval_fk_impl`. + fk_mask: Per-articulation mask of articulations to update (``None`` means all). + """ + eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, fk_mask) + @classmethod def forward(cls) -> None: """Update articulation kinematics without stepping physics. - Runs Newton's generic forward kinematics (``eval_fk``) over **all** - articulations to compute body poses from joint coordinates. This is - the full (unmasked) FK path used during initial setup. For incremental - per-environment updates after resets, see :meth:`invalidate_fk` which - accumulates masks consumed by :meth:`step`. + Update body poses from joint coordinates via the solver-specialized FK delegate + (:attr:`_eval_fk`, bound to the active subclass's :meth:`_eval_fk_impl` in + :meth:`initialize_solver`). Only the articulations flagged dirty in + :attr:`_fk_reset_mask` and :attr:`_world_reset_mask` (see :meth:`invalidate_fk`) are + updated. The masks are consumed (zeroed) afterwards so the next :meth:`step` does not + redundantly re-solve them. + + The delegate (rather than a direct ``cls._eval_fk_impl`` call) is required because the + data layer invokes ``NewtonManager.forward()`` on the base class, where ``cls`` is the + base ``NewtonManager``; the bound delegate dispatches to the concrete subclass override. """ - eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, None) + cls._eval_fk(cls._world_reset_mask, cls._fk_reset_mask) + if cls._fk_reset_mask is not None: + cls._fk_reset_mask.zero_() + if cls._world_reset_mask is not None: + cls._world_reset_mask.zero_() @classmethod def pre_render(cls) -> None: @@ -651,6 +691,12 @@ def step(cls) -> None: NewtonManager._graph_capture_pending = False NewtonManager._graph = cls._capture_relaxed_graph(device) if cls._graph is not None: + # 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) logger.info("Newton CUDA graph captured (deferred relaxed mode, RTX-compatible)") else: logger.warning("Newton deferred CUDA graph capture failed; using eager execution") @@ -659,10 +705,11 @@ def step(cls) -> None: # After env resets or kinematic root writes, joint_q is written but # body_q is stale until FK runs. Collision-based solvers need this for # broadphase/narrowphase; collider-based solvers such as MPM need it - # for their internal collider queries. + # for their internal collider queries. Maximal-coordinate solvers + # that treat body state as the main state (e.g. Kamino) require FK before step. # Only runs FK for dirtied articulations via the accumulated mask. if cls._needs_collision_pipeline or cls._needs_fk_before_step: - eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, cls._fk_reset_mask) + cls._eval_fk(cls._world_reset_mask, cls._fk_reset_mask) # Zero both masks after consumption NewtonManager._world_reset_mask.zero_() @@ -759,6 +806,7 @@ def clear(cls): NewtonManager._contacts = None NewtonManager._needs_collision_pipeline = False NewtonManager._needs_fk_before_step = False + NewtonManager._eval_fk = _eval_fk_unbound NewtonManager._collision_pipeline = None NewtonManager._collision_cfg = None NewtonManager._newton_contact_sensors = {} @@ -1126,7 +1174,8 @@ def start_simulation(cls) -> None: NewtonManager._state_0 = cls._model.state() NewtonManager._state_1 = cls._model.state() NewtonManager._control = cls._model.control() - eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, None) + # The initial body-state update from joint coordinates is deferred to the tail of + # initialize_solver(), where it runs through the solver-specialized FK delegate after the solver is initialized. # The single global actuator adapter is built lazily on the first # call to ``activate_newton_actuator_path`` from any Newton-fast-path @@ -1136,7 +1185,7 @@ def start_simulation(cls) -> None: 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) + # 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.bool, device=device) NewtonManager._fk_reset_mask = wp.zeros(cls._model.articulation_count, dtype=wp.bool, device=device) @@ -1409,6 +1458,17 @@ def initialize_solver(cls) -> None: ) cls._initialize_contacts() + # Bind the solver-specialized FK delegate to the active subclass's _eval_fk_impl so + # that forward()/step() dispatch correctly even when forward() is invoked through the + # base class (the data layer imports NewtonManager directly). ``cls`` is the concrete + # subclass here, since initialize_solver is reached via sim.physics_manager.reset(). + NewtonManager._eval_fk = cls._eval_fk_impl + + # Establish the initial kinematically-consistent body state through the + # solver-specialized FK delegate, now that the solver and the delegate both exist. + # Runs before graph capture below so the capture warmup sees a valid body_q. + cls._eval_fk(None, None) + if cls._usdrt_stage is not None: cls._setup_cubric_bindings() @@ -1645,7 +1705,7 @@ def _run_solver_substeps(cls, contacts) -> None: cls._collision_pipeline.collide(cls._state_0, contacts) 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] + need_copy_on_last = cfg is not None and cls._num_substeps % 2 == 1 for i in range(cls._num_substeps): 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: 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 28f116a68d7..2de4e556b28 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -334,6 +334,7 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) mock.patch.object(visualization_builder_module, "SchemaResolverNewton", lambda: object()), mock.patch.object(visualization_builder_module, "SchemaResolverPhysx", lambda: object()), mock.patch.object(newton_clone_utils_module.solvers.SolverMuJoCo, "register_custom_attributes"), + mock.patch.object(newton_clone_utils_module.solvers.SolverKamino, "register_custom_attributes"), ): builder = visualization_builder_module.build_visualization_builder_from_stage_envs( stage, env_paths, clone_plan diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 991a5f63da8..56bf4c33f1f 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -651,3 +651,72 @@ def counting_collide(state, contacts): # Expect: 1 (top-of-tick) + expected_mid_loop_collides. assert calls["n"] == 1 + expected_mid_loop_collides + + +# --------------------------------------------------------------------------- +# Regression: an env reset written through the data layer must land in the +# manager's canonical _state_0 after an odd number of steps when CUDA graphs +# are disabled (the use_cuda_graph state-swap gating bug). +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("num_steps", [1, 3]) +def test_reset_lands_in_state_0_after_odd_kamino_steps_without_cuda_graph(num_steps): + """An env reset written through the data-layer binding lands in ``_state_0``. + + Kamino is double-buffered (``_use_single_state=False``), so each substep + ping-pongs ``_state_0`` / ``_state_1``. With a single substep the loop must + copy the result back into ``_state_0`` instead of swapping, otherwise after + an *odd* number of steps the canonical ``_state_0`` ends up on the other + buffer. This copy-on-last was previously gated on ``use_cuda_graph``, so with + CUDA graphs disabled ``_state_0`` flipped buffers and env-reset writes landed + in the stale buffer. + + :class:`~isaaclab_newton.assets.ArticulationData` binds its joint-state write + target to ``_state_0.joint_q`` once at setup (``_sim_bind_joint_pos``) and + never re-binds on env resets, so a flipped ``_state_0`` makes reset writes + miss the live state. This test reproduces that contract without a full USD + articulation: it caches the same ``_state_0.joint_q`` binding, steps Kamino an + odd number of times, writes a sentinel through the cached binding (mimicking + the reset write), and asserts the manager's ``_state_0`` observes it. + + Without the fix the swap-on-last flips ``_state_0`` for odd ``num_steps`` and + the sentinel lands in ``_state_1`` instead, so the final assertion fails. + """ + sentinel = 1.2345 + sim_cfg = SimulationCfg( + dt=1.0 / 120.0, + device="cuda:0", + gravity=(0.0, 0.0, -9.81), + physics=NewtonCfg( + solver_cfg=KaminoSolverCfg(), + num_substeps=1, + use_cuda_graph=False, + ), + ) + + with build_simulation_context(sim_cfg=sim_cfg) as sim: + builder = NewtonManager.create_builder() + body = builder.add_body(mass=1.0) + builder.add_joint_revolute(parent=-1, child=body, axis=(0, 0, 1)) + NewtonManager.set_builder(builder) + sim.reset() + + # Kamino keeps separate input/output states; the bug only exists there. + assert NewtonManager._use_single_state is False + # The data layer binds its joint-state write target to _state_0 at setup. + reset_target = NewtonManager._state_0.joint_q + assert reset_target.shape[0] > 0 # guard against a vacuous assertion + + for _ in range(num_steps): + sim.step(render=False) + + # An env reset writes joint state through the (still bound) target. + reset_target.fill_(sentinel) + + # The reset must be visible in the manager's canonical _state_0; if the + # buffer flipped it landed in _state_1 instead. + canonical_joint_q = NewtonManager._state_0.joint_q.numpy() + assert np.allclose(canonical_joint_q, sentinel), ( + f"reset write did not land in _state_0 after {num_steps} steps: {canonical_joint_q}" + ) diff --git a/source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst b/source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst new file mode 100644 index 00000000000..65b7ea95e46 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst @@ -0,0 +1,14 @@ +Added +^^^^^ + +* Added ``Isaac-DrLegs-HoldPose-v0`` and ``Isaac-DrLegs-Walk-v0`` Kamino closed-loop locomotion + tasks via :class:`~isaaclab_tasks.contrib.dr_legs.hold_pose_env_cfg.DrLegsHoldPoseEnvCfg` and + :class:`~isaaclab_tasks.contrib.dr_legs.walk_env_cfg.DrLegsWalkEnvCfg`. + +* Added ``newton_kamino`` physics presets to core and contrib velocity, reach, cabinet, and Shadow + Hand environment configurations. + +Fixed +^^^^^ + +* Fixed reach task table spawn offset for Newton ``newton_mjwarp`` and ``newton_kamino`` presets. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/__init__.py new file mode 100644 index 00000000000..8cfe3a75c5d --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/__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 + +"""DR Legs closed-loop biped locomotion tasks (Kamino solver).""" + +import gymnasium as gym + +from . import agents + +gym.register( + id="Isaac-DrLegs-HoldPose-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.hold_pose_env_cfg:DrLegsHoldPoseEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:DrLegsHoldPosePPORunnerCfg", + }, +) + +gym.register( + id="Isaac-DrLegs-Walk-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.walk_env_cfg:DrLegsWalkEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:DrLegsWalkPPORunnerCfg", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/__init__.py new file mode 100644 index 00000000000..460a3056908 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/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/contrib/dr_legs/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/rsl_rl_ppo_cfg.py new file mode 100644 index 00000000000..8fbc732f256 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/rsl_rl_ppo_cfg.py @@ -0,0 +1,50 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from isaaclab.utils.configclass import configclass + +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg + + +@configclass +class DrLegsHoldPosePPORunnerCfg(RslRlOnPolicyRunnerCfg): + num_steps_per_env = 24 + max_iterations = 3000 + save_interval = 100 + experiment_name = "dr_legs_hold_pose" + obs_groups = {"actor": ["policy"], "critic": ["policy", "critic"]} + actor = RslRlMLPModelCfg( + hidden_dims=[512, 512, 512], + activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 512, 512], + activation="elu", + obs_normalization=True, + ) + algorithm = RslRlPpoAlgorithmCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.001, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + ) + + +@configclass +class DrLegsWalkPPORunnerCfg(DrLegsHoldPosePPORunnerCfg): + def __post_init__(self): + super().__post_init__() + self.experiment_name = "dr_legs_walk" + self.max_iterations = 3000 diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py new file mode 100644 index 00000000000..e710e8c4a06 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py @@ -0,0 +1,279 @@ +# 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 + +"""Hold-pose environment for the Disney DR Legs closed-loop biped (Kamino solver). + +The robot must keep its pelvis upright at a target height. +""" + +from isaaclab_newton.physics import KaminoSolverCfg, NewtonCfg, NewtonShapeCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import AssetBaseCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +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.sim import SimulationCfg +from isaaclab.utils.configclass import configclass + +import isaaclab_tasks.contrib.dr_legs.mdp as mdp +from isaaclab_tasks.utils import PresetCfg + +from isaaclab_assets.robots.dr_legs import DR_LEGS_ACTUATED_JOINTS, DR_LEGS_IMPLICIT_PD_CFG + +## +# Shared constants +## + +_NUM_ENVS = 4096 + +_ACTUATED_JOINT_CFG = SceneEntityCfg("robot", joint_names=DR_LEGS_ACTUATED_JOINTS, preserve_order=True) + +_PHYSICS_MATERIAL = sim_utils.RigidBodyMaterialCfg( + friction_combine_mode="average", + restitution_combine_mode="average", + static_friction=1.0, + dynamic_friction=0.8, + restitution=0.0, +) + + +def _kamino_newton_cfg() -> NewtonCfg: + """Kamino solver preset for DR Legs (closed-loop, implicit PD).""" + return NewtonCfg( + solver_cfg=KaminoSolverCfg( + integrator="moreau", + sparse_jacobian=True, + sparse_dynamics=False, + use_collision_detector=False, + collision_detector_pipeline="unified", + collision_detector_max_contacts_per_pair=8, + use_fk_solver=True, + constraints_alpha=0.1, + padmm_max_iterations=100, + padmm_primal_tolerance=1.0e-5, + padmm_dual_tolerance=1.0e-5, + padmm_compl_tolerance=1.0e-5, + padmm_rho_0=0.02, + padmm_eta=1.0e-5, + padmm_use_acceleration=True, + padmm_warmstart_mode="containers", + padmm_contact_warmstart_method="geom_pair_net_force", + padmm_use_graph_conditionals=False, + max_contacts_per_world=50, + ), + num_substeps=2, + use_cuda_graph=True, + default_shape_cfg=NewtonShapeCfg(margin=0.0, gap=0.001), + ) + + +@configclass +class DrLegsPhysicsCfg(PresetCfg): + """Physics backend preset (DR Legs runs only under the Kamino solver).""" + + default: NewtonCfg = _kamino_newton_cfg() + newton_kamino: NewtonCfg = _kamino_newton_cfg() + + +## +# Scene definition +## + + +@configclass +class HoldPoseSceneCfg(InteractiveSceneCfg): + ground = AssetBaseCfg( + prim_path="/World/ground", + spawn=sim_utils.GroundPlaneCfg(size=(100.0, 100.0), physics_material=_PHYSICS_MATERIAL), + ) + + robot = DR_LEGS_IMPLICIT_PD_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + dome_light = AssetBaseCfg( + prim_path="/World/DomeLight", + spawn=sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)), + ) + + +## +# MDP settings +## + + +@configclass +class ActionsCfg: + joint_pos = mdp.JointPositionActionCfg( + asset_name="robot", + joint_names=DR_LEGS_ACTUATED_JOINTS, + preserve_order=True, + scale=0.3, + use_default_offset=True, + ) + + +@configclass +class ObservationsCfg: + @configclass + class PolicyCfg(ObsGroup): + projected_gravity = ObsTerm(func=mdp.projected_gravity) + base_ang_vel = ObsTerm(func=mdp.base_ang_vel) + joint_pos = ObsTerm(func=mdp.joint_pos_rel, params={"asset_cfg": _ACTUATED_JOINT_CFG}) + setpoint_hist = ObsTerm( + func=mdp.joint_position_setpoints, + params={"action_term_name": "joint_pos"}, + history_length=2, + flatten_history_dim=True, + ) + + def __post_init__(self) -> None: + self.enable_corruption = False + self.concatenate_terms = True + + @configclass + class CriticCfg(ObsGroup): + base_lin_vel = ObsTerm(func=mdp.base_lin_vel) + joint_vel_rel = ObsTerm(func=mdp.joint_vel_rel, scale=0.2) + + def __post_init__(self) -> None: + self.enable_corruption = False + self.concatenate_terms = True + + policy: PolicyCfg = PolicyCfg() + critic: CriticCfg = CriticCfg() + + +@configclass +class EventCfg: + randomize_joint_params = EventTerm( + func=mdp.randomize_joint_parameters, + mode="startup", + params={ + "asset_cfg": _ACTUATED_JOINT_CFG, + "operation": "scale", + "distribution": "uniform", + "armature_distribution_params": (0.8, 1.2), + }, + ) + + randomize_actuator_gains = EventTerm( + func=mdp.randomize_actuator_gains, + mode="startup", + params={ + "asset_cfg": _ACTUATED_JOINT_CFG, + "operation": "scale", + "distribution": "uniform", + "stiffness_distribution_params": (0.8, 1.2), + "damping_distribution_params": (0.8, 1.2), + }, + ) + + physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=".*"), + "static_friction_range": (0.7, 1.2), + "dynamic_friction_range": (0.5, 1.0), + "restitution_range": (0.0, 0.0), + "num_buckets": 64, + "make_consistent": True, + }, + ) + + reset_base = EventTerm( + func=mdp.reset_root_state_uniform, + mode="reset", + params={ + "pose_range": { + "x": (-0.05, 0.05), + "y": (-0.05, 0.05), + "z": (-0.02, 0.02), + "roll": (-0.1, 0.1), + "pitch": (-0.1, 0.1), + "yaw": (-3.14159, 3.14159), + }, + "velocity_range": { + "x": (-0.2, 0.2), + "y": (-0.2, 0.2), + "z": (-0.1, 0.1), + "roll": (-0.2, 0.2), + "pitch": (-0.2, 0.2), + "yaw": (-0.2, 0.2), + }, + }, + ) + + reset_robot_joints = EventTerm( + func=mdp.reset_joints_by_offset, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "position_range": (-0.1, 0.1), + "velocity_range": (-0.05, 0.05), + }, + ) + + +@configclass +class RewardsCfg: + alive = RewTerm(func=mdp.is_alive, weight=5.0) + flat_orientation = RewTerm(func=mdp.flat_orientation_l2, weight=-1.0) + height = RewTerm(func=mdp.base_height_l2, weight=-1.0, params={"target_height": 0.265}) + lin_vel = RewTerm(func=mdp.base_lin_vel_l2, weight=-1.0) + ang_vel = RewTerm(func=mdp.base_ang_vel_l2, weight=-0.5) + joint_torque = RewTerm( + func=mdp.joint_pd_command_l2, + weight=-1.0e-5, + params={"asset_cfg": _ACTUATED_JOINT_CFG, "stiffness": 5.0, "damping": 0.2}, + ) + action_rate = RewTerm(func=mdp.action_rate_l2, weight=-0.1) + action_rate2 = RewTerm(func=mdp.ActionRate2L2, weight=-0.01) + joint_pos_deviation = RewTerm( + func=mdp.joint_deviation_l1, + weight=-2.0, + params={"asset_cfg": _ACTUATED_JOINT_CFG}, + ) + + +@configclass +class TerminationsCfg: + time_out = DoneTerm(func=mdp.time_out, time_out=True) + root_height = DoneTerm(func=mdp.root_height_below_minimum, params={"minimum_height": 0.12}) + bad_orientation = DoneTerm(func=mdp.bad_orientation, params={"limit_angle": 1.0}) + + +## +# Environment configuration +## + + +@configclass +class DrLegsHoldPoseEnvCfg(ManagerBasedRLEnvCfg): + """DR Legs hold-pose environment (Newton/Kamino backend).""" + + scene: HoldPoseSceneCfg = HoldPoseSceneCfg(num_envs=_NUM_ENVS, env_spacing=2.0) + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + events: EventCfg = EventCfg() + rewards: RewardsCfg = RewardsCfg() + terminations: TerminationsCfg = TerminationsCfg() + sim: SimulationCfg = SimulationCfg( + dt=0.004, + render_interval=5, + physics=DrLegsPhysicsCfg(), + physics_material=_PHYSICS_MATERIAL, + ) + + def __post_init__(self) -> None: + self.decimation = 5 + self.episode_length_s = 10.0 + self.viewer.eye = (1.5, 0.5, 0.5) + self.viewer.lookat = (0.0, 0.0, 0.265) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.py new file mode 100644 index 00000000000..1876a0b22bf --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.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 + +"""MDP terms for the DR Legs tasks. + +Re-exports the stock Isaac Lab manager-based MDP terms and adds the few +DR-Legs-specific terms that have no stock equivalent. +""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.pyi new file mode 100644 index 00000000000..8d178a6b5ed --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.pyi @@ -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 + +__all__ = [ + # observations + "gait_phase", + "joint_position_setpoints", + # rewards + "ActionRate2L2", + "base_ang_vel_l2", + "base_lin_vel_l2", + "contact_matching", + "feet_flat", + "feet_parallel", + "feet_touchdown_vel", + "foot_clearance", + "joint_pd_command_l2", + "joint_pos_tracking_exp", + "root_orientation_exp", +] + +from .observations import gait_phase, joint_position_setpoints +from .rewards import ( + ActionRate2L2, + base_ang_vel_l2, + base_lin_vel_l2, + contact_matching, + feet_flat, + feet_parallel, + feet_touchdown_vel, + foot_clearance, + joint_pd_command_l2, + joint_pos_tracking_exp, + root_orientation_exp, +) +from isaaclab.envs.mdp import * diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/observations.py new file mode 100644 index 00000000000..2c25c5a012e --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/observations.py @@ -0,0 +1,35 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""DR-Legs-specific observation terms.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import torch + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedEnv, ManagerBasedRLEnv + +__all__ = ["gait_phase", "joint_position_setpoints"] + + +def joint_position_setpoints(env: ManagerBasedEnv, action_term_name: str = "joint_pos") -> torch.Tensor: + """Joint position setpoints (the action term's ``processed_actions``, in radians).""" + action_term = env.action_manager.get_term(action_term_name) + return action_term.processed_actions.clone() + + +def gait_phase(env: ManagerBasedRLEnv, period: float = 1.0) -> torch.Tensor: + """Cyclic gait clock encoded as ``(sin(2*pi*phi), cos(2*pi*phi))``. + + ``phi`` is the episode time wrapped to ``period`` and normalized to ``[0, 1)``. + """ + episode_time_s = env.episode_length_buf.float() * env.step_dt + phase = (episode_time_s % period) / period + two_pi = 2.0 * math.pi + return torch.stack([torch.sin(two_pi * phase), torch.cos(two_pi * phase)], dim=-1) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/rewards.py new file mode 100644 index 00000000000..9c0818af2f2 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/rewards.py @@ -0,0 +1,224 @@ +# 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 + +"""DR-Legs-specific reward terms with no stock Isaac Lab equivalent. + +Includes the posture/effort terms used by the hold-pose task and the gait/contact/feet +terms used by the walk task. The exponential kernels follow the convention +``exp(-error / (2 * sigma**2))`` and the quaternion error is the squared norm of the +imaginary part of the relative quaternion (scalar-last ``(x, y, z, w)``, matching +:mod:`isaaclab.utils.math`). +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import torch + +import isaaclab.utils.math as math_utils +from isaaclab.managers import ManagerTermBase, RewardTermCfg, SceneEntityCfg + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + +__all__ = [ + "base_lin_vel_l2", + "base_ang_vel_l2", + "joint_pd_command_l2", + "joint_pos_tracking_exp", + "ActionRate2L2", + "contact_matching", + "foot_clearance", + "feet_parallel", + "feet_flat", + "feet_touchdown_vel", + "root_orientation_exp", +] + + +## +# Kernel and gait helpers (private). +## + + +def _exp_se(error: torch.Tensor, sigma: float) -> torch.Tensor: + return torch.exp(-error / (2.0 * sigma * sigma)) + + +def _quat_inv_mul(q1: torch.Tensor, q2: torch.Tensor) -> torch.Tensor: + return math_utils.quat_mul(math_utils.quat_conjugate(q1), q2) + + +def _exp_quat_err(q1: torch.Tensor, q2: torch.Tensor, sigma: float) -> torch.Tensor: + err_imag = _quat_inv_mul(q1, q2)[:, :3] + return _exp_se(torch.sum(torch.square(err_imag), dim=1), sigma) + + +def _gait_phase(env: ManagerBasedRLEnv, gait_period: float) -> torch.Tensor: + episode_time = env.episode_length_buf.float() * env.step_dt + return (episode_time % gait_period) / gait_period + + +def _reference_contacts(phase: torch.Tensor) -> torch.Tensor: + left = (phase < 0.5).float() + right = (phase >= 0.5).float() + return torch.stack([left, right], dim=1) + + +## +# Posture / effort terms. +## + + +def joint_pos_tracking_exp( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + std: float = 0.5, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + joint_ids = asset_cfg.joint_ids + error = asset.data.joint_pos[:, joint_ids] - asset.data.default_joint_pos[:, joint_ids] + return torch.exp(-torch.sum(torch.square(error), dim=1) / std**2) + + +def base_lin_vel_l2(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + return torch.sum(torch.square(asset.data.root_lin_vel_b[:, :3]), dim=1) + + +def base_ang_vel_l2(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + return torch.sum(torch.square(asset.data.root_ang_vel_b[:, :3]), dim=1) + + +def joint_pd_command_l2( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + stiffness: float = 5.0, + damping: float = 0.2, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + joint_ids = asset_cfg.joint_ids + joint_pos = asset.data.joint_pos[:, joint_ids] + joint_vel = asset.data.joint_vel[:, joint_ids] + joint_pos_target = asset.data.joint_pos_target[:, joint_ids] + pd_command = stiffness * (joint_pos_target - joint_pos) - damping * joint_vel + return torch.sum(torch.square(pd_command), dim=1) + + +class ActionRate2L2(ManagerTermBase): + def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv): + super().__init__(cfg, env) + dim = env.action_manager.total_action_dim + self._prev_action = torch.zeros((env.num_envs, dim), device=env.device) + self._prev_prev_action = torch.zeros((env.num_envs, dim), device=env.device) + + def reset(self, env_ids: torch.Tensor | None = None): + if env_ids is None: + env_ids = slice(None) + self._prev_action[env_ids] = 0.0 + self._prev_prev_action[env_ids] = 0.0 + + def __call__(self, env: ManagerBasedRLEnv) -> torch.Tensor: + current_action = env.action_manager.action + jerk = current_action - 2.0 * self._prev_action + self._prev_prev_action + reward = torch.sum(torch.square(jerk), dim=1) + self._prev_prev_action[:] = self._prev_action + self._prev_action[:] = current_action + return reward + + +## +# Gait / feet / orientation terms (walk task). +## + + +def contact_matching( + env: ManagerBasedRLEnv, + sensor_cfg: SceneEntityCfg, + threshold: float = 1.0, + gait_period: float = 1.0, +) -> torch.Tensor: + contact_sensor = env.scene.sensors[sensor_cfg.name] + net_forces = contact_sensor.data.net_forces_w.torch[:, sensor_cfg.body_ids] + observed = torch.linalg.norm(net_forces, dim=-1) > threshold + reference = _reference_contacts(_gait_phase(env, gait_period)).bool() + return torch.sum(observed == reference, dim=1).float() - 1.0 + + +def foot_clearance( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg, + target_height: float = 0.065, + sigma: float = 0.01, + gait_period: float = 1.0, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + foot_ids = asset_cfg.body_ids + foot_z = asset.data.body_link_pos_w.torch[:, foot_ids, 2] + phase = _gait_phase(env, gait_period) + two_pi = 2.0 * math.pi + left_target = target_height * torch.clamp(torch.sin(two_pi * (phase - 0.5)), min=0.0) + right_target = target_height * torch.clamp(torch.sin(two_pi * phase), min=0.0) + left_swing = (left_target > 0.0).float() + right_swing = (right_target > 0.0).float() + left_reward = left_swing * _exp_se(torch.square(left_target - foot_z[:, 0]), sigma) + right_reward = right_swing * _exp_se(torch.square(right_target - foot_z[:, 1]), sigma) + return left_reward + right_reward + + +def feet_parallel( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg, + sigma: float = 0.2, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + foot_quat = asset.data.body_link_quat_w.torch[:, asset_cfg.body_ids] + left_yaw = math_utils.yaw_quat(foot_quat[:, 0]) + right_yaw = math_utils.yaw_quat(foot_quat[:, 1]) + return _exp_quat_err(left_yaw, right_yaw, sigma) + + +def feet_flat( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg, + sigma: float = 0.2, +) -> torch.Tensor: + """Reward keeping each foot level (small roll/pitch); foot yaw is ignored. Mean over feet.""" + asset = env.scene[asset_cfg.name] + foot_quat = asset.data.body_link_quat_w.torch[:, asset_cfg.body_ids] + num_envs, num_feet = foot_quat.shape[0], foot_quat.shape[1] + quat = foot_quat.reshape(-1, 4) + tilt = _quat_inv_mul(math_utils.yaw_quat(quat), quat) + tilt_sq = torch.sum(torch.square(tilt[:, :3]), dim=1).reshape(num_envs, num_feet) + return _exp_se(tilt_sq, sigma).mean(dim=1) + + +def feet_touchdown_vel( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg, + sensor_cfg: SceneEntityCfg, + threshold: float = 1.0, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + contact_sensor = env.scene.sensors[sensor_cfg.name] + foot_z_vel = asset.data.body_com_lin_vel_w.torch[:, asset_cfg.body_ids, 2] + net_forces = contact_sensor.data.net_forces_w.torch[:, sensor_cfg.body_ids] + contact = (torch.linalg.norm(net_forces, dim=-1) > threshold).float() + downward_vel = torch.clamp(-foot_z_vel, min=0.0) + return torch.sum(contact * downward_vel, dim=-1) + + +def root_orientation_exp( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + sigma: float = 0.2, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + root_quat = asset.data.root_link_quat_w.torch + tilt = _quat_inv_mul(math_utils.yaw_quat(root_quat), root_quat) + return _exp_se(torch.sum(torch.square(tilt[:, :3]), dim=1), sigma) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/walk_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/walk_env_cfg.py new file mode 100644 index 00000000000..16382957aca --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/walk_env_cfg.py @@ -0,0 +1,151 @@ +# 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 + +"""Velocity-tracking walk environment for the Disney DR Legs closed-loop biped (Kamino). + +Extends the hold-pose task with a base-velocity command, a gait-phase clock, a foot +contact sensor, and gait / contact / foot-clearance rewards. +""" + +import math + +from isaaclab_newton.sensors import ContactSensorCfg as NewtonContactSensorCfg + +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils.configclass import configclass + +import isaaclab_tasks.contrib.dr_legs.mdp as mdp + +from isaaclab_assets.robots.dr_legs import DR_LEGS_ACTUATED_JOINTS + +from .hold_pose_env_cfg import DrLegsHoldPoseEnvCfg, HoldPoseSceneCfg, ObservationsCfg + +## +# Shared constants +## + +# Full left+right gait cycle [s]. Shared by the gait-phase observation and the rewards. +_GAIT_PERIOD = 1.0 + +# The 12 servo-driven joints (used by the implicit-PD effort proxy). +_ACTUATED_JOINT_CFG = SceneEntityCfg("robot", joint_names=DR_LEGS_ACTUATED_JOINTS, preserve_order=True) + +# Foot bodies in canonical [left, right] order for the kinematics and contact rewards. +_FOOT_ASSET_CFG = SceneEntityCfg("robot", body_names=["foot_l", "foot_r"], preserve_order=True) +_FOOT_SENSOR_CFG = SceneEntityCfg("contact_forces", body_names=["foot_l", "foot_r"], preserve_order=True) + + +@configclass +class WalkSceneCfg(HoldPoseSceneCfg): + contact_forces = NewtonContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Robot/foot_.*", + history_length=3, + track_air_time=True, + ) + + +@configclass +class CommandsCfg: + base_velocity = mdp.UniformVelocityCommandCfg( + asset_name="robot", + resampling_time_range=(10.0, 10.0), + rel_standing_envs=0.1, + rel_heading_envs=1.0, + heading_command=True, + heading_control_stiffness=0.5, + debug_vis=False, + ranges=mdp.UniformVelocityCommandCfg.Ranges( + lin_vel_x=(-0.3, 0.3), + lin_vel_y=(-0.3, 0.3), + ang_vel_z=(-0.8, 0.8), + heading=(-math.pi, math.pi), + ), + ) + + +@configclass +class WalkObservationsCfg(ObservationsCfg): + @configclass + class WalkPolicyCfg(ObservationsCfg.PolicyCfg): + velocity_commands = ObsTerm(func=mdp.generated_commands, params={"command_name": "base_velocity"}) + gait_phase = ObsTerm(func=mdp.gait_phase, params={"period": _GAIT_PERIOD}) + + policy: WalkPolicyCfg = WalkPolicyCfg() + + +@configclass +class WalkRewardsCfg: + """Gait / contact / feet reward suite for velocity-tracking walking.""" + + # -- positive (task / gait) + survival = RewTerm(func=mdp.is_alive, weight=10.0) + contact_matching = RewTerm( + func=mdp.contact_matching, + weight=10.0, + params={"sensor_cfg": _FOOT_SENSOR_CFG, "threshold": 1.0, "gait_period": _GAIT_PERIOD}, + ) + track_lin_vel_xy = RewTerm( + func=mdp.track_lin_vel_xy_exp, + weight=5.0, + params={"command_name": "base_velocity", "std": math.sqrt(0.125)}, + ) + root_orientation = RewTerm( + func=mdp.root_orientation_exp, + weight=5.0, + params={"asset_cfg": SceneEntityCfg("robot"), "sigma": 0.2}, + ) + foot_clearance = RewTerm( + func=mdp.foot_clearance, + weight=5.0, + params={ + "asset_cfg": _FOOT_ASSET_CFG, + "target_height": 0.065, + "sigma": 0.01, + "gait_period": _GAIT_PERIOD, + }, + ) + track_ang_vel_z = RewTerm( + func=mdp.track_ang_vel_z_exp, + weight=2.0, + params={"command_name": "base_velocity", "std": math.sqrt(0.5)}, + ) + feet_parallel = RewTerm( + func=mdp.feet_parallel, + weight=2.0, + params={"asset_cfg": _FOOT_ASSET_CFG, "sigma": 0.2}, + ) + feet_flat = RewTerm( + func=mdp.feet_flat, + weight=2.0, + params={"asset_cfg": _FOOT_ASSET_CFG, "sigma": 0.2}, + ) + + # -- negative (penalties) + feet_touchdown_vel = RewTerm( + func=mdp.feet_touchdown_vel, + weight=-2.0, + params={"asset_cfg": _FOOT_ASSET_CFG, "sensor_cfg": _FOOT_SENSOR_CFG, "threshold": 1.0}, + ) + lin_vel_z = RewTerm(func=mdp.lin_vel_z_l2, weight=-0.5) + ang_vel_xy = RewTerm(func=mdp.ang_vel_xy_l2, weight=-0.5) + torques = RewTerm( + func=mdp.joint_pd_command_l2, + weight=-0.001, + params={"asset_cfg": _ACTUATED_JOINT_CFG, "stiffness": 5.0, "damping": 0.2}, + ) + action_rate = RewTerm(func=mdp.action_rate_l2, weight=-0.001) + action_2nd_derivative = RewTerm(func=mdp.ActionRate2L2, weight=-5.0e-5) + + +@configclass +class DrLegsWalkEnvCfg(DrLegsHoldPoseEnvCfg): + """DR Legs velocity-tracking walk environment (Newton/Kamino backend).""" + + scene: WalkSceneCfg = WalkSceneCfg(num_envs=4096, env_spacing=2.0) + observations: WalkObservationsCfg = WalkObservationsCfg() + commands: CommandsCfg = CommandsCfg() + rewards: WalkRewardsCfg = WalkRewardsCfg() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py index c29ea2500da..8436893db82 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py index 88799d6df62..dd4765cbe73 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py index 65795ad8230..09619985e32 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py index ff8a24d0217..e3e1daa8467 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -28,6 +28,7 @@ class PhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py index a1502f7b2de..3df3825a440 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py @@ -6,7 +6,7 @@ from dataclasses import MISSING -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.sim as sim_utils @@ -73,6 +73,11 @@ class CabinetSimCfg(PresetCfg): debug_mode=False, ), ) + newton_kamino: SimulationCfg = SimulationCfg( + dt=1 / 600, + render_interval=1, + physics=NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=1), + ) ## diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py index cd226b543de..bed549290c0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py @@ -5,7 +5,7 @@ from dataclasses import MISSING -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.envs.mdp as mdp @@ -43,6 +43,10 @@ class ReachPhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) + newton_kamino: NewtonCfg = NewtonCfg( + solver_cfg=KaminoSolverCfg(max_contacts_per_world=32), + num_substeps=2, + ) default = physx @@ -65,7 +69,7 @@ class TableCfg(PresetCfg): newton_mjwarp: ArticulationCfg = ArticulationCfg( prim_path="/World/envs/env_.*/Table", init_state=ArticulationCfg.InitialStateCfg( - pos=(0.5, 0.15, -0.5), rot=(0, 0, 0.707, 0.707), joint_pos={}, joint_vel={} + pos=(0.5, 0, -0.5), rot=(0, 0, 0.707, 0.707), joint_pos={}, joint_vel={} ), spawn=sim_utils.CuboidCfg( size=(0.9, 1.3, 1.00), @@ -76,6 +80,7 @@ class TableCfg(PresetCfg): articulation_root_prim_path="", ) + newton_kamino = newton_mjwarp default = physx diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py index c4d78511555..a77d33b9b52 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.envs.mdp as mdp @@ -135,6 +135,7 @@ class ShadowHandEventCfg(PresetCfg): physx = PhysxEventCfg() newton_mjwarp = NewtonEventCfg() default = physx + newton_kamino = newton_mjwarp @configclass @@ -207,6 +208,7 @@ class ShadowHandRobotCfg(PresetCfg): soft_joint_pos_limit_factor=1.0, ) default = physx + newton_kamino = newton_mjwarp @configclass @@ -246,6 +248,7 @@ class ObjectCfg(PresetCfg): articulation_root_prim_path="", ) default = physx + newton_kamino = newton_mjwarp @configclass @@ -263,6 +266,7 @@ class ShadowHandSceneCfg(PresetCfg): num_envs=8192, env_spacing=0.75, replicate_physics=True, clone_in_fabric=False ) default: InteractiveSceneCfg = physx + newton_kamino = newton_mjwarp @configclass @@ -287,6 +291,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) default = physx + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=128), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py index 02bae7147b9..745c0852617 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py index 02a3b4d1252..34ae1b08091 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.managers import SceneEntityCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py index bb6c6347233..ab145d6a468 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py index 2b30240f5fb..4e54676fa1a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py index b89fa7ef126..b3ac1fb949f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py @@ -3,7 +3,13 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg, NewtonCollisionPipelineCfg, NewtonShapeCfg +from isaaclab_newton.physics import ( + KaminoSolverCfg, + MJWarpSolverCfg, + NewtonCfg, + NewtonCollisionPipelineCfg, + NewtonShapeCfg, +) from isaaclab_physx.physics import PhysxCfg import isaaclab.sim as sim_utils @@ -43,6 +49,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, default_shape_cfg=NewtonShapeCfg(margin=0.01), ) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) ## @@ -223,6 +230,7 @@ class SpotEventCfg(PresetCfg): default = SpotPhysxEventCfg() newton_mjwarp = SpotNewtonEventCfg() physx = default + newton_kamino = newton_mjwarp @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py index 16e4fb0d04d..24e20b9622f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py @@ -80,6 +80,7 @@ class RoughPhysicsCfg(PresetCfg): 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) + newton_kamino = newton_mjwarp physx = default ovphysx = OvPhysXContactSensorCfg(prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3, track_air_time=True) From 21ba048bd7dd4422305f8bdbdb1c6052a87f829e Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Fri, 3 Jul 2026 11:07:44 -0400 Subject: [PATCH 075/139] [IsaacLab 3.0.0 Multi-Backend] Visual Logging for Headless Runs (#6212) # Description REQ-12: Support capturing images from the visualizer when running headless such that we can capture the full or subset of environments during training to observe progress through frameworks such as tensorboard or wandb This feature exports per step frames from simulation from full or subset of the environments. Fixes # (OMPE-78691) https://github.com/isaac-sim/IsaacLab/blob/0316aea144ff1d159159db182a28319bf56be186/source/isaaclab_tasks/test/rendering_test_utils.py#L553 I save the images in the same way as the tests compare goldens moving this shared logic into utils/images.py ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots image ## 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: Matthew Taylor Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- CONTRIBUTORS.md | 1 + docs/source/how-to/capture_sensor_frames.rst | 180 ++++++++++++++ docs/source/how-to/index.rst | 1 + docs/source/how-to/record_video.rst | 1 + .../rl_existing_scripts.rst | 4 + .../tutorials/03_envs/run_rl_training.rst | 4 + scripts/reinforcement_learning/common.py | 173 +++++++++++++- .../rl_games/train_rl_games.py | 4 +- .../rsl_rl/train_rsl_rl.py | 4 +- .../reinforcement_learning/sb3/train_sb3.py | 4 +- .../reinforcement_learning/skrl/train_skrl.py | 4 +- .../capture-env-sensors-tests.skip | 1 + source/isaaclab/isaaclab/utils/images.py | 25 ++ .../test_reinforcement_learning_common.py | 219 ++++++++++++++++++ source/isaaclab/test/utils/test_images.py | 39 ++++ .../changelog.d/mt-visual-sensor-logs.skip | 1 + .../test/rendering_test_utils.py | 30 +-- 17 files changed, 658 insertions(+), 37 deletions(-) create mode 100644 docs/source/how-to/capture_sensor_frames.rst create mode 100644 source/isaaclab/changelog.d/capture-env-sensors-tests.skip create mode 100644 source/isaaclab/test/test_reinforcement_learning_common.py create mode 100644 source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index c70ed2d3faf..0cceced8f20 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -128,6 +128,7 @@ Guidelines for modifications: * Marco Alesiani * Masoud Moghani * Mateo Guaman Castro +* Matthew Taylor * Maurice Rahme * Michael Gussert * Michael Lin diff --git a/docs/source/how-to/capture_sensor_frames.rst b/docs/source/how-to/capture_sensor_frames.rst new file mode 100644 index 00000000000..7988a8346b8 --- /dev/null +++ b/docs/source/how-to/capture_sensor_frames.rst @@ -0,0 +1,180 @@ +.. _how-to-capture-sensor-frames: + +Capturing sensor frames during training +======================================= + +Isaac Lab supports saving image-like outputs from scene sensors during training using the +``CaptureEnvSensors`` Gymnasium wrapper. When ``--capture_env_sensors`` is set to a positive value, +Isaac Lab iterates over the environment's :class:`~isaaclab.scene.InteractiveScene` sensors and +writes each sensor's ``data.output`` tensors (for example, ``rgb``, ``depth``, or ``normals`` from a +:class:`~isaaclab.sensors.Camera`). The flag value is the number of parallel environment views to +tile into each saved frame grid. This differs from :doc:`record_video`, which records a single +perspective viewport clip of the scene. + +The capture flags are registered on the shared training entrypoints for RSL-RL, RL-Games, Stable +Baselines3, and skrl. They are not available on ``play`` scripts. + +This feature can be enabled using the following command line arguments with +``./isaaclab.sh train``: + +* ``--capture_env_sensors``: number of parallel environments to include in each saved frame grid + (default: ``0``, which disables capture) +* ``--capture_env_sensors_length``: length of each captured sensor frame window in **per-episode** + steps (default: ``200``). Set to ``0`` to disable saving even when ``--capture_env_sensors`` is + positive. +* ``--capture_env_sensors_interval``: interval between captured sensor frame windows in **per-episode** + steps (default: ``2000``) +* ``--capture_env_sensors_format``: output format, either ``tensorboard`` (default) or ``file`` + +Enabling sensor capture automatically enables camera rendering during training, the same as +``--video``, which slows down both startup and runtime performance. Sensor capture can also be used +at the same time as ``--video`` when both per-sensor frames and a perspective viewport clip are +needed. + +Example usage: + +.. code-block:: shell + + ./isaaclab.sh train --rl_library rsl_rl --task=Isaac-Reorient-Cube-Shadow-Camera-Direct --capture_env_sensors 4 --capture_env_sensors_length 100 --capture_env_sensors_interval 2000 --capture_env_sensors_format file + + +The captured frames will be saved in the same directory as the training checkpoints, under +``logs////sensor_frames/train``. Here ```` is the training run +directory (the same timestamped folder used for checkpoints and ``run.json``), not the per-episode +index used in output tags and file names. + + +Overview +-------- + +The sensor capture feature wraps the training environment through ``wrap_training_capture`` in +``scripts/reinforcement_learning/common.py`` and saves frames on reset and step when the current +**per-episode** step falls inside a capture window. For each image-like scene sensor, the wrapper: + +* reads ``sensor.data.output`` and skips sensors whose output is not a dictionary of tensors +* resolves each output to a :class:`torch.Tensor`, including + :class:`~isaaclab.utils.warp.ProxyArray` buffers through their ``.torch`` accessor +* selects the first ``capture_num_envs`` parallel environments from each tensor +* replaces non-finite values with zero before display normalization +* normalizes each output for display using :func:`~isaaclab.utils.images.normalize_camera_output_for_display` + and tiles the selected views into a single image grid with + :func:`~isaaclab.utils.images.make_camera_output_grid` +* writes the grid to TensorBoard or to disk as a PNG, depending on ``--capture_env_sensors_format`` + +Sensors with ``None`` for a given data type are skipped. Only tasks that define scene sensors with +image-like outputs (such as camera-based RL environments) produce captured frames. + + +Command-line options +-------------------- + +The training entrypoints register the capture flags in ``add_common_train_args``: + +.. literalinclude:: ../../../scripts/reinforcement_learning/common.py + :language: python + :lines: 240-263 + + +Capture schedule +---------------- + +A frame is saved on every environment reset and after each step while the current **per-episode** +step is inside an active capture window. The episode step counter resets to ``0`` on every +environment reset. A step is inside the window when: + +.. code-block:: text + + step % capture_env_sensors_interval < capture_env_sensors_length + +For example, with ``--capture_env_sensors_length 100`` and ``--capture_env_sensors_interval 2000``, +the wrapper saves episode steps ``0-99`` after each reset. If an episode lasts longer than 2000 +steps, another capture window opens at episode steps ``2000-2099``, then ``4000-4099``, and so on. + +.. note:: + + This schedule uses a **per-episode** step counter. :doc:`record_video` instead keys off the + Gymnasium ``RecordVideo`` wrapper's **global** environment-step counter across episodes. + + +Episode indexing +^^^^^^^^^^^^^^^^ + +Each environment reset increments an episode index that starts at ``1`` on the first reset. Captured +frames are grouped by this index so consecutive episodes can be compared: + +* TensorBoard tags use ``//episode_`` with a five-digit zero-padded + episode index (for example, ``episode_00001``) +* File output uses ``episode__step_.png`` under each sensor and data-type directory, + with a five-digit episode index and an eight-digit step index (for example, + ``episode_00001_step_00000042.png``) + +The ```` value is the per-episode step counter (reset to ``0`` on every environment reset). +In TensorBoard, images are logged with ``global_step`` set to the total number of environment steps +since training started. + + +Image processing +---------------- + +Before tiling, each selected environment view is passed through +:func:`~isaaclab.utils.images.normalize_camera_output_for_display`, which maps common camera data +types to a ``[0, 1]`` float range suitable for PNG export: + +* RGB-like outputs are scaled by ``255`` +* Depth-like outputs (``depth``, ``distance_to_camera``, ``distance_to_image_plane``) are scaled by + their per-frame maximum +* ``albedo`` keeps the first three channels and scales by ``255`` +* ``normals`` are remapped from ``[-1, 1]`` to ``[0, 1]`` + +:func:`~isaaclab.utils.images.make_camera_output_grid` arranges the ``capture_num_envs`` views into a +roughly square grid (``nrow = round(sqrt(num_envs))``) before the image is written. + + +Output formats +-------------- + +**TensorBoard (default).** Each sensor output is logged as a separate image series per episode with +the tag ``//episode_``. View the captures alongside other training +metrics by pointing TensorBoard at the training log directory or the ``sensor_frames/train`` +subdirectory: + +.. code-block:: shell + + ./isaaclab.sh -p -m tensorboard.main --logdir logs/rsl_rl/Isaac-Reorient-Cube-Shadow-Camera-Direct//sensor_frames/train + +**File.** PNG images are written per sensor, data type, episode, and step: + +``//episode__step_.png`` + +Sensor and data-type names are sanitized for file output so they are safe as path components. For +example, a frame from the first episode at step ``42`` for sensor ``front/camera`` and data type +``rgb`` is written as ``front_camera/rgb/episode_00001_step_00000042.png``. + + +Summary +------- + +.. list-table:: + :widths: 30 35 35 + :header-rows: 1 + + * - ``--capture_env_sensors_format`` + - Output location + - How to view + * - ``tensorboard`` (default) + - ``sensor_frames/train`` event files + - TensorBoard image tab (tagged by sensor, data type, and episode index) + * - ``file`` + - ``sensor_frames/train///episode__step_.png`` + - Any image viewer or filesystem browser + + +See also +-------- + +* :doc:`record_video` - record a perspective viewport clip with ``--video`` +* :doc:`/source/overview/reinforcement-learning/rl_existing_scripts` - training entrypoints that + expose the capture flags +* :doc:`/source/overview/core-concepts/visualization` - visualizers and rendering during training +* :doc:`/source/overview/core-concepts/sensors/camera` - camera sensors and annotator data types +* :doc:`proxy_array` - dual CPU/GPU sensor buffers read through ``.torch`` diff --git a/docs/source/how-to/index.rst b/docs/source/how-to/index.rst index 7d4f37449e0..d0362c0cbf6 100644 --- a/docs/source/how-to/index.rst +++ b/docs/source/how-to/index.rst @@ -146,6 +146,7 @@ This guide explains how to record an animation and video in Isaac Lab. record_animation record_video + capture_sensor_frames Dynamically Modifying Environment Parameters With CurriculumTerm diff --git a/docs/source/how-to/record_video.rst b/docs/source/how-to/record_video.rst index 28b89710546..2490ab81328 100644 --- a/docs/source/how-to/record_video.rst +++ b/docs/source/how-to/record_video.rst @@ -177,4 +177,5 @@ Summary See also -------- +* :doc:`capture_sensor_frames` - save image-like scene sensor outputs during training * :doc:`/source/overview/core-concepts/visualization` - interactive visualizers diff --git a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst index 24720d30587..9eaa3d03250 100644 --- a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst +++ b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst @@ -67,6 +67,10 @@ 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). +During training, image-like scene sensor outputs from camera tasks can be saved with +``--capture_env_sensors``. See :doc:`/source/how-to/capture_sensor_frames` for the full capture +schedule and output format details. + RL-Games -------- diff --git a/docs/source/tutorials/03_envs/run_rl_training.rst b/docs/source/tutorials/03_envs/run_rl_training.rst index 4723d411077..22b853048e0 100644 --- a/docs/source/tutorials/03_envs/run_rl_training.rst +++ b/docs/source/tutorials/03_envs/run_rl_training.rst @@ -105,6 +105,10 @@ in the workflow and pass ``--video`` to record the agent behavior. The videos are saved to the ``logs/sb3/Isaac-Cartpole//videos/train`` directory. You can open these videos using any video player. +For tasks with on-scene cameras, you can also save the sensor image outputs directly during training +with ``--capture_env_sensors``. See :doc:`/source/how-to/capture_sensor_frames` for the available +options and output formats. + Interactive execution """"""""""""""""""""" diff --git a/scripts/reinforcement_learning/common.py b/scripts/reinforcement_learning/common.py index f99a7577952..e5591704ba5 100644 --- a/scripts/reinforcement_learning/common.py +++ b/scripts/reinforcement_learning/common.py @@ -21,10 +21,13 @@ from typing import Any import gymnasium as gym +import torch +from PIL import Image from isaaclab.app import add_launcher_args from isaaclab.envs import DirectMARLEnvCfg, ManagerBasedRLEnvCfg from isaaclab.utils.dict import print_dict +from isaaclab.utils.images import make_camera_output_grid, normalize_camera_output_for_display from isaaclab.utils.io import dump_yaml RUN_MANIFEST_FILENAME = "run.json" @@ -32,6 +35,113 @@ CHECKPOINT_SELECTORS = frozenset({"latest", "best"}) +class CaptureEnvSensors(gym.Wrapper): + """Capture image-like environment sensor outputs during training.""" + + def __init__( + self, + env: gym.Env, + output_dir: str, + frame_count: int, + capture_num_envs: int, + interval: int, + output_format: str = "tensorboard", + ) -> None: + """Initialize the sensor capture wrapper. + + Args: + env: Gymnasium environment to wrap. + output_dir: Directory where captured frames are written. + frame_count: Number of frames to capture per interval. + capture_num_envs: Number of environment views to capture from each sensor. + interval: Number of environment steps between capture windows. + output_format: Output format. Can be ``"tensorboard"`` or ``"file"``. + """ + super().__init__(env) + self.output_dir = output_dir + os.makedirs(self.output_dir, exist_ok=True) + self.frame_count = max(frame_count, 0) + self.capture_num_envs = max(capture_num_envs, 0) + self.interval = max(interval, 1) + self._step_count = 0 + self._global_step_count = 0 + self._episode_index = 0 + self.writer = None + + if output_format not in {"tensorboard", "file"}: + raise ValueError(f"Unsupported sensor capture output format: {output_format}") + if output_format == "tensorboard": + from torch.utils.tensorboard import SummaryWriter + + self.writer = SummaryWriter(self.output_dir) + + def reset(self, **kwargs) -> Any: + """Reset the wrapped environment and capture the reset frame when scheduled.""" + result = self.env.reset(**kwargs) + self._step_count = 0 + self._episode_index += 1 + self._save_frame() + return result + + def step(self, action) -> Any: + """Step the wrapped environment and capture the resulting frame when scheduled.""" + result = self.env.step(action) + self._step_count += 1 + self._global_step_count += 1 + self._save_frame() + return result + + def close(self) -> None: + """Close the writer and wrapped environment.""" + if self.writer is not None: + self.writer.close() + super().close() + + def _save_frame(self) -> None: + """Write the current sensor outputs when the current step is inside a capture window.""" + if self.frame_count == 0: + return + if self._step_count % self.interval >= self.frame_count: + return + + sensors = getattr(getattr(self.unwrapped, "scene", None), "sensors", {}) + + for sensor_name, sensor in sensors.items(): + camera_outputs = getattr(getattr(sensor, "data", None), "output", None) + if not isinstance(camera_outputs, dict): + continue + + for data_type, output in camera_outputs.items(): + if output is None: + continue + tensor = output if isinstance(output, torch.Tensor) else output.torch + tensor = tensor[: self.capture_num_envs].detach().clone() + condition = torch.logical_or(torch.isinf(tensor), torch.isnan(tensor)) + corrected = torch.where(condition, torch.zeros_like(tensor), tensor) + normalized = normalize_camera_output_for_display(corrected, data_type) + grid = make_camera_output_grid(normalized) + ndarr = grid.mul(255).add_(0.5).clamp_(0, 255).permute(1, 2, 0).to("cpu", torch.uint8).numpy() + tag = f"{sensor_name}/{data_type}/episode_{self._episode_index:05d}" + + if self.writer is not None: + self.writer.add_image(tag, ndarr, global_step=self._global_step_count, dataformats="HWC") + else: + file_path = os.path.join( + self.output_dir, + self._safe_path_name(sensor_name), + self._safe_path_name(data_type), + f"episode_{self._episode_index:05d}_step_{self._step_count:08d}.png", + ) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + result_image = Image.fromarray(ndarr) + result_image.save(file_path) + + @staticmethod + def _safe_path_name(name: str) -> str: + """Return a filesystem-safe path component.""" + return "".join(character if character.isalnum() or character in "._-" else "_" for character in name) + + def dispatch_library_entrypoint( argv: list[str] | None, entrypoints: dict[str, Path], @@ -127,6 +237,31 @@ def add_common_train_args( help="Automatically configured by Ray integration, otherwise None.", ) + parser.add_argument( + "--capture_env_sensors", + type=int, + default=0, + help="Number of environment views to capture from each image-like scene sensor.", + ) + parser.add_argument( + "--capture_env_sensors_length", + type=int, + default=200, + help="Length of each captured sensor frame window (in steps).", + ) + parser.add_argument( + "--capture_env_sensors_interval", + type=int, + default=2000, + help="Interval between captured sensor frame windows (in steps).", + ) + parser.add_argument( + "--capture_env_sensors_format", + choices=["tensorboard", "file"], + default="tensorboard", + help="Format used to save the captured sensor frames.", + ) + def add_isaaclab_launcher_args(parser: argparse.ArgumentParser) -> None: """Add Isaac Lab simulation launcher arguments to a parser. @@ -138,12 +273,12 @@ def add_isaaclab_launcher_args(parser: argparse.ArgumentParser) -> None: def enable_cameras_for_video(args_cli: argparse.Namespace) -> None: - """Enable camera rendering when video recording is requested. + """Enable camera rendering when video recording or sensor capture is requested. Args: args_cli: Parsed command-line arguments. """ - if getattr(args_cli, "video", False): + if getattr(args_cli, "video", False) or getattr(args_cli, "capture_env_sensors", 0) > 0: args_cli.enable_cameras = True @@ -250,6 +385,33 @@ def create_isaaclab_env( return env +def wrap_sensor_capture(env: gym.Env, log_dir: str, args_cli: argparse.Namespace): + """Wrap an environment with sensor capture when requested. + + Args: + env: Gymnasium environment to wrap. + log_dir: Training log directory. + args_cli: Parsed command-line arguments. + + Returns: + The original or sensor-capture-wrapped environment. + """ + if args_cli.capture_env_sensors <= 0: + return env + + output_dir = os.path.join(log_dir, "sensor_frames", "train") + sensor_capture_kwargs = { + "output_dir": output_dir, + "frame_count": args_cli.capture_env_sensors_length, + "capture_num_envs": args_cli.capture_env_sensors, + "interval": args_cli.capture_env_sensors_interval, + "output_format": args_cli.capture_env_sensors_format, + } + print("[INFO] Capturing environment sensor frames during training.") + print_dict(sensor_capture_kwargs, nesting=4) + return CaptureEnvSensors(env, **sensor_capture_kwargs) + + def wrap_record_video(env, log_dir: str, args_cli: argparse.Namespace): """Wrap an environment with video recording when requested. @@ -275,6 +437,13 @@ def wrap_record_video(env, log_dir: str, args_cli: argparse.Namespace): return gym.wrappers.RecordVideo(env, **video_kwargs) +def wrap_training_capture(env: gym.Env, log_dir: str, args_cli: argparse.Namespace) -> gym.Env: + """Apply optional video and sensor capture wrappers for training.""" + env = wrap_record_video(env, log_dir, args_cli) + env = wrap_sensor_capture(env, log_dir, args_cli) + return env + + def dump_train_configs(log_dir: str, env_cfg: Any, agent_cfg: Any) -> None: """Dump training configuration files under a run log directory. diff --git a/scripts/reinforcement_learning/rl_games/train_rl_games.py b/scripts/reinforcement_learning/rl_games/train_rl_games.py index 7a487479609..a863b9a424b 100644 --- a/scripts/reinforcement_learning/rl_games/train_rl_games.py +++ b/scripts/reinforcement_learning/rl_games/train_rl_games.py @@ -30,7 +30,7 @@ resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, - wrap_record_video, + wrap_training_capture, write_run_manifest, ) @@ -175,7 +175,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) - env = wrap_record_video(env, run_log_dir, args_cli) + env = wrap_training_capture(env, run_log_dir, args_cli) start_time = time.time() env = RlGamesVecEnvWrapper(env, rl_device, clip_obs, clip_actions, obs_groups, concate_obs_groups) diff --git a/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py b/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py index 6d30f9faa68..8f5c797cad4 100644 --- a/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py +++ b/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py @@ -30,7 +30,7 @@ resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, - wrap_record_video, + wrap_training_capture, write_run_manifest, ) from packaging import version @@ -176,7 +176,7 @@ def run(argv: list[str]) -> None: else: 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) + env = wrap_training_capture(env, log_dir, args_cli) start_time = time.time() env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) diff --git a/scripts/reinforcement_learning/sb3/train_sb3.py b/scripts/reinforcement_learning/sb3/train_sb3.py index 32032c9be8a..52b73d75f38 100644 --- a/scripts/reinforcement_learning/sb3/train_sb3.py +++ b/scripts/reinforcement_learning/sb3/train_sb3.py @@ -29,7 +29,7 @@ enable_cameras_for_video, resolve_checkpoint_selector, set_hydra_args, - wrap_record_video, + wrap_training_capture, write_run_manifest, ) @@ -140,7 +140,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) - env = wrap_record_video(env, log_dir, args_cli) + env = wrap_training_capture(env, log_dir, args_cli) start_time = time.time() env = Sb3VecEnvWrapper(env, fast_variant=not args_cli.keep_all_info) diff --git a/scripts/reinforcement_learning/skrl/train_skrl.py b/scripts/reinforcement_learning/skrl/train_skrl.py index 1be36b42e84..7ca754f6493 100644 --- a/scripts/reinforcement_learning/skrl/train_skrl.py +++ b/scripts/reinforcement_learning/skrl/train_skrl.py @@ -27,7 +27,7 @@ resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, - wrap_record_video, + wrap_training_capture, write_run_manifest, ) from packaging import version @@ -197,7 +197,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg) and algorithm in ["ppo"], ) - env = wrap_record_video(env, log_dir, args_cli) + env = wrap_training_capture(env, log_dir, args_cli) start_time = time.time() env = SkrlVecEnvWrapper(env, ml_framework=args_cli.ml_framework) diff --git a/source/isaaclab/changelog.d/capture-env-sensors-tests.skip b/source/isaaclab/changelog.d/capture-env-sensors-tests.skip new file mode 100644 index 00000000000..a34e75ee2c7 --- /dev/null +++ b/source/isaaclab/changelog.d/capture-env-sensors-tests.skip @@ -0,0 +1 @@ +Tests only. diff --git a/source/isaaclab/isaaclab/utils/images.py b/source/isaaclab/isaaclab/utils/images.py index f832af8381e..19361e73b7a 100644 --- a/source/isaaclab/isaaclab/utils/images.py +++ b/source/isaaclab/isaaclab/utils/images.py @@ -94,3 +94,28 @@ def normalize_camera_image( if is_normals_like(data_type): return (images + 1.0) * 0.5 return images + + +def normalize_camera_output_for_display(tensor: torch.Tensor, data_type: str) -> torch.Tensor: + """Convert camera output tensor to [0, 1] float32 for conversion to image.""" + normalized = tensor.float() + + if data_type in ["depth", "distance_to_camera", "distance_to_image_plane"]: + max_val = normalized.max() + if max_val > 0: + normalized = normalized / max_val + elif data_type in {"albedo"}: + normalized = normalized[..., :3] / 255.0 + elif data_type in {"normals"}: + normalized = (normalized + 1.0) * 0.5 + else: + normalized = normalized / 255.0 + + return normalized + + +def make_camera_output_grid(images: torch.Tensor) -> torch.Tensor: + """Make a grid of images from a tensor of shape (B, H, W, C).""" + from torchvision.utils import make_grid + + return make_grid(torch.swapaxes(images.unsqueeze(1), 1, -1).squeeze(-1), nrow=round(images.shape[0] ** 0.5)) diff --git a/source/isaaclab/test/test_reinforcement_learning_common.py b/source/isaaclab/test/test_reinforcement_learning_common.py new file mode 100644 index 00000000000..4f563e90ca7 --- /dev/null +++ b/source/isaaclab/test/test_reinforcement_learning_common.py @@ -0,0 +1,219 @@ +# 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 shared reinforcement learning script utilities.""" + +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Any + +import gymnasium as gym +import pytest +import torch + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _load_rl_common_module() -> ModuleType: + module_path = _repo_root() / "scripts" / "reinforcement_learning" / "common.py" + spec = importlib.util.spec_from_file_location("isaaclab_test_reinforcement_learning_common", module_path) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load reinforcement learning common module from {module_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +_rl_common = _load_rl_common_module() +CaptureEnvSensors: Any = getattr(_rl_common, "CaptureEnvSensors") +add_common_train_args: Any = getattr(_rl_common, "add_common_train_args") +enable_cameras_for_video: Any = getattr(_rl_common, "enable_cameras_for_video") +wrap_sensor_capture: Any = getattr(_rl_common, "wrap_sensor_capture") + + +class _FakeEnv(gym.Env): + """Minimal Gymnasium env exposing an IsaacLab-style scene sensor mapping.""" + + def __init__(self, sensors: dict[str, Any] | None = None) -> None: + self.scene = SimpleNamespace(sensors=sensors or {}) + self.closed = False + + def reset(self, **kwargs: Any) -> tuple[dict[str, torch.Tensor], dict[str, Any]]: + return {"obs": torch.zeros(1)}, {} + + def step(self, action: Any) -> tuple[dict[str, torch.Tensor], float, bool, bool, dict[str, Any]]: + return {"obs": torch.ones(1)}, 0.0, False, False, {} + + def close(self) -> None: + self.closed = True + + +def _make_sensor(output: dict[str, Any]) -> SimpleNamespace: + return SimpleNamespace(data=SimpleNamespace(output=output)) + + +def _make_capture_wrapper(tmp_path: Path, **kwargs: Any) -> Any: + defaults = { + "env": _FakeEnv(), + "output_dir": str(tmp_path), + "frame_count": 1, + "capture_num_envs": 1, + "interval": 1, + "output_format": "file", + } + defaults.update(kwargs) + return CaptureEnvSensors(**defaults) + + +def test_capture_env_sensors_saves_file_outputs_on_scheduled_steps( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """File capture writes image grids during the active capture window.""" + rgb = torch.tensor( + [ + [[[0, 127, 255, 9], [255, 0, 127, 9]]], + [[[42, 42, 42, 9], [43, 43, 43, 9]]], + ], + dtype=torch.uint8, + ) + env = _FakeEnv({"front/camera": _make_sensor({"rgb": rgb})}) + saved_images: list[Any] = [] + saved_paths: list[Path] = [] + + class _FakeImage: + def __init__(self, image: Any) -> None: + self.image = image + + def save(self, path: str) -> None: + saved_images.append(self.image.copy()) + saved_paths.append(Path(path)) + + monkeypatch.setattr(_rl_common.Image, "fromarray", _FakeImage) + wrapper = _make_capture_wrapper( + tmp_path, + env=env, + frame_count=2, + capture_num_envs=1, + interval=3, + ) + + wrapper.reset() + wrapper.step(None) + wrapper.step(None) + wrapper.step(None) + + relative_paths = [path.relative_to(tmp_path).as_posix() for path in saved_paths] + assert relative_paths == [ + "front_camera/rgb/episode_00001_step_00000000.png", + "front_camera/rgb/episode_00001_step_00000001.png", + "front_camera/rgb/episode_00001_step_00000003.png", + ] + assert all(image.shape == (1, 2, 4) for image in saved_images) + assert all((image == rgb[0].numpy()).all() for image in saved_images) + + +def test_capture_env_sensors_accepts_proxyarray_torch_buffers(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """ProxyArray-style buffers are read through their ``.torch`` accessor.""" + image_buffer = SimpleNamespace(torch=torch.ones((3, 2, 2, 4), dtype=torch.float32)) + env = _FakeEnv({"camera": _make_sensor({"rgb": image_buffer})}) + captured_tensors: list[torch.Tensor] = [] + + def fake_normalize(tensor: torch.Tensor, data_type: str) -> torch.Tensor: + captured_tensors.append(tensor.clone()) + return tensor + + monkeypatch.setattr(_rl_common, "normalize_camera_output_for_display", fake_normalize) + monkeypatch.setattr(_rl_common, "make_camera_output_grid", lambda images: torch.zeros((4, 1, 1))) + wrapper = _make_capture_wrapper(tmp_path, env=env, capture_num_envs=2) + + wrapper.reset() + + assert len(captured_tensors) == 1 + assert captured_tensors[0].shape == (2, 2, 2, 4) + + +def test_capture_env_sensors_skips_none_outputs(tmp_path: Path) -> None: + """Missing sensor outputs are skipped instead of being written.""" + env = _FakeEnv({"camera": _make_sensor({"rgb": None})}) + wrapper = _make_capture_wrapper(tmp_path, env=env) + + wrapper.reset() + + assert not any(tmp_path.rglob("*.png")) + + +def test_capture_env_sensors_rejects_unknown_output_format(tmp_path: Path) -> None: + """Only tensorboard and file output formats are supported.""" + with pytest.raises(ValueError, match="Unsupported sensor capture output format"): + _make_capture_wrapper(tmp_path, output_format="invalid") + + +def test_wrap_sensor_capture_uses_training_sensor_frame_directory(tmp_path: Path) -> None: + """The train helper wraps the env with the configured sensor capture output directory.""" + env = _FakeEnv() + args_cli = argparse.Namespace( + capture_env_sensors=2, + capture_env_sensors_length=5, + capture_env_sensors_interval=7, + capture_env_sensors_format="file", + ) + + wrapped_env = wrap_sensor_capture(env, str(tmp_path), args_cli) + + assert isinstance(wrapped_env, CaptureEnvSensors) + assert Path(wrapped_env.output_dir) == tmp_path / "sensor_frames" / "train" + assert wrapped_env.frame_count == 5 + assert wrapped_env.capture_num_envs == 2 + assert wrapped_env.interval == 7 + assert wrapped_env.env is env + + +def test_wrap_sensor_capture_returns_env_when_disabled(tmp_path: Path) -> None: + """The train helper leaves the env unwrapped when sensor capture is disabled.""" + env = _FakeEnv() + args_cli = argparse.Namespace(capture_env_sensors=0) + + assert wrap_sensor_capture(env, str(tmp_path), args_cli) is env + + +def test_common_train_args_include_sensor_capture_options() -> None: + """Common train parsers expose sensor capture CLI arguments.""" + parser = argparse.ArgumentParser() + add_common_train_args(parser, agent_default=None, agent_help="", include_agent=False) + + args_cli = parser.parse_args( + [ + "--capture_env_sensors", + "3", + "--capture_env_sensors_length", + "4", + "--capture_env_sensors_interval", + "5", + "--capture_env_sensors_format", + "file", + ] + ) + + assert args_cli.capture_env_sensors == 3 + assert args_cli.capture_env_sensors_length == 4 + assert args_cli.capture_env_sensors_interval == 5 + assert args_cli.capture_env_sensors_format == "file" + + +def test_enable_cameras_for_video_enables_cameras_for_sensor_capture() -> None: + """Sensor capture requires camera rendering even when normal video capture is disabled.""" + args_cli = argparse.Namespace(video=False, capture_env_sensors=1, enable_cameras=False) + + enable_cameras_for_video(args_cli) + + assert args_cli.enable_cameras diff --git a/source/isaaclab/test/utils/test_images.py b/source/isaaclab/test/utils/test_images.py index 92bb3e7de9d..881961b5d01 100644 --- a/source/isaaclab/test/utils/test_images.py +++ b/source/isaaclab/test/utils/test_images.py @@ -180,3 +180,42 @@ def test_unknown_type_passthrough(self, device, data_type): src = torch.ones((2, 4, 4, 3), device=device) out = normalize_camera_image(src, data_type) assert out is src + + +class TestNormalizeCameraOutputForDisplay: + """Display normalization for capture and golden-image workflows.""" + + def test_rgb_scales_to_unit_range(self, device): + from isaaclab.utils.images import normalize_camera_output_for_display + + src = torch.tensor([[[[0.0, 127.0, 255.0]]]], device=device) + out = normalize_camera_output_for_display(src, "rgb") + expected = torch.tensor([[[[0.0, 127.0 / 255.0, 1.0]]]], device=device) + torch.testing.assert_close(out, expected) + + def test_depth_scales_by_max(self, device): + from isaaclab.utils.images import normalize_camera_output_for_display + + src = torch.tensor([[[[0.0], [2.0], [4.0]]]], device=device) + out = normalize_camera_output_for_display(src, "distance_to_camera") + expected = torch.tensor([[[[0.0], [0.5], [1.0]]]], device=device) + torch.testing.assert_close(out, expected) + + def test_albedo_keeps_rgb_channels(self, device): + from isaaclab.utils.images import normalize_camera_output_for_display + + src = torch.tensor([[[[255.0, 128.0, 64.0, 9.0]]]], device=device) + out = normalize_camera_output_for_display(src, "albedo") + expected = torch.tensor([[[[1.0, 128.0 / 255.0, 64.0 / 255.0]]]], device=device) + torch.testing.assert_close(out, expected) + + +class TestMakeCameraOutputGrid: + """Grid composition for multi-env camera capture.""" + + def test_single_batch_produces_channel_first_grid(self, device): + from isaaclab.utils.images import make_camera_output_grid + + images = torch.ones((1, 2, 3, 3), device=device) + grid = make_camera_output_grid(images) + assert grid.shape == (3, 2, 3) diff --git a/source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip b/source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip new file mode 100644 index 00000000000..b408a2fb78f --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip @@ -0,0 +1 @@ +Tests only: refactored rendering test utils to use shared camera image helpers. diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index a4424f645b4..e5bce7ecebe 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -14,6 +14,7 @@ import torch from PIL import Image, ImageChops +from isaaclab.utils.images import make_camera_output_grid, normalize_camera_output_for_display from isaaclab.utils.warp import ProxyArray # Directory containing golden images. @@ -246,24 +247,6 @@ def _physics_preset_name(physics_backend: str) -> str: return "newton_mjwarp" if physics_backend == "newton" else physics_backend -def _normalize_tensor(tensor: torch.Tensor, data_type: str) -> torch.Tensor: - """Convert camera output tensor to [0, 1] float32 for conversion to image.""" - normalized = tensor.float() - - if data_type in ["depth", "distance_to_camera", "distance_to_image_plane"]: - max_val = normalized.max() - if max_val > 0: - normalized = normalized / max_val - elif data_type in {"albedo"}: - normalized = normalized[..., :3] / 255.0 - elif data_type in {"normals"}: - normalized = (normalized + 1.0) * 0.5 - else: - normalized = normalized / 255.0 - - return normalized - - def _save_comparison_image(img: Image.Image, filename: str) -> str: """Save a PIL image under the comparison images directory.""" path = os.path.join(_COMPARISON_IMAGES_DIR, _COMPARISON_IMAGE_SUBDIR, filename) @@ -473,13 +456,6 @@ def _require_ovlibs_install(request, monkeypatch: pytest.MonkeyPatch): return _require_ovlibs_install -def _make_grid(images: torch.Tensor) -> torch.Tensor: - """Make a grid of images from a tensor of shape (B, H, W, C).""" - from torchvision.utils import make_grid - - return make_grid(torch.swapaxes(images.unsqueeze(1), 1, -1).squeeze(-1), nrow=round(images.shape[0] ** 0.5)) - - def _ssim(img1: torch.Tensor, img2: torch.Tensor, window_size: int = 11) -> float: """Compute mean SSIM between two (1, C, H, W) float tensors in [0, 1].""" c1 = 0.01**2 @@ -585,8 +561,8 @@ def validate_camera_outputs( failed_data_types[data_type] = f"Camera output '{data_type}' has no non-zero pixels." continue - normalized = _normalize_tensor(corrected, data_type) - grid = _make_grid(normalized) + normalized = normalize_camera_output_for_display(corrected, data_type) + grid = make_camera_output_grid(normalized) ndarr = grid.mul(255).add_(0.5).clamp_(0, 255).permute(1, 2, 0).to("cpu", torch.uint8).numpy() result_image = Image.fromarray(ndarr) From 1a9971ed58ba49de4b9cdc6da8c921799e1d5922 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Fri, 3 Jul 2026 21:19:08 -0400 Subject: [PATCH 076/139] [IsaacLab CI] Smoke test marker (#6347) # Description Adds the smoke test marker for annotating smoke tests which should always be run in the pipeline - Register a shared `smoke` pytest marker in the install-CI `pytest.ini` and `conftest.py`. - Tag existing installation, contrib-environment, and rigid/deformable coupling smoke tests with `@pytest.mark.smoke`. - Lets core installation, task, and RL smoke functionality checks be selected as a group via `-m smoke`. Tests named with `_smoke` have been marked: Other tests can be marked respectively if they should be classified as smoke. 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 --- pyproject.toml | 1 + source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip | 3 +++ .../test/install_ci/cli/test_cli_install_in_globalenv_smoke.py | 1 + .../test/install_ci/cli/test_cli_install_in_uvenv_smoke.py | 1 + source/isaaclab/test/install_ci/conftest.py | 1 + .../isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py | 1 + source/isaaclab/test/install_ci/pytest.ini | 1 + ...est_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py | 1 + .../changelog.d/mataylor-smoke-test-marker.skip | 2 ++ .../test/deformable/test_rigid_deformable_coupling.py | 1 + .../isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip | 2 ++ .../test/contrib/test_contrib_environments_smoke.py | 1 + 12 files changed, 16 insertions(+) create mode 100644 source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip create mode 100644 source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip create mode 100644 source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip diff --git a/pyproject.toml b/pyproject.toml index f40737cf55a..caf6b793a9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,6 +199,7 @@ markers = [ "device_split: re-invoke this file once per device (CPU and GPU) in CI due to process-global device locks (e.g., ovphysx<=0.3.7 gap G5)", "windows_ci: mark test to run on Windows platforms in CI", "arm_ci: mark test to run on ARM platforms in CI (e.g. NVIDIA DGX Spark)", + "smoke: tests for core installation, task, and RL functionality", ] # Add pypi.nvidia.com so that `uv pip install isaaclab[isaacsim]` works without --extra-index-url. diff --git a/source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip b/source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip new file mode 100644 index 00000000000..8cc2b98fdf9 --- /dev/null +++ b/source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip @@ -0,0 +1,3 @@ +Test-only: add a ``smoke`` pytest marker and register it, then tag the +install-CI smoke tests with it so quick installation smoke checks can be +selected as a group. diff --git a/source/isaaclab/test/install_ci/cli/test_cli_install_in_globalenv_smoke.py b/source/isaaclab/test/install_ci/cli/test_cli_install_in_globalenv_smoke.py index 1d39de3137a..10d0ff5ff34 100644 --- a/source/isaaclab/test/install_ci/cli/test_cli_install_in_globalenv_smoke.py +++ b/source/isaaclab/test/install_ci/cli/test_cli_install_in_globalenv_smoke.py @@ -19,6 +19,7 @@ from utils import run_cmd +@pytest.mark.smoke class Test_Cli_Install_In_Globalenv_Smoke: """./isaaclab.sh -i with no uv/conda env active (system Python).""" diff --git a/source/isaaclab/test/install_ci/cli/test_cli_install_in_uvenv_smoke.py b/source/isaaclab/test/install_ci/cli/test_cli_install_in_uvenv_smoke.py index 322c4b29352..7de51d7afc7 100644 --- a/source/isaaclab/test/install_ci/cli/test_cli_install_in_uvenv_smoke.py +++ b/source/isaaclab/test/install_ci/cli/test_cli_install_in_uvenv_smoke.py @@ -33,6 +33,7 @@ def _skip_if_isaacsim_unavailable() -> None: pytest.skip("isaacsim is not importable and _isaac_sim link not found, skipping") +@pytest.mark.smoke class Test_Cli_Install_In_Uvenv_Smoke(UV_Mixin): """./isaaclab.sh -u/-i smoke checks plus optional submodule (mimic) and feature (newton) installs.""" diff --git a/source/isaaclab/test/install_ci/conftest.py b/source/isaaclab/test/install_ci/conftest.py index 747330dfd84..d1dc50ab486 100644 --- a/source/isaaclab/test/install_ci/conftest.py +++ b/source/isaaclab/test/install_ci/conftest.py @@ -100,6 +100,7 @@ def pytest_addoption(parser: pytest.Parser) -> None: def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line("markers", "smoke: tests for core installation, task, and RL functionality") config.addinivalue_line("markers", "bug: bug-regression tests (use bug id as argument)") config.addinivalue_line("markers", "gpu: tests that require a GPU") config.addinivalue_line("markers", "docker: tests that only run inside Docker") diff --git a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py index e111b0fec3c..fe79a384228 100644 --- a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py +++ b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py @@ -33,6 +33,7 @@ from utils import UV_Mixin, run_cmd +@pytest.mark.smoke class Test_Wheel_Builder_Smoke(UV_Mixin): """Test building the isaaclab wheel and installing it in a uv environment.""" diff --git a/source/isaaclab/test/install_ci/pytest.ini b/source/isaaclab/test/install_ci/pytest.ini index ab7e9d50137..93b6cec4183 100644 --- a/source/isaaclab/test/install_ci/pytest.ini +++ b/source/isaaclab/test/install_ci/pytest.ini @@ -6,6 +6,7 @@ python_files = *_test.py *_tests.py markers = + smoke: tests for core installation, task, and RL functionality bug: regression tests (bug id as argument) gpu: tests that require a GPU docker: tests that ONLY run inside Docker diff --git a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py index eab7d5ba937..7f1355840f3 100644 --- a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py +++ b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py @@ -58,6 +58,7 @@ def setup_class(cls): pytest.skip("uv is not available") @pytest.mark.docker + @pytest.mark.smoke @pytest.mark.uv @pytest.mark.slow @pytest.mark.gpu diff --git a/source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip b/source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip new file mode 100644 index 00000000000..797f40bfd46 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip @@ -0,0 +1,2 @@ +Test-only: tag the rigid/deformable coupling smoke test with the shared +``smoke`` pytest marker. diff --git a/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py b/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py index d835f8fb458..d9c6cbc67df 100644 --- a/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py +++ b/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py @@ -219,6 +219,7 @@ def generate_lateral_rigid_and_deformable_cubes( return rigid_cube, deformable_cube +@pytest.mark.smoke @pytest.mark.parametrize( "sim", [("featherstone", "kinematic")], diff --git a/source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip b/source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip new file mode 100644 index 00000000000..8487220a16c --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip @@ -0,0 +1,2 @@ +Test-only: tag the contrib environments smoke test with the shared ``smoke`` +pytest marker. diff --git a/source/isaaclab_tasks/test/contrib/test_contrib_environments_smoke.py b/source/isaaclab_tasks/test/contrib/test_contrib_environments_smoke.py index ae0c8382f5a..ea1ae57d9fc 100644 --- a/source/isaaclab_tasks/test/contrib/test_contrib_environments_smoke.py +++ b/source/isaaclab_tasks/test/contrib/test_contrib_environments_smoke.py @@ -37,6 +37,7 @@ from env_test_utils import _run_environments, setup_environment # isort: skip +@pytest.mark.smoke @pytest.mark.parametrize("num_envs, device", [(2, "cuda")]) @pytest.mark.parametrize( "task_name", From 142541711356ad494e53884c21b40dc57328fbea Mon Sep 17 00:00:00 2001 From: vidurv-nvidia Date: Fri, 3 Jul 2026 21:57:11 -0700 Subject: [PATCH 077/139] Adding Rigid Body Material USD data classes and writers (#6287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Converts **rigid-body physics materials** to the single-namespace "fragment" model already used by the rigid-body / collision / mass / mesh / tendon / joint-drive / articulation families. Additive and backward-compatible — the legacy inheritance cfgs remain as deprecated shims. The one material-specific twist vs. the schema families: a physics material is **spawned as its own `UsdShade.Material` prim and bound** (not applied onto the body prim). So the family writer both spawns the prim + applies the anchor *and* dispatches the fragment list. ### Added - **Core** (`isaaclab.sim.spawners.materials`): - `RigidBodyMaterialFragment` — marker base typing the `physics_material` slot. - `UsdPhysicsRigidBodyMaterialCfg` — solver-common `physics:*` friction/restitution (anchor `UsdPhysics.MaterialAPI`). - `spawn_rigid_body_material_from_fragments(prim_path, fragments, stage)` — spawns the `UsdShade.Material` prim, applies the `MaterialAPI` anchor, dispatches each fragment's `func` (default `apply_namespaced`). - `spawn_physics_material(prim_path, material, stage)` — slot dispatcher: fragment list → fragment writer, else legacy cfg via its own `func`. - **PhysX** (`isaaclab_physx.sim.spawners.materials`): - `PhysxMaterialCfg` — single-namespace `physxMaterial:*` (`PhysxMaterialAPI`): compliant-contact spring + combine-mode tokens. ### Changed - Spawner `physics_material` slots (`shapes`, `meshes`, `from_files`) now accept `RigidBodyMaterialFragment | list[...]` in addition to the legacy material cfg; consume sites route through `spawn_physics_material`. **Legacy single-cfg path unchanged.** ### Scope - **Rigid-body materials only.** Deformable-body materials are deferred — they pair with the deferred deformable-body family (multi-inherited OmniPhysics + PhysX material APIs). ## Tests - New `test_material_fragments.py` (6): fragment metadata; spawn-from-fragments composes `physics:*` + `physxMaterial:*` with the `MaterialAPI` anchor + `PhysxMaterialAPI`; single-fragment; partial-update (None left unauthored); slot dispatcher handles both fragment and legacy forms. - Regression: existing `test_spawn_materials.py` (6) and `test_spawn_shapes.py` (12) pass — legacy path intact. ## Checklist - [x] Ran `./isaaclab.sh --format` - [x] Added changelog fragments (core + physx, minor) --------- Co-authored-by: Octi Zhang --- docs/source/api/lab/isaaclab.sim.spawners.rst | 22 + .../isaaclab_newton.sim.spawners.rst | 9 + .../lab_physx/isaaclab_physx.sim.spawners.rst | 15 + .../vidurv-schema-frag-materials.minor.rst | 45 ++ .../sim/spawners/from_files/from_files.py | 7 +- .../sim/spawners/from_files/from_files_cfg.py | 25 +- .../sim/spawners/materials/__init__.pyi | 13 +- .../spawners/materials/physics_materials.py | 136 ++++- .../materials/physics_materials_cfg.py | 62 ++- .../isaaclab/sim/spawners/meshes/meshes.py | 22 +- .../sim/spawners/meshes/meshes_cfg.py | 11 +- .../isaaclab/sim/spawners/shapes/shapes.py | 5 +- .../sim/spawners/shapes/shapes_cfg.py | 11 +- .../isaaclab/terrains/terrain_importer_cfg.py | 10 +- source/isaaclab/isaaclab/terrains/utils.py | 11 +- .../test/sim/test_material_fragments.py | 523 ++++++++++++++++++ .../vidurv-schema-frag-materials.minor.rst | 21 + .../isaaclab_newton/sim/__init__.pyi | 2 + .../sim/schemas/schemas_cfg.py | 29 + .../sim/spawners/materials/__init__.pyi | 2 + .../materials/physics_materials_cfg.py | 71 +++ .../test/sim/test_newton_schemas.py | 79 +++ .../vidurv-schema-frag-materials.minor.rst | 12 + .../sim/spawners/materials/__init__.pyi | 2 + .../materials/physics_materials_cfg.py | 88 ++- 25 files changed, 1200 insertions(+), 33 deletions(-) create mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst create mode 100644 source/isaaclab/test/sim/test_material_fragments.py create mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst create mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst diff --git a/docs/source/api/lab/isaaclab.sim.spawners.rst b/docs/source/api/lab/isaaclab.sim.spawners.rst index d42fa13e524..ce82c003e8e 100644 --- a/docs/source/api/lab/isaaclab.sim.spawners.rst +++ b/docs/source/api/lab/isaaclab.sim.spawners.rst @@ -267,6 +267,9 @@ Materials MdlFileCfg GlassMdlCfg PhysicsMaterialCfg + RigidBodyMaterialBaseCfg + RigidBodyMaterialFragment + UsdPhysicsRigidBodyMaterialCfg RigidBodyMaterialCfg DeformableBodyMaterialBaseCfg SurfaceDeformableBodyMaterialBaseCfg @@ -303,8 +306,27 @@ Physical Materials :members: :exclude-members: __init__, func +.. autofunction:: spawn_physics_material + .. autofunction:: spawn_rigid_body_material +.. autoclass:: RigidBodyMaterialBaseCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autofunction:: spawn_rigid_body_material_from_fragments + +.. autoclass:: RigidBodyMaterialFragment + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: UsdPhysicsRigidBodyMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + .. autoclass:: RigidBodyMaterialCfg :members: :exclude-members: __init__, func diff --git a/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst b/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst index f42cef3d816..2db0a162281 100644 --- a/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst +++ b/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst @@ -7,10 +7,19 @@ isaaclab_newton.sim.spawners .. autosummary:: + NewtonMaterialCfg NewtonDeformableBodyMaterialCfg NewtonDeformableMaterialCfg NewtonSurfaceDeformableBodyMaterialCfg +Rigid Materials +--------------- + +.. autoclass:: NewtonMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + Deformable Materials -------------------- 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 7a2ace2a33f..3e2355d141e 100644 --- a/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst +++ b/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst @@ -7,12 +7,27 @@ isaaclab_physx.sim.spawners .. autosummary:: + PhysxRigidBodyMaterialCfg + PhysxMaterialCfg PhysxDeformableBodyMaterialCfg PhysxSurfaceDeformableBodyMaterialCfg PhysXDeformableMaterialCfg DeformableBodyMaterialCfg SurfaceDeformableBodyMaterialCfg +Rigid Materials +--------------- + +.. autoclass:: PhysxRigidBodyMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: PhysxMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + Deformable Materials -------------------- diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst new file mode 100644 index 00000000000..1cfe675cb0b --- /dev/null +++ b/source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst @@ -0,0 +1,45 @@ +Added +^^^^^ + +* Added the rigid-body physics-material "fragment" classes + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` (marker base) and + :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg` (solver-common + ``physics:*`` friction/restitution/density), plus the family writer + :func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments` and the slot + dispatcher :func:`~isaaclab.sim.spawners.materials.spawn_physics_material`. Relevant + ``physics_material`` slots now accept a single fragment or list alongside their legacy cfg form. + Legacy material cfgs are current-stage-only: the dispatcher raises ``ValueError`` for an explicit + non-current stage, while fragment-based materials support explicit-stage authoring. + +* Added :attr:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg.density` (writes + ``physics:density``), completing the fragment's coverage of ``UsdPhysics.MaterialAPI``. +* Added :attr:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg.density`, so the legacy + rigid material base authors every attribute the fragment authors. + +Changed +^^^^^^^ + +* Narrowed :attr:`~isaaclab.sim.spawners.ShapeCfg.physics_material` from the broad + :class:`~isaaclab.sim.spawners.materials.PhysicsMaterialCfg` to the rigid material base, a single + rigid-material fragment, or a list of fragments. +* Extended the already rigid-only :attr:`~isaaclab.sim.spawners.GroundPlaneCfg.physics_material` + and :attr:`~isaaclab.terrains.TerrainImporterCfg.physics_material` slots from the legacy + :class:`~isaaclab_physx.sim.spawners.materials.RigidBodyMaterialCfg` type to + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`, a single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of fragments. + Deformable materials remain accepted where deformables can spawn + (:class:`~isaaclab.sim.spawners.FileCfg` and :class:`~isaaclab.sim.spawners.MeshCfg`). +* Changed the generated-terrain material path to use + :func:`~isaaclab.sim.spawners.materials.spawn_physics_material`, enabling fragment lists there. + +Fixed +^^^^^ + +* Fixed mesh spawners rejecting valid rigid physics materials: the rigid-material check compared + against the deprecated leaf class, so canonical + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` and + :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg` instances raised + ``ValueError`` on rigid meshes. The check now accepts any + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`. +* Fixed malformed fragment inputs reaching an opaque legacy ``func`` call. Direct and dispatched + fragment calls now reject empty, mixed, and non-fragment inputs before creating a material prim. 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 d9f789b666f..80b9f940e41 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -16,6 +16,7 @@ from isaaclab.sim import converters, schemas from isaaclab.sim.spawners.materials import SurfaceDeformableBodyMaterialBaseCfg +from isaaclab.sim.spawners.materials.physics_materials import spawn_physics_material from isaaclab.sim.utils import ( add_labels, bind_physics_material, @@ -211,7 +212,7 @@ def spawn_ground_plane( # Create physics material if cfg.physics_material is not None: - cfg.physics_material.func(f"{prim_path}/physicsMaterial", cfg.physics_material) + spawn_physics_material(f"{prim_path}/physicsMaterial", cfg.physics_material, stage=stage) # Apply physics material to ground plane collision_prim = get_first_matching_child_prim( prim_path, @@ -478,8 +479,8 @@ def _spawn_from_usd_file( material_path = f"{prim_path}/{cfg.physics_material_path}" else: material_path = cfg.physics_material_path - # create material - cfg.physics_material.func(material_path, cfg.physics_material) + # create material (accepts a legacy material cfg or rigid-body fragment(s)) + spawn_physics_material(material_path, cfg.physics_material, stage=stage) # apply material bind_physics_material(prim_path, material_path, stage=stage) 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 ea2cfe9b6c9..1c9c26e0252 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 @@ -106,9 +106,18 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): This parameter is ignored if `physics_material` is not None. """ - physics_material: materials.PhysicsMaterialCfg | None = None + physics_material: ( + materials.PhysicsMaterialCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + | None + ) = None """Physics material properties. + Accepts either a legacy material cfg, a single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of such + single-namespace fragments. + Note: If None, then no custom physics material will be added. """ @@ -259,5 +268,15 @@ class GroundPlaneCfg(SpawnerCfg): size: tuple[float, float] = (100.0, 100.0) """The size of the ground plane. Defaults to 100 m x 100 m.""" - physics_material: materials.RigidBodyMaterialCfg = materials.RigidBodyMaterialCfg() - """Physics material properties. Defaults to the default rigid body material.""" + physics_material: ( + materials.RigidBodyMaterialBaseCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + ) = materials.RigidBodyMaterialCfg() + """Physics material properties. Defaults to the default rigid body material. + + The ground plane only spawns a collision plane, so this only accepts rigid-body materials: a + legacy :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`, a single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of such + single-namespace fragments. + """ diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi index 7ce7815955c..305c980d231 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi @@ -5,9 +5,13 @@ __all__ = [ "spawn_rigid_body_material", + "spawn_rigid_body_material_from_fragments", + "spawn_physics_material", "spawn_deformable_body_material", "PhysicsMaterialCfg", "RigidBodyMaterialBaseCfg", + "RigidBodyMaterialFragment", + "UsdPhysicsRigidBodyMaterialCfg", "DeformableBodyMaterialBaseCfg", "DeformableBodyMaterialCfg", "SurfaceDeformableBodyMaterialBaseCfg", @@ -20,10 +24,17 @@ __all__ = [ "VisualMaterialCfg", ] -from .physics_materials import spawn_rigid_body_material, spawn_deformable_body_material +from .physics_materials import ( + spawn_deformable_body_material, + spawn_physics_material, + spawn_rigid_body_material, + spawn_rigid_body_material_from_fragments, +) from .physics_materials_cfg import ( PhysicsMaterialCfg, RigidBodyMaterialBaseCfg, + RigidBodyMaterialFragment, + UsdPhysicsRigidBodyMaterialCfg, DeformableBodyMaterialBaseCfg, DeformableBodyMaterialCfg, SurfaceDeformableBodyMaterialBaseCfg, diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py index 8c12bee9442..51f19226bb1 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py @@ -12,10 +12,138 @@ 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 string_to_callable from . import physics_materials_cfg +def spawn_rigid_body_material_from_fragments( + prim_path: str, + fragments: physics_materials_cfg.RigidBodyMaterialFragment + | list[physics_materials_cfg.RigidBodyMaterialFragment] + | tuple[physics_materials_cfg.RigidBodyMaterialFragment, ...], + stage: Usd.Stage | None = None, +) -> Usd.Prim: + """Spawn a rigid-body physics material from one or more single-namespace fragments. + + Creates (or reuses) the ``UsdShade.Material`` prim at ``prim_path``, applies the standard + ``UsdPhysics.MaterialAPI`` anchor, then dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func` to author its namespace onto the material prim. + Backend fragments carry backend-specific namespaces (e.g. PhysX ``physxMaterial:*``) without core + importing a backend. + + .. note:: + Unlike the ``@clone``-decorated :func:`spawn_rigid_body_material`, this writer expects a + concrete ``prim_path`` and does not resolve regex prim-path patterns. Physics materials are + spawned at a single derived path by :func:`spawn_physics_material` and the spawner internals, + so regex resolution does not apply here. + + Args: + prim_path: The prim path to spawn the material at. + fragments: A single :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, + or a list/tuple of them. + stage: The stage to spawn on. Defaults to None, in which case the current stage is used. + + Returns: + The spawned rigid body material prim. + + Raises: + ValueError: When ``fragments`` is empty. + ValueError: When a prim already exists at the path and is not a material. + TypeError: When an item is not a rigid-body material fragment. + """ + if not isinstance(fragments, (list, tuple)): + fragments = [fragments] + else: + fragments = list(fragments) + if not fragments: + raise ValueError(f"Cannot spawn a physics material at '{prim_path}' from an empty fragment collection.") + invalid_types = [ + type(fragment).__name__ + for fragment in fragments + if not isinstance(fragment, physics_materials_cfg.RigidBodyMaterialFragment) + ] + if invalid_types: + raise TypeError( + "A physics-material fragment collection must contain only RigidBodyMaterialFragment instances; got" + f" {invalid_types}." + ) + if stage is None: + stage = get_current_stage() + + # create the material prim if none exists yet + if not stage.GetPrimAtPath(prim_path).IsValid(): + UsdShade.Material.Define(stage, prim_path) + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsA(UsdShade.Material): + raise ValueError(f"A prim already exists at path: '{prim_path}' but is not a material.") + + # apply the standard UsdPhysics MaterialAPI anchor (the defining schema for a physics material) + if not UsdPhysics.MaterialAPI(prim): + UsdPhysics.MaterialAPI.Apply(prim) + + # dispatch each fragment's applier (writes its single namespace onto the material prim) + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, prim_path, stage) + return prim + + +def spawn_physics_material( + prim_path: str, + material: physics_materials_cfg.PhysicsMaterialCfg + | physics_materials_cfg.RigidBodyMaterialFragment + | list[physics_materials_cfg.RigidBodyMaterialFragment] + | tuple[physics_materials_cfg.RigidBodyMaterialFragment, ...], + stage: Usd.Stage | None = None, +) -> Usd.Prim: + """Spawn a physics material from a spawner ``physics_material`` slot value. + + Dispatches the two accepted slot forms: a single rigid-body fragment or list/tuple is spawned via + :func:`spawn_rigid_body_material_from_fragments`; otherwise the value is a legacy material cfg and is + spawned via its own :attr:`func`. Lets spawners accept both the fragment and the legacy interface + from one call site. + + Args: + prim_path: The prim path to spawn the material at. + material: A :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` (or list/tuple + of them), or a legacy material cfg carrying a :attr:`func`. + stage: The stage to spawn on. Defaults to None, in which case the current stage is used. A + legacy material cfg only supports the current stage (or None); passing a different + stage raises. + Returns: + The spawned material prim. + + Raises: + ValueError: When ``material`` is an empty fragment collection. + ValueError: When ``material`` is a legacy material cfg and ``stage`` is neither ``None`` + nor the current stage. + TypeError: When ``material`` is a fragment collection containing anything other than + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` instances. + TypeError: When ``material`` is neither a physics-material cfg nor a rigid-body fragment. + """ + # The family writer owns fragment validation so direct and dispatched calls have one contract. + if isinstance(material, (list, tuple, physics_materials_cfg.RigidBodyMaterialFragment)): + return spawn_rigid_body_material_from_fragments(prim_path, material, stage) + if not isinstance(material, physics_materials_cfg.PhysicsMaterialCfg): + raise TypeError( + "A physics material must be a PhysicsMaterialCfg or rigid-body material fragment; got" + f" '{type(material).__name__}'." + ) + # legacy single-cfg path (rigid or deformable material cfg with its own spawner ``func``). + # Legacy material funcs take only ``(prim_path, cfg)`` and resolve the stage internally via + # ``get_current_stage()`` (their path matching is also current-stage-bound), so an explicit + # different stage cannot be honored on this path -- reject it loudly rather than authoring on + # the wrong stage. The fragment path above supports explicit stages. + if stage is not None and stage != get_current_stage(): + raise ValueError( + f"Legacy material cfg '{type(material).__name__}' can only be spawned on the current" + " stage. Pass the current stage (or None), or use the fragment-based API for" + " explicit-stage authoring." + ) + return material.func(prim_path, material) + + @clone def spawn_rigid_body_material(prim_path: str, cfg: physics_materials_cfg.RigidBodyMaterialBaseCfg) -> Usd.Prim: """Create material with rigid-body physics properties. @@ -26,10 +154,10 @@ def spawn_rigid_body_material(prim_path: str, cfg: physics_materials_cfg.RigidBo 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. + writes the friction/restitution/density 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 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 5c88731cf8d..6dbe000e773 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py @@ -9,6 +9,7 @@ from dataclasses import MISSING from typing import ClassVar +from isaaclab.sim.schemas.schemas_cfg import SchemaFragment from isaaclab.utils.configclass import configclass # Names that moved out of this submodule into ``isaaclab_physx.sim.spawners.materials.physics_materials_cfg``. @@ -60,9 +61,10 @@ class PhysicsMaterialCfg: 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`. + Contains the friction, restitution, and density fields from the `UsdPhysics.MaterialAPI`_ that + are common across all simulation backends. For properties in the ``physxMaterial`` namespace + (compliant-contact spring and combine modes), use + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. See :meth:`spawn_rigid_body_material` for more information. @@ -88,6 +90,60 @@ class RigidBodyMaterialBaseCfg(PhysicsMaterialCfg): restitution: float = 0.0 """The restitution coefficient. Defaults to 0.0.""" + density: float | None = None + """The material density [kg/m^3]. Defaults to None, in which case it is not authored. + + Writes ``physics:density``. It is a fallback for collision shapes bound to this material; + explicitly authored rigid-body mass or density takes precedence. + """ + + +@configclass +class RigidBodyMaterialFragment(SchemaFragment): + """Marker base for rigid-body physics-material fragments; types the ``physics_material`` slot. + + A rigid-body physics material is a single ``UsdShade.Material`` prim that carries one or more + physics-material schemas. The fragments author single namespaces onto that prim: the + solver-common ``physics:*`` friction/restitution/density + (:class:`UsdPhysicsRigidBodyMaterialCfg`) and any backend-specific namespace (e.g. PhysX + ``physxMaterial:*`` via :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg`). + The defining ``UsdPhysics.MaterialAPI`` anchor is applied by the family writer + (:func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments`). + """ + + pass + + +@configclass +class UsdPhysicsRigidBodyMaterialCfg(RigidBodyMaterialFragment): + """``physics:*`` rigid-body material attributes from `UsdPhysics.MaterialAPI`_. + + The ``UsdPhysics.MaterialAPI`` schema is applied as the implicit anchor by the rigid-body material + family writer, so this fragment owns no applied schema of its own. ``None`` fields are left + unchanged on the material prim. + + .. _UsdPhysics.MaterialAPI: https://openusd.org/dev/api/class_usd_physics_material_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None # MaterialAPI applied by the family writer + + static_friction: float | None = None + """The static friction coefficient. Writes ``physics:staticFriction``.""" + + dynamic_friction: float | None = None + """The dynamic friction coefficient. Writes ``physics:dynamicFriction``.""" + + restitution: float | None = None + """The restitution coefficient. Writes ``physics:restitution``.""" + + density: float | None = None + """The material density [kg/m^3]. Writes ``physics:density``. + + Participates in mass computation via material binding when no explicit rigid-body mass or + density takes precedence. + """ + @configclass class DeformableBodyMaterialBaseCfg(PhysicsMaterialCfg): diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index bf0df38db12..f46d06c6856 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -16,7 +16,13 @@ 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 DeformableBodyMaterialBaseCfg, RigidBodyMaterialCfg, SurfaceDeformableBodyMaterialBaseCfg +from ..materials import ( + DeformableBodyMaterialBaseCfg, + RigidBodyMaterialBaseCfg, + RigidBodyMaterialFragment, + SurfaceDeformableBodyMaterialBaseCfg, +) +from ..materials.physics_materials import spawn_physics_material if TYPE_CHECKING: from . import meshes_cfg @@ -367,7 +373,15 @@ def _spawn_mesh_geom_from_mesh( 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): + # accept anything spawn_physics_material accepts for the rigid case: a legacy rigid-body + # material cfg, a single fragment, or a list/tuple of fragments + physics_material_frags = ( + cfg.physics_material if isinstance(cfg.physics_material, (list, tuple)) else [cfg.physics_material] + ) + is_rigid_material = isinstance(cfg.physics_material, RigidBodyMaterialBaseCfg) or all( + isinstance(frag, RigidBodyMaterialFragment) for frag in physics_material_frags + ) + if not is_rigid_material: raise ValueError("Rigid properties require a rigid physics material.") # create all the paths we need for clarity @@ -439,8 +453,8 @@ def _spawn_mesh_geom_from_mesh( material_path = f"{geom_prim_path}/{cfg.physics_material_path}" else: material_path = cfg.physics_material_path - # create material - cfg.physics_material.func(material_path, cfg.physics_material) + # create material (accepts a legacy material cfg or rigid-body fragment(s)) + spawn_physics_material(material_path, cfg.physics_material, stage=stage) # apply material bind_physics_material(prim_path, material_path, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py index c6eb26507d3..aafd4d3cd7e 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py @@ -57,9 +57,18 @@ class MeshCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): This parameter is ignored if `physics_material` is not None. """ - physics_material: materials.PhysicsMaterialCfg | None = None + physics_material: ( + materials.PhysicsMaterialCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + | None + ) = None """Physics material properties. + Accepts either a legacy material cfg, a single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of such + single-namespace fragments. + Note: If None, then no physics material will be added. """ diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index 2d1b721e956..2b8177423a8 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -10,6 +10,7 @@ from pxr import Usd from isaaclab.sim import schemas +from isaaclab.sim.spawners.materials.physics_materials import spawn_physics_material from isaaclab.sim.utils import bind_physics_material, bind_visual_material, clone, create_prim, get_current_stage if TYPE_CHECKING: @@ -316,8 +317,8 @@ def _spawn_geom_from_prim_type( material_path = f"{geom_prim_path}/{cfg.physics_material_path}" else: material_path = cfg.physics_material_path - # create material - cfg.physics_material.func(material_path, cfg.physics_material) + # create material (accepts a legacy material cfg or rigid-body fragment(s)) + spawn_physics_material(material_path, cfg.physics_material, stage=stage) # apply material bind_physics_material(mesh_prim_path, material_path, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py index b111bdbb2bf..04d0bc42a76 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py @@ -37,9 +37,18 @@ class ShapeCfg(RigidObjectSpawnerCfg): If the path is relative, then it will be relative to the prim's path. This parameter is ignored if `physics_material` is not None. """ - physics_material: materials.PhysicsMaterialCfg | None = None + physics_material: ( + materials.RigidBodyMaterialBaseCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + | None + ) = None """Physics material properties. + Since shapes are rigid-only spawners, this slot accepts the rigid material base class or + rigid-material fragments (single-namespace :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` + instances or lists thereof). + Note: If None, then no physics material will be added. """ diff --git a/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py b/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py index db34c9a1719..d457c1606c8 100644 --- a/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py +++ b/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Literal import isaaclab.sim as sim_utils +from isaaclab.sim.spawners import materials from isaaclab.utils.configclass import configclass if TYPE_CHECKING: @@ -87,13 +88,20 @@ class TerrainImporterCfg: to the grid color of the imported ground plane. """ - physics_material: sim_utils.RigidBodyMaterialCfg = sim_utils.RigidBodyMaterialCfg() + physics_material: ( + materials.RigidBodyMaterialBaseCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + ) = materials.RigidBodyMaterialCfg() """The physics material of the terrain. Defaults to a default physics material. The material is created at the path: ``{prim_path}/physicsMaterial``. .. note:: This parameter is used only when the ``terrain_type`` is "generator" or "plane". + + Accepts a legacy rigid material cfg, a single rigid-material fragment, or a list of + rigid-material fragments. """ max_init_terrain_level: int | None = None diff --git a/source/isaaclab/isaaclab/terrains/utils.py b/source/isaaclab/isaaclab/terrains/utils.py index 739f8094002..68498cfde40 100644 --- a/source/isaaclab/isaaclab/terrains/utils.py +++ b/source/isaaclab/isaaclab/terrains/utils.py @@ -14,6 +14,7 @@ from pxr import UsdGeom import isaaclab.sim as sim_utils +from isaaclab.sim.spawners.materials import spawn_physics_material from isaaclab.utils.warp import raycast_mesh @@ -80,7 +81,8 @@ def create_prim_from_mesh(prim_path: str, mesh: trimesh.Trimesh, **kwargs): translation: The translation of the terrain. Defaults to None. orientation: The orientation of the terrain. Defaults to None. visual_material: The visual material to apply. Defaults to None. - physics_material: The physics material to apply. Defaults to None. + physics_material: The physics material to apply. Defaults to None. Accepts a legacy rigid + material cfg, a single rigid-material fragment, or a list of fragments. """ # create parent prim sim_utils.create_prim(prim_path, "Xform") @@ -122,10 +124,9 @@ def create_prim_from_mesh(prim_path: str, mesh: trimesh.Trimesh, **kwargs): visual_material_cfg.func(f"{prim_path}/visualMaterial", visual_material_cfg) sim_utils.bind_visual_material(prim.GetPrimPath(), f"{prim_path}/visualMaterial") # create physics material - if kwargs.get("physics_material") is not None: - physics_material_cfg: sim_utils.RigidBodyMaterialCfg = kwargs.get("physics_material") - # spawn the material - physics_material_cfg.func(f"{prim_path}/physicsMaterial", physics_material_cfg) + physics_material = kwargs.get("physics_material") + if physics_material is not None: + spawn_physics_material(f"{prim_path}/physicsMaterial", physics_material) sim_utils.bind_physics_material(prim.GetPrimPath(), f"{prim_path}/physicsMaterial") diff --git a/source/isaaclab/test/sim/test_material_fragments.py b/source/isaaclab/test/sim/test_material_fragments.py new file mode 100644 index 00000000000..8caf9a99fd9 --- /dev/null +++ b/source/isaaclab/test/sim/test_material_fragments.py @@ -0,0 +1,523 @@ +# 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 pytest + +from pxr import UsdPhysics, UsdShade + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + +# ------------------------------------------------------------------------------------- +# RigidBodyMaterialFragment marker + metadata +# ------------------------------------------------------------------------------------- + + +def test_rigid_body_material_fragment_metadata_defaults(): + from isaaclab.sim.schemas import SchemaFragment + from isaaclab.sim.spawners.materials.physics_materials_cfg import ( + RigidBodyMaterialFragment, + UsdPhysicsRigidBodyMaterialCfg, + ) + + cfg = UsdPhysicsRigidBodyMaterialCfg(static_friction=0.7) + assert isinstance(cfg, RigidBodyMaterialFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physics" + assert type(cfg)._usd_applied_schema is None # MaterialAPI applied by the family writer + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.static_friction == 0.7 and cfg.dynamic_friction is None + + +def test_physx_material_fragment_metadata_defaults(): + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialFragment + + cfg = PhysxMaterialCfg(compliant_contact_stiffness=100.0) + assert isinstance(cfg, RigidBodyMaterialFragment) + assert type(cfg)._usd_namespace == "physxMaterial" + assert type(cfg)._usd_applied_schema == "PhysxMaterialAPI" + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + + +# ------------------------------------------------------------------------------------- +# spawn_rigid_body_material_from_fragments: spawn prim + anchor + multi-namespace compose +# ------------------------------------------------------------------------------------- + + +def test_spawn_rigid_body_material_from_fragments_composes_namespaces(): + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/Mat", + [ + UsdPhysicsRigidBodyMaterialCfg(static_friction=0.7, dynamic_friction=0.6, restitution=0.1), + PhysxMaterialCfg(compliant_contact_stiffness=100.0, friction_combine_mode="max"), + ], + stage, + ) + assert prim.IsA(UsdShade.Material) + assert bool(UsdPhysics.MaterialAPI(prim)) # neutral anchor applied by the writer + 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) + # the PhysX fragment applied its own schema and namespace + assert "PhysxMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("physxMaterial:compliantContactStiffness").Get() == pytest.approx(100.0) + assert prim.GetAttribute("physxMaterial:frictionCombineMode").Get() == "max" + + +def test_spawn_rigid_body_material_from_fragments_accepts_single_fragment(): + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/Mat2", UsdPhysicsRigidBodyMaterialCfg(static_friction=0.3), stage + ) + assert prim.IsA(UsdShade.Material) + assert bool(UsdPhysics.MaterialAPI(prim)) + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.3) + + +def test_spawn_physics_material_dispatches_fragments_and_legacy(): + """The shared dispatcher handles both a rigid-body fragment collection and a legacy material + cfg carrying its own ``func``.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import spawn_physics_material + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + + # tuple form is accepted by the low-level dispatcher alongside the list form used by cfg slots + frag_prim = spawn_physics_material("/World/MaterialA", (UsdPhysicsRigidBodyMaterialCfg(static_friction=0.4),)) + assert bool(UsdPhysics.MaterialAPI(frag_prim)) + assert frag_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.4) + + # legacy single-cfg form (RigidBodyMaterialBaseCfg subclass with its own spawner func) + legacy_prim = spawn_physics_material("/World/MaterialB", PhysxRigidBodyMaterialCfg(static_friction=0.9)) + assert bool(UsdPhysics.MaterialAPI(legacy_prim)) + assert legacy_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.9) + + +def test_spawn_physics_material_rejects_non_current_stage_for_legacy(): + """The legacy path is current-stage-bound; an explicit different stage raises instead of + silently authoring on the current stage. Passing the current stage explicitly stays valid + (the in-tree spawners do so unconditionally).""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from pxr import Usd + + from isaaclab.sim.spawners.materials import spawn_physics_material + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + other = Usd.Stage.CreateInMemory() + with pytest.raises(ValueError, match="current stage"): + spawn_physics_material("/World/MatOther", PhysxRigidBodyMaterialCfg(), stage=other) + # nothing leaked onto the current stage + assert not sim_utils.get_current_stage().GetPrimAtPath("/World/MatOther").IsValid() + # explicit current stage remains supported + prim = spawn_physics_material("/World/MatCurrent", PhysxRigidBodyMaterialCfg(), stage=sim_utils.get_current_stage()) + assert prim.IsValid() + + +def test_fragment_writer_validates_inputs_before_authoring(): + """Direct and dispatched fragment calls share one validation contract and do not leave prims.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import ( + spawn_physics_material, + spawn_rigid_body_material_from_fragments, + ) + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + + with pytest.raises(ValueError): + spawn_rigid_body_material_from_fragments("/World/MatEmpty", [], stage) + # a list mixing a fragment with a legacy cfg is not a valid fragment list + with pytest.raises(TypeError): + spawn_physics_material( + "/World/MatMixed", + [UsdPhysicsRigidBodyMaterialCfg(static_friction=0.4), PhysxRigidBodyMaterialCfg(static_friction=0.9)], + ) + with pytest.raises(TypeError): + spawn_rigid_body_material_from_fragments("/World/MatLegacy", PhysxRigidBodyMaterialCfg(), stage) + with pytest.raises(TypeError): + spawn_physics_material("/World/MatInvalid", object()) + for path in ("MatEmpty", "MatMixed", "MatLegacy", "MatInvalid"): + assert not stage.GetPrimAtPath(f"/World/{path}").IsValid() + + +def test_spawn_rigid_body_material_from_fragments_leaves_none_fields_unwritten(): + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/Mat3", [UsdPhysicsRigidBodyMaterialCfg(static_friction=0.5)], stage + ) + # only the authored field is written; None fields are left unauthored (partial update) + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.5) + assert not prim.GetAttribute("physics:dynamicFriction").HasAuthoredValue() + + +# ------------------------------------------------------------------------------------- +# UsdPhysicsRigidBodyMaterialCfg: density round-trip + physics:* schema parity +# ------------------------------------------------------------------------------------- + + +def test_usd_physics_rigid_body_material_density_round_trips(): + """``physics:density`` participates in mass computation via material binding; it must author + the same as the other ``UsdPhysics.MaterialAPI`` friction/restitution fields.""" + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/MatDensity", [UsdPhysicsRigidBodyMaterialCfg(density=1200.0)], stage + ) + assert prim.GetAttribute("physics:density").Get() == pytest.approx(1200.0) + + +def test_usd_physics_rigid_body_material_fragment_matches_material_api_schema(): + """Schema-parity guard: the set of ``physics:*`` attrs the neutral fragment can author must + equal the attribute set on ``UsdPhysics.MaterialAPI`` (4 attrs: static/dynamic friction, + restitution, density). Catches drift if the schema gains/loses an attribute.""" + import dataclasses + + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + from isaaclab.utils.string import to_camel_case + + fragment_fields = {f.name for f in dataclasses.fields(UsdPhysicsRigidBodyMaterialCfg) if f.name != "func"} + schema_attr_names = {name.split(":", 1)[1] for name in UsdPhysics.MaterialAPI.GetSchemaAttributeNames()} + fragment_attr_names = {to_camel_case(name, "cC") for name in fragment_fields} + assert fragment_attr_names == schema_attr_names + + +# ------------------------------------------------------------------------------------- +# PhysxMaterialCfg: damping-combine-mode + compliant-contact-acceleration-spring +# ------------------------------------------------------------------------------------- + + +def test_physx_material_fragment_authors_damping_combine_mode_and_acceleration_spring(): + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/MatPhysxExtra", + [PhysxMaterialCfg(damping_combine_mode="min", compliant_contact_acceleration_spring=True)], + stage, + ) + assert "PhysxMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("physxMaterial:dampingCombineMode").Get() == "min" + assert prim.GetAttribute("physxMaterial:compliantContactAccelerationSpring").Get() is True + + +# ------------------------------------------------------------------------------------- +# Finding 4: mesh spawner must accept a fragment list for a rigid physics_material +# ------------------------------------------------------------------------------------- + + +def test_spawn_mesh_with_rigid_props_accepts_fragment_list_physics_material(): + """Regression test: the rigid-vs-deformable material guard in the mesh spawner used to reject + a fragment / fragment-list ``physics_material`` outright. A rigid-body fragment list must spawn + and bind successfully.""" + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + from isaaclab.sim.spawners.meshes.meshes_cfg import MeshCuboidCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + cfg = MeshCuboidCfg( + size=(1.0, 1.0, 1.0), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=[UsdPhysicsRigidBodyMaterialCfg(static_friction=0.65, dynamic_friction=0.55)], + ) + prim = cfg.func("/World/MeshCubeFrag", cfg, stage=stage) + assert prim.IsValid() + material_prim = stage.GetPrimAtPath("/World/MeshCubeFrag/geometry/material") + assert material_prim.IsValid() + assert bool(UsdPhysics.MaterialAPI(material_prim)) + assert material_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.65) + # material binding: the mesh prim carries a physics-purpose material binding + binding_api = UsdShade.MaterialBindingAPI(stage.GetPrimAtPath("/World/MeshCubeFrag/geometry/mesh")) + bound_material, _ = binding_api.ComputeBoundMaterial(materialPurpose="physics") + assert bound_material.GetPath() == material_prim.GetPath() + + +# ------------------------------------------------------------------------------------- +# Finding 5: ground-plane spawner must accept a fragment-list physics_material +# ------------------------------------------------------------------------------------- + + +def test_spawn_ground_plane_accepts_fragment_list_physics_material(): + from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + cfg = GroundPlaneCfg(physics_material=[UsdPhysicsRigidBodyMaterialCfg(static_friction=0.42)]) + prim = cfg.func("/World/groundPlane", cfg) + assert prim.IsValid() + material_prim = stage.GetPrimAtPath("/World/groundPlane/physicsMaterial") + assert material_prim.IsValid() + assert bool(UsdPhysics.MaterialAPI(material_prim)) + assert material_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.42) + # the collision (Plane) prim under the ground plane must bind to the spawned material + from isaaclab.sim.utils import get_first_matching_child_prim + + collision_prim = get_first_matching_child_prim( + "/World/groundPlane", predicate=lambda _prim: _prim.GetTypeName() == "Plane", stage=stage + ) + assert collision_prim is not None + binding_api = UsdShade.MaterialBindingAPI(collision_prim) + bound_material, _ = binding_api.ComputeBoundMaterial(materialPurpose="physics") + assert bound_material.GetPath() == material_prim.GetPath() + + +# ------------------------------------------------------------------------------------- +# Regression: the mesh spawner's rigid-material guard must also accept legacy (non-fragment) +# rigid-body material cfgs, not just the deprecated ``RigidBodyMaterialCfg`` alias. +# ------------------------------------------------------------------------------------- + + +def test_spawn_mesh_with_rigid_props_accepts_legacy_physx_rigid_body_material(): + """Regression test: the mesh guard used to check ``isinstance(cfg.physics_material, + RigidBodyMaterialCfg)`` -- the deprecated PhysX leaf alias -- which rejected the canonical + legacy :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` even though + :func:`~isaaclab.sim.spawners.materials.spawn_physics_material` accepts it.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from isaaclab.sim.spawners.meshes.meshes_cfg import MeshCuboidCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + cfg = MeshCuboidCfg( + size=(1.0, 1.0, 1.0), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=PhysxRigidBodyMaterialCfg(static_friction=0.65, dynamic_friction=0.55), + ) + prim = cfg.func("/World/MeshCubeLegacyPhysx", cfg, stage=stage) + assert prim.IsValid() + material_prim = stage.GetPrimAtPath("/World/MeshCubeLegacyPhysx/geometry/material") + assert material_prim.IsValid() + assert bool(UsdPhysics.MaterialAPI(material_prim)) + assert material_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.65) + binding_api = UsdShade.MaterialBindingAPI(stage.GetPrimAtPath("/World/MeshCubeLegacyPhysx/geometry/mesh")) + bound_material, _ = binding_api.ComputeBoundMaterial(materialPurpose="physics") + assert bound_material.GetPath() == material_prim.GetPath() + + +def test_spawn_mesh_with_rigid_props_accepts_legacy_newton_material(): + """Same regression as above for Newton's legacy rigid-body material cfg, which also derives + from :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` (not the deprecated + PhysX ``RigidBodyMaterialCfg`` alias) and must not be rejected by the mesh guard.""" + from isaaclab_newton.sim.schemas import NewtonMaterialPropertiesCfg + + from isaaclab.sim.spawners.meshes.meshes_cfg import MeshCuboidCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + cfg = MeshCuboidCfg( + size=(1.0, 1.0, 1.0), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=NewtonMaterialPropertiesCfg(torsional_friction=0.3, rolling_friction=0.001), + ) + prim = cfg.func("/World/MeshCubeLegacyNewton", cfg, stage=stage) + assert prim.IsValid() + material_prim = stage.GetPrimAtPath("/World/MeshCubeLegacyNewton/geometry/material") + assert material_prim.IsValid() + assert bool(UsdPhysics.MaterialAPI(material_prim)) + assert material_prim.GetAttribute("newton:torsionalFriction").Get() == pytest.approx(0.3) + assert material_prim.GetAttribute("newton:rollingFriction").Get() == pytest.approx(0.001) + binding_api = UsdShade.MaterialBindingAPI(stage.GetPrimAtPath("/World/MeshCubeLegacyNewton/geometry/mesh")) + bound_material, _ = binding_api.ComputeBoundMaterial(materialPurpose="physics") + assert bound_material.GetPath() == material_prim.GetPath() + + +# ------------------------------------------------------------------------------------- +# PhysxRigidBodyMaterialCfg (legacy): damping-combine-mode + compliant-contact-acceleration-spring +# ------------------------------------------------------------------------------------- + + +def test_legacy_physx_rigid_body_material_authors_damping_combine_mode_and_acceleration_spring(): + """The legacy :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` must + author the same two ``physxMaterial:*`` attributes as the + :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg` fragment, since the legacy + spawner is metadata-driven off the same ``physxMaterial`` namespace.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from isaaclab.sim.spawners.materials import spawn_rigid_body_material + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = PhysxRigidBodyMaterialCfg(damping_combine_mode="min", compliant_contact_acceleration_spring=True) + prim = spawn_rigid_body_material("/World/MatLegacyPhysxExtra", cfg) + assert "PhysxMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("physxMaterial:dampingCombineMode").Get() == "min" + assert prim.GetAttribute("physxMaterial:compliantContactAccelerationSpring").Get() is True + + +# ------------------------------------------------------------------------------------- +# Generated-terrain routing: create_prim_from_mesh must accept a fragment-list physics_material +# ------------------------------------------------------------------------------------- + + +def test_create_prim_from_mesh_accepts_fragment_list(): + """Generated terrain routes its material through the dispatcher, so a fragment list works + end-to-end instead of crashing on the legacy-only spawn path.""" + import trimesh + from isaaclab_newton.sim.spawners.materials.physics_materials_cfg import NewtonMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + from isaaclab.terrains.utils import create_prim_from_mesh + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + mesh = trimesh.creation.box(extents=(1.0, 1.0, 0.2)) + create_prim_from_mesh( + "/World/terrainFrag", + mesh, + physics_material=[UsdPhysicsRigidBodyMaterialCfg(static_friction=0.9), NewtonMaterialCfg(rolling_friction=0.1)], + ) + stage = sim_utils.get_current_stage() + mat = stage.GetPrimAtPath("/World/terrainFrag/physicsMaterial") + assert mat.IsValid() + assert mat.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.9) + assert mat.GetAttribute("newton:rollingFriction").Get() == pytest.approx(0.1) + + +def test_legacy_base_cfg_authors_density(): + """The legacy rigid material base authors ``physics:density``, matching the USD fragment. + + ``UsdPhysics.MaterialAPI`` defines four properties; explicitly set values must be available + through both interfaces, including material density read by Newton's importer. + """ + from isaaclab.sim.spawners.materials import spawn_rigid_body_material + from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + prim = spawn_rigid_body_material("/World/LegacyDensity", RigidBodyMaterialBaseCfg(density=800.0)) + assert prim.GetAttribute("physics:density").Get() == pytest.approx(800.0) + # None default -> unauthored (backward compatible) + prim2 = spawn_rigid_body_material("/World/LegacyDensityNone", RigidBodyMaterialBaseCfg()) + assert not prim2.GetAttribute("physics:density").HasAuthoredValue() + + +def test_public_default_material_types_remain_backward_compatible(): + """Fragment support must not silently replace the released default config objects.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg + + from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg + from isaaclab.terrains.terrain_importer_cfg import TerrainImporterCfg + + defaults = ( + SimulationCfg().physics_material, + GroundPlaneCfg().physics_material, + TerrainImporterCfg(prim_path="/World/terrain").physics_material, + ) + assert all(type(material) is RigidBodyMaterialCfg for material in defaults) + + +def test_physx_fragment_and_legacy_cfg_match_material_api_schema(): + """Both PhysX interfaces cover the properties declared by ``PhysxMaterialAPI``.""" + import dataclasses + + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import ( + PhysxMaterialCfg, + PhysxRigidBodyMaterialCfg, + ) + + from pxr import PhysxSchema + + from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg + from isaaclab.utils.string import to_camel_case + + def fields(cls): + return {f.name for f in dataclasses.fields(cls) if f.name != "func"} + + base = fields(RigidBodyMaterialBaseCfg) + schema_attrs = {name.split(":", 1)[1] for name in PhysxSchema.PhysxMaterialAPI.GetSchemaAttributeNames()} + fragment_attrs = {to_camel_case(name, "cC") for name in fields(PhysxMaterialCfg)} + legacy_attrs = {to_camel_case(name, "cC") for name in fields(PhysxRigidBodyMaterialCfg) - base} + assert fragment_attrs == schema_attrs + assert legacy_attrs == schema_attrs + + +# ------------------------------------------------------------------------------------- +# Slot-typing contract +# ------------------------------------------------------------------------------------- + + +def test_material_slot_unions_match_spawner_kind(): + """Structural slot typing: rigid-only spawner slots admit the rigid base + fragments and + exclude the deformable-admitting root; mixed spawners keep the broad root. New slots added + to this list keep the contract enforced.""" + import sys + import typing + + from isaaclab.sim.spawners.from_files.from_files_cfg import FileCfg, GroundPlaneCfg + from isaaclab.sim.spawners.materials import physics_materials_cfg as mats + from isaaclab.sim.spawners.meshes.meshes_cfg import MeshCfg + from isaaclab.sim.spawners.shapes.shapes_cfg import ShapeCfg + from isaaclab.terrains.terrain_importer_cfg import TerrainImporterCfg + + def union_args(cls): + # typing.get_type_hints(cls) resolves annotations across the whole MRO, including the + # inherited ``func: Callable[..., Usd.Prim]`` from ``SpawnerCfg``, whose ``Usd`` import is + # TYPE_CHECKING-only and unresolvable at runtime. Evaluate the ``physics_material`` + # annotation directly against its declaring class's module globals instead -- each class + # in this list declares the field itself, so this never needs the base class's namespace. + annotation = cls.__dict__["__annotations__"]["physics_material"] + resolved = eval(annotation, vars(sys.modules[cls.__module__])) + return set(typing.get_args(resolved)) + + for cls in (ShapeCfg, GroundPlaneCfg, TerrainImporterCfg): + args = union_args(cls) + assert mats.RigidBodyMaterialBaseCfg in args, f"{cls.__name__} must admit the rigid base" + assert mats.RigidBodyMaterialFragment in args, f"{cls.__name__} must admit fragments" + assert mats.PhysicsMaterialCfg not in args, f"{cls.__name__} is rigid-only" + for cls in (FileCfg, MeshCfg): + args = union_args(cls) + assert mats.PhysicsMaterialCfg in args, f"{cls.__name__} spawns deformables too" + assert mats.RigidBodyMaterialFragment in args diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst new file mode 100644 index 00000000000..977d1590eca --- /dev/null +++ b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst @@ -0,0 +1,21 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`, a single-namespace + ``newton`` rigid-body physics-material fragment (torsional and rolling friction) backing + ``NewtonMaterialAPI``. Composes with other rigid-body material fragments (e.g. + :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg`) in a fragment list. +* Added :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_stiffness`, + :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_damping`, + :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_friction_gain`, and + :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_adhesion` (writing + ``newton:contactStiffness``, ``newton:contactDamping``, ``newton:contactFrictionGain``, and + ``newton:contactAdhesion``), matching the per-material contact attributes Newton's USD schema + resolver reads in place of the deprecated per-shape ``ke``/``kd``/``kf``/``ka`` parameters. +* Added the contact attributes + (:attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_stiffness`, + :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_damping`, + :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_friction_gain`, and + :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_adhesion`) to + :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg`, matching + :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi index 27677fbd6db..1e684304e81 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi @@ -7,6 +7,7 @@ __all__ = [ "NewtonDeformableBodyPropertiesCfg", "NewtonDeformableBodyMaterialCfg", "NewtonDeformableMaterialCfg", + "NewtonMaterialCfg", "NewtonSurfaceDeformableBodyMaterialCfg", "MPMGridCfg", "MPMParticleMaterialCfg", @@ -22,6 +23,7 @@ from .schemas import NewtonDeformableBodyPropertiesCfg from .spawners.materials import ( NewtonDeformableBodyMaterialCfg, NewtonDeformableMaterialCfg, + NewtonMaterialCfg, NewtonSurfaceDeformableBodyMaterialCfg, ) from .spawners.mpm import MPMGridCfg, MPMParticleMaterialCfg, MPMParticleSpawnerCfg, MPMPointsCfg 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 05d63921efd..86485af3aa7 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -501,6 +501,35 @@ class NewtonMaterialPropertiesCfg(RigidBodyMaterialBaseCfg): Range: [0, inf). """ + contact_stiffness: float | None = None + """Contact normal-force stiffness [N/m]. + + Writes ``newton:contactStiffness``. Replaces the deprecated per-shape ``ke`` contact parameter; + used by the SemiImplicit, Featherstone, MuJoCo, and VBD solvers. + """ + + contact_damping: float | None = None + """Contact normal-force damping coefficient [N·s/m]. + + Writes ``newton:contactDamping``. Replaces the deprecated per-shape ``kd`` contact parameter; + used by the SemiImplicit, Featherstone, MuJoCo, and VBD solvers. + """ + + contact_friction_gain: float | None = None + """Friction-force stiffness gain used by the tangential (friction) contact response [N·s/m]. + + Writes ``newton:contactFrictionGain``. Replaces the deprecated per-shape ``kf`` contact + parameter; used by the SemiImplicit and Featherstone solvers. + """ + + contact_adhesion: float | None = None + """Contact adhesion distance: shapes closer than this threshold experience an attractive + (adhesive) force [m]. + + Writes ``newton:contactAdhesion``. Replaces the deprecated per-shape ``ka`` contact parameter; + used by the SemiImplicit and Featherstone solvers. + """ + @configclass class MujocoFixedTendonCfg(FixedTendonFragment): diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi index c3f13216f78..5ee626b240b 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi @@ -7,6 +7,7 @@ __all__ = [ "spawn_deformable_body_material", "NewtonDeformableBodyMaterialCfg", "NewtonDeformableMaterialCfg", + "NewtonMaterialCfg", "NewtonSurfaceDeformableBodyMaterialCfg", ] @@ -14,5 +15,6 @@ from .physics_materials import spawn_deformable_body_material from .physics_materials_cfg import ( NewtonDeformableBodyMaterialCfg, NewtonDeformableMaterialCfg, + NewtonMaterialCfg, NewtonSurfaceDeformableBodyMaterialCfg, ) 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 index f7b63a8701c..e0027a81553 100644 --- 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 @@ -10,6 +10,7 @@ from isaaclab.sim.spawners.materials.physics_materials_cfg import ( DeformableBodyMaterialBaseCfg, + RigidBodyMaterialFragment, SurfaceDeformableBodyMaterialBaseCfg, ) from isaaclab.utils.configclass import configclass @@ -77,3 +78,73 @@ class NewtonSurfaceDeformableBodyMaterialCfg(SurfaceDeformableBodyMaterialBaseCf edge_kd: float = 1e-2 """Bending damping [N*m*s]. Used by Newton backend for cloth meshes.""" + + +@configclass +class NewtonMaterialCfg(RigidBodyMaterialFragment): + """``newton:*`` rigid-body material attributes read by Newton's USD material schema resolver. + + Single-namespace fragment (see + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`) for the Newton-only + friction knobs (torsional and rolling friction) and the per-material contact model (contact + stiffness/damping, friction gain, adhesion) that replaces the deprecated per-shape + ``ke``/``kd``/``kf``/``ka`` parameters. The ``NewtonMaterialAPI`` schema is applied (and the + ``newton:*`` attributes authored) by the generic :func:`~isaaclab.sim.schemas.apply_namespaced` + writer. ``None`` fields are left unchanged. + + .. note:: + The generated ``NewtonMaterialAPI`` USD schema currently only declares the two friction + attributes; the four contact attributes are still authored as raw ``newton:*`` USD + attributes and are read directly by Newton's schema resolver. + + Composes with other rigid-body material fragments (e.g. + :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg`) in the same fragment + list passed to + :func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments`. For the + legacy (non-fragment) equivalent, see + :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg`. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonMaterialAPI" + + torsional_friction: float | None = None + """Torsional friction coefficient (resistance to spinning at a contact point) [dimensionless]. + + Writes ``newton:torsionalFriction``. Range: [0, inf). + """ + + rolling_friction: float | None = None + """Rolling friction coefficient (resistance to rolling motion) [dimensionless]. + + Writes ``newton:rollingFriction``. Range: [0, inf). + """ + + contact_stiffness: float | None = None + """Contact normal-force stiffness [N/m]. + + Writes ``newton:contactStiffness``. Replaces the deprecated per-shape ``ke`` contact parameter; + used by the SemiImplicit, Featherstone, MuJoCo, and VBD solvers. + """ + + contact_damping: float | None = None + """Contact normal-force damping coefficient [N·s/m]. + + Writes ``newton:contactDamping``. Replaces the deprecated per-shape ``kd`` contact parameter; + used by the SemiImplicit, Featherstone, MuJoCo, and VBD solvers. + """ + + contact_friction_gain: float | None = None + """Friction-force stiffness gain used by the tangential (friction) contact response [N·s/m]. + + Writes ``newton:contactFrictionGain``. Replaces the deprecated per-shape ``kf`` contact + parameter; used by the SemiImplicit and Featherstone solvers. + """ + + contact_adhesion: float | None = None + """Contact adhesion distance: shapes closer than this threshold experience an attractive + (adhesive) force [m]. + + Writes ``newton:contactAdhesion``. Replaces the deprecated per-shape ``ka`` contact parameter; + used by the SemiImplicit and Featherstone solvers. + """ diff --git a/source/isaaclab_newton/test/sim/test_newton_schemas.py b/source/isaaclab_newton/test/sim/test_newton_schemas.py index 1774792d3d0..0f78c8e3950 100644 --- a/source/isaaclab_newton/test/sim/test_newton_schemas.py +++ b/source/isaaclab_newton/test/sim/test_newton_schemas.py @@ -164,6 +164,72 @@ def test_newton_material_no_schema_when_none(setup_sim): assert "NewtonMaterialAPI" not in prim.GetAppliedSchemas() +@pytest.mark.isaacsim_ci +def test_newton_material_fragment_composes_with_usd_physics_fragment(setup_sim): + """NewtonMaterialCfg is a rigid-body material fragment (backend symmetry with the PhysX + fragment): it must compose in a fragment list with UsdPhysicsRigidBodyMaterialCfg and author + both the ``newton:*`` and solver-common ``physics:*`` namespaces on the same material prim.""" + from isaaclab_newton.sim.spawners.materials import NewtonMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + prim = spawn_rigid_body_material_from_fragments( + "/World/newton_mat_frag", + [ + UsdPhysicsRigidBodyMaterialCfg(static_friction=0.6, dynamic_friction=0.5), + NewtonMaterialCfg(torsional_friction=0.3, rolling_friction=0.001), + ], + ) + assert bool(UsdPhysics.MaterialAPI(prim)) + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.6) + assert prim.GetAttribute("physics:dynamicFriction").Get() == pytest.approx(0.5) + assert "NewtonMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("newton:torsionalFriction").Get() == pytest.approx(0.3) + assert prim.GetAttribute("newton:rollingFriction").Get() == pytest.approx(0.001) + + +@pytest.mark.isaacsim_ci +def test_newton_material_fragment_authors_all_six_newton_attrs(setup_sim): + """Regression test: Newton's USD material schema resolver (``SchemaResolverNewton``) reads six + ``newton:*`` material attributes -- the two friction knobs plus four contact-model attributes + (``contactStiffness``/``contactDamping``/``contactFrictionGain``/``contactAdhesion``) that + replace the deprecated per-shape ``ke``/``kd``/``kf``/``ka`` parameters. All six must round-trip + through :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`, even though the + generated ``NewtonMaterialAPI`` schema currently only declares the two friction attributes.""" + from isaaclab_newton.sim.spawners.materials import NewtonMaterialCfg + from newton._src.usd.schema_resolver import PrimType + from newton._src.usd.schemas import SchemaResolverNewton + + from isaaclab.sim.spawners.materials import spawn_rigid_body_material_from_fragments + + prim = spawn_rigid_body_material_from_fragments( + "/World/newton_mat_contact", + NewtonMaterialCfg( + torsional_friction=0.3, + rolling_friction=0.001, + contact_stiffness=2500.0, + contact_damping=100.0, + contact_friction_gain=1000.0, + contact_adhesion=0.01, + ), + ) + expected = { + "mu_torsional": 0.3, + "mu_rolling": 0.001, + "ke": 2500.0, + "kd": 100.0, + "kf": 1000.0, + "ka": 0.01, + } + + assert "NewtonMaterialAPI" in prim.GetAppliedSchemas() + resolver = SchemaResolverNewton() + assert set(resolver.mapping[PrimType.MATERIAL]) == set(expected) + for key, value in expected.items(): + assert resolver.get_value(prim, PrimType.MATERIAL, key) == pytest.approx(value) + + # --------------------------------------------------------------------------- # Newton articulation root # --------------------------------------------------------------------------- @@ -347,3 +413,16 @@ def test_newton_mesh_collision_mixed_namespace_write(setup_sim): applied = prim.GetAppliedSchemas() assert "NewtonCollisionAPI" in applied assert "NewtonMeshCollisionAPI" in applied + + +@pytest.mark.isaacsim_ci +def test_newton_legacy_cfg_authors_contact_attrs(setup_sim): + """The legacy Newton material cfg authors all newton:* attributes the fragment authors.""" + mat_cfg = NewtonMaterialPropertiesCfg( + contact_stiffness=1.0e4, contact_damping=250.0, contact_friction_gain=40.0, contact_adhesion=0.02 + ) + prim = spawn_rigid_body_material("/World/newton_mat_contact", mat_cfg) + assert prim.GetAttribute("newton:contactStiffness").Get() == pytest.approx(1.0e4) + assert prim.GetAttribute("newton:contactDamping").Get() == pytest.approx(250.0) + assert prim.GetAttribute("newton:contactFrictionGain").Get() == pytest.approx(40.0) + assert prim.GetAttribute("newton:contactAdhesion").Get() == pytest.approx(0.02) diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst new file mode 100644 index 00000000000..b580d3ad631 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst @@ -0,0 +1,12 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg`, a single-namespace + ``physxMaterial`` rigid-body physics-material fragment (compliant-contact spring stiffness/damping + and the friction/restitution combine-mode tokens) backing ``PhysxMaterialAPI``. +* Added :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.damping_combine_mode` (writes + ``physxMaterial:dampingCombineMode``) and + :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.compliant_contact_acceleration_spring` + (writes ``physxMaterial:compliantContactAccelerationSpring``), completing the fragment's coverage + of ``PhysxMaterialAPI``. Also added the same two fields to the legacy + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. 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 315458ea5d2..7a018a14ecd 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi @@ -7,6 +7,7 @@ __all__ = [ "DeformableBodyMaterialCfg", "PhysXDeformableMaterialCfg", "PhysxDeformableBodyMaterialCfg", + "PhysxMaterialCfg", "PhysxRigidBodyMaterialCfg", "PhysxSurfaceDeformableBodyMaterialCfg", "RigidBodyMaterialCfg", @@ -17,6 +18,7 @@ from .physics_materials_cfg import ( DeformableBodyMaterialCfg, PhysXDeformableMaterialCfg, PhysxDeformableBodyMaterialCfg, + PhysxMaterialCfg, PhysxRigidBodyMaterialCfg, PhysxSurfaceDeformableBodyMaterialCfg, RigidBodyMaterialCfg, 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 345fadc8870..3560bc12bb3 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,6 +12,7 @@ from isaaclab.sim.spawners.materials.physics_materials_cfg import ( DeformableBodyMaterialBaseCfg, RigidBodyMaterialBaseCfg, + RigidBodyMaterialFragment, SurfaceDeformableBodyMaterialBaseCfg, ) from isaaclab.utils.configclass import configclass @@ -154,12 +155,13 @@ class PhysxRigidBodyMaterialCfg(RigidBodyMaterialBaseCfg): 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. + friction/restitution combine-mode tokens. Newton's USD importer also recognizes stiffness and + damping as fallback contact parameters; the acceleration-spring flag and combine modes are + PhysX-only. 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 + .. _PhysxMaterialAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_material_a_p_i.html """ # -- Class metadata (not dataclass fields) -- @@ -172,14 +174,23 @@ class PhysxRigidBodyMaterialCfg(RigidBodyMaterialBaseCfg): """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. + is only enabled if the stiffness is larger than 0. Newton's USD importer also recognizes this + attribute as a fallback contact-stiffness value. """ 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. + to zero and rigid contacts are active. Newton's USD importer also recognizes this attribute as + a fallback contact-damping value. + """ + + compliant_contact_acceleration_spring: bool | None = None + """Whether the compliant contact spring is formulated in acceleration (rather than force) units. + + Only relevant when compliant contacts are enabled (:attr:`compliant_contact_stiffness` is + non-zero). PhysX-only; not consumed by Newton. """ friction_combine_mode: Literal["average", "min", "multiply", "max"] | None = None @@ -202,6 +213,73 @@ class PhysxRigidBodyMaterialCfg(RigidBodyMaterialBaseCfg): `__. """ + damping_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """Determines the way the (compliant-contact) damping coefficient is 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 PhysxMaterialCfg(RigidBodyMaterialFragment): + """``physxMaterial:*`` rigid-body material attributes from `PhysxMaterialAPI`_. + + Single-namespace fragment (see :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`) + for the PhysX material namespace: compliant-contact spring (stiffness/damping), which Newton's + USD importer can read as fallback contact parameters, plus PhysX-only acceleration-spring and + combine-mode knobs. The ``PhysxMaterialAPI`` schema is applied (and the ``physxMaterial:*`` + attributes authored) by the generic :func:`~isaaclab.sim.schemas.apply_namespaced` writer. + ``None`` fields are left unchanged. + + .. _PhysxMaterialAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_material_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physxMaterial" + _usd_applied_schema: ClassVar[str | None] = "PhysxMaterialAPI" + + 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. Writes ``physxMaterial:compliantContactStiffness``. + """ + + compliant_contact_damping: float | None = None + """Damping coefficient for a compliant contact model using implicit springs. + + Irrelevant if compliant contacts are disabled (``compliant_contact_stiffness`` is zero). Writes + ``physxMaterial:compliantContactDamping``. + """ + + compliant_contact_acceleration_spring: bool | None = None + """Whether the compliant contact spring is formulated in acceleration (rather than force) units. + + Writes ``physxMaterial:compliantContactAccelerationSpring``. Only relevant when compliant + contacts are enabled (:attr:`compliant_contact_stiffness` is non-zero). + """ + + friction_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """How friction is combined during collisions. Writes ``physxMaterial:frictionCombineMode``.""" + + restitution_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """How restitution is combined during collisions. Writes ``physxMaterial:restitutionCombineMode``.""" + + damping_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """How the (compliant-contact) damping coefficient is combined during collisions. + + Writes ``physxMaterial:dampingCombineMode``. + + .. 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): From d55e0ed817054478ab67847713d22b106a62f5ff Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 3 Jul 2026 22:49:39 -0700 Subject: [PATCH 078/139] Fix articulation-root fragment integration boundaries --- .../isaaclab/physics/physics_manager.py | 164 ++++++-- source/isaaclab/isaaclab/sim/__init__.pyi | 2 - .../isaaclab/isaaclab/sim/schemas/schemas.py | 195 +++++----- .../sim/spawners/from_files/from_files.py | 2 +- .../sim/spawners/from_files/from_files_cfg.py | 5 +- .../isaaclab/isaaclab/sim/utils/__init__.pyi | 2 - source/isaaclab/isaaclab/sim/utils/prims.py | 54 --- .../test/sim/test_articulation_fragments.py | 355 ++++++++++-------- .../isaaclab_newton/sim/schemas/__init__.py | 5 - .../sim/schemas/schemas_cfg.py | 1 - .../vidurv-schema-frag-articulation.rst | 6 + .../physics/ovphysx_manager.py | 28 ++ .../test/assets/test_articulation.py | 43 +++ .../isaaclab_physx/physics/physx_manager.py | 83 +--- .../isaaclab_physx/sim/schemas/__init__.py | 5 - 15 files changed, 542 insertions(+), 408 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/vidurv-schema-frag-articulation.rst diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 98e375e0331..4a818c16d7d 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -103,13 +103,12 @@ def provides_implicit_damping(cls) -> bool: def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: """Fix an articulation base to the world frame and return the resulting root prim. - Authors a world<->root fixed joint that pins the articulation base at its current world pose. - The base implementation authors a backend-neutral ``UsdPhysics.FixedJoint`` (via - :func:`~isaaclab.sim.utils.create_fixed_root_joint`) and returns ``articulation_prim`` - unchanged -- sufficient for backends whose parser reads a fixed joint on the root directly - (e.g. Newton). Backends whose parser does not treat a fixed joint on a rigid body as a - fixed-base articulation (e.g. PhysX) override this to relocate the articulation root and - return the new root prim, so the caller applies articulation-root schemas to the actual root. + Ensures that a world-to-root fixed joint exists and is enabled. The operation is idempotent: + an existing disabled joint is enabled rather than duplicated. The base implementation leaves + ``articulation_prim`` unchanged, which is sufficient for backends whose parser reads a fixed + joint on the root directly (e.g. Newton). Backends whose parser requires the articulation-root + schema on the root link's parent override this method, call ``super()``, then relocate the root + and return the resulting prim. This is a backend capability rather than a registry keyed by cfg type: the active backend's manager class is resolved from ``cfg.physics.class_type``, so subclassed cfgs and every @@ -129,34 +128,151 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any cannot be determined to anchor the fixed joint. """ - from isaaclab.sim.utils import create_fixed_root_joint # noqa: PLC0415 + from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 + from isaaclab.sim.utils.stage import get_current_stage # noqa: PLC0415 - cls._require_rigid_body_root(articulation_prim) - create_fixed_root_joint(articulation_prim, stage) + if stage is None: + stage = get_current_stage() + existing_joint = find_global_fixed_joint_prim(articulation_prim.GetPath().pathString, stage=stage) + if existing_joint is not None: + existing_joint.GetJointEnabledAttr().Set(True) + return articulation_prim + + cls._create_fixed_root_joint(articulation_prim, stage) return articulation_prim @staticmethod - def _require_rigid_body_root(articulation_prim: Any) -> None: - """Raise ``NotImplementedError`` when the articulation root is not a rigid body. - - A fixed root joint is anchored to the first rigid-body link of the articulation; when the - resolved root carries no ``RigidBodyAPI`` that link cannot be determined. - - Args: - articulation_prim: The resolved articulation-root prim. - - Raises: - NotImplementedError: When the prim does not have ``UsdPhysics.RigidBodyAPI`` applied. - """ - from pxr import UsdPhysics # noqa: PLC0415 + def _create_fixed_root_joint(articulation_prim: Any, stage: Any) -> Any: + """Author a world-to-root fixed joint at the articulation's current world pose.""" + from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 + root_path = articulation_prim.GetPath().pathString if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): raise NotImplementedError( - f"The articulation prim '{articulation_prim.GetPath().pathString}' does not have the" + f"The articulation prim '{root_path}' does not have the" " RigidBodyAPI applied. To create a fixed joint, we need to determine the first rigid" " body link in the articulation tree. However, this is not implemented yet." ) + joint_parent = articulation_prim + while joint_parent != stage.GetPseudoRoot(): + if joint_parent.IsInPrototype() or joint_parent.IsInstanceProxy() or joint_parent.IsInstanceable(): + joint_parent = joint_parent.GetParent() + else: + break + parent_path = joint_parent.GetPath().pathString + if parent_path == "/": + parent_path = "" + joint_name = "FixedJoint" + if stage.GetPrimAtPath(f"{parent_path}/{joint_name}").IsValid(): + index = 0 + while stage.GetPrimAtPath(f"{parent_path}/{joint_name}{index}").IsValid(): + index += 1 + joint_name = f"{joint_name}{index}" + + world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() + joint = UsdPhysics.FixedJoint.Define(stage, f"{parent_path}/{joint_name}") + joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) + joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) + joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) + joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0)) + joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0)) + max_float = 3.40282347e38 + joint.CreateBreakForceAttr().Set(max_float) + joint.CreateBreakTorqueAttr().Set(max_float) + return joint.GetPrim() + + @staticmethod + def _relocate_articulation_root( + articulation_prim: Any, + companion_schema_namespaces: dict[str, str] | None = None, + ) -> Any: + """Move an articulation-root schema family to the root link's parent. + + The pure-USD operation is shared by backends whose parser requires parent-root topology. + Every applied API that composes ``PhysicsArticulationRootAPI`` is moved, together with any + backend companion schemas named by ``companion_schema_namespaces``. Authored properties are + flattened before the old schemas are removed, preserving defaults, time samples, metadata, + connections, and relationship targets even when opinions come through a referenced asset. + """ + from pxr import Usd, UsdPhysics # noqa: PLC0415 + + companion_schema_namespaces = companion_schema_namespaces or {} + if not articulation_prim.HasAPI(UsdPhysics.ArticulationRootAPI): + raise ValueError( + f"Prim '{articulation_prim.GetPath()}' is not an articulation root and cannot be relocated." + ) + new_root = articulation_prim.GetParent() + if new_root.HasAPI(UsdPhysics.ArticulationRootAPI): + raise RuntimeError( + f"Cannot relocate articulation root '{articulation_prim.GetPath()}' to" + f" '{new_root.GetPath()}': the destination is already an articulation root." + " Relocation would merge distinct articulations." + ) + registry = Usd.SchemaRegistry() + root_schema_name = UsdPhysics.Tokens.PhysicsArticulationRootAPI + + schemas_to_move: list[tuple[str, list[Any]]] = [] + has_root_schema = False + for schema_name in articulation_prim.GetPrimTypeInfo().GetAppliedAPISchemas(): + base_name, instance_name = registry.GetTypeNameAndInstance(schema_name) + prim_def = registry.FindAppliedAPIPrimDefinition(base_name) + is_root_anchor = base_name == root_schema_name + composes_root = prim_def is not None and root_schema_name in prim_def.GetAppliedAPISchemas() + if not is_root_anchor and not composes_root and base_name not in companion_schema_namespaces: + continue + if is_root_anchor or composes_root: + has_root_schema = True + + property_names = list(prim_def.GetPropertyNames()) if prim_def is not None else [] + if instance_name: + property_names = [ + registry.MakeMultipleApplyNameInstance(name, instance_name) for name in property_names + ] + fallback_namespace = companion_schema_namespaces.get(base_name) + if fallback_namespace is not None: + property_names.extend( + prop.GetName() for prop in articulation_prim.GetAuthoredPropertiesInNamespace(fallback_namespace) + ) + properties = [] + for property_name in dict.fromkeys(property_names): + prop = articulation_prim.GetProperty(property_name) + if prop and prop.IsAuthored(): + properties.append(prop) + schemas_to_move.append((schema_name, properties)) + if not has_root_schema: + raise RuntimeError( + f"Cannot relocate articulation root '{articulation_prim.GetPath()}': its root API is" + " built in or auto-applied, so there is no movable root-schema opinion." + ) + + new_root_schemas = set(new_root.GetPrimTypeInfo().GetAppliedAPISchemas()) + for schema_name, properties in schemas_to_move: + if schema_name not in new_root_schemas: + if not new_root.AddAppliedSchema(schema_name): + raise RuntimeError(f"Failed to apply schema '{schema_name}' to '{new_root.GetPath()}'.") + new_root_schemas.add(schema_name) + for prop in properties: + if not prop.FlattenTo(new_root): + raise RuntimeError( + f"Failed to move property '{prop.GetPath()}' to articulation root '{new_root.GetPath()}'." + ) + + for schema_name, _ in schemas_to_move: + if not articulation_prim.RemoveAppliedSchema(schema_name): + raise RuntimeError( + f"Failed to remove schema '{schema_name}' from old articulation root" + f" '{articulation_prim.GetPath()}'." + ) + if not new_root.HasAPI(UsdPhysics.ArticulationRootAPI) or articulation_prim.HasAPI( + UsdPhysics.ArticulationRootAPI + ): + raise RuntimeError( + f"Relocating articulation root '{articulation_prim.GetPath()}' to '{new_root.GetPath()}'" + " did not produce exactly one root at the destination." + ) + return new_root + @classmethod def register_callback( cls, diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 604a1b85b08..1b52e8f6cfc 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -72,7 +72,6 @@ __all__ = [ "UsdPhysicsMeshCollisionCfg", "UsdPhysicsRigidBodyCfg", "apply_articulation_root_properties", - "create_fixed_root_joint", "apply_collision_properties", "apply_fixed_tendon_properties", "apply_mass_properties", @@ -375,7 +374,6 @@ from .spawners import ( spawn_sphere, ) from .utils import ( - create_fixed_root_joint, add_labels, add_reference_to_stage, add_usd_reference, diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index e16a5a875c4..309dd8cc9fb 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -24,7 +24,6 @@ create_prim, find_global_fixed_joint_prim, get_all_matching_child_prims, - get_first_matching_child_prim, safe_set_attribute_on_usd_prim, safe_set_attribute_on_usd_schema, ) @@ -278,127 +277,131 @@ def apply_namespaced(cfg: schemas_cfg.SchemaFragment, prim_path: str, stage: Usd def apply_articulation_root_properties( prim_path: str, - fragments, + fragments: Iterable[schemas_cfg.ArticulationRootFragment], stage: Usd.Stage | None = None, fix_root_link: bool | None = None, ) -> bool: - """Apply a list of articulation-root fragments to a prim. - - Resolves the articulation root before writing: USD assets author - ``UsdPhysics.ArticulationRootAPI`` on a child prim (the root link / fixed joint), so this - writer descends the subtree under ``prim_path`` and tunes the existing root in place (matching - the legacy :func:`modify_articulation_root_properties` writer). Only when no prim in the - subtree carries the root does it apply ``UsdPhysics.ArticulationRootAPI`` on ``prim_path`` - itself (define-fresh, e.g. for primitive or programmatic spawns). This guarantees exactly one - articulation root rather than stamping a duplicate on the input prim. - - When ``fix_root_link`` is not ``None`` the base is fixed to the world *before* any fragment is - written: an existing world-to-root fixed joint is enabled/disabled, otherwise (when True) one is - created via the active backend's - :meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root` capability. That capability returns - the resulting root prim (some backends relocate it, e.g. PhysX moves the root to the parent), and - every fragment is then dispatched to *that* root via its - :attr:`~isaaclab.sim.schemas.SchemaFragment.func`. Writing fragments after the (possibly - relocating) fix keeps all backend schemas on the single resulting root -- so composing e.g. a - PhysX and a Newton fragment with ``fix_root_link=True`` never leaves a stray second root behind. - Backend fragments carry backend-specific funcs, so core never imports a backend. + """Apply articulation-root fragments to every top-level articulation under a prim. + + Existing articulation roots may live on descendant root links, and a USD file may contain + several sibling articulations. This writer discovers every top-level (non-nested) root and + dispatches each fragment to each root, matching the traversal semantics of the legacy + :func:`modify_articulation_root_properties` writer. Only when no root exists and fragments are + present does it apply ``UsdPhysics.ArticulationRootAPI`` to ``prim_path`` as a fresh anchor. + + Instance proxies are traversed for discovery so a hidden root suppresses that define-fresh + fallback, but they are never authored through. Skipped instance roots make the operation return + ``False`` with a warning, matching the legacy nested writer. + + When ``fix_root_link`` is ``True``, each writable root is normalized by the active backend's + :meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root` capability before fragments are + written. The capability creates or enables the world joint and returns the final root prim; + PhysX-based parsers may relocate it to the parent. ``False`` disables an existing world joint + without creating one. Backend fragments carry backend-specific funcs, so core imports no backend. Args: - prim_path: The prim path to search for the articulation root under (the root may be on a - descendant); the schemas are applied to the resolved root prim. + prim_path: The prim path whose subtree is searched for articulation roots. fragments: An iterable of :class:`~isaaclab.sim.schemas.ArticulationRootFragment` instances. - stage: The stage where to find the prim. Defaults to None, in which case the current - stage is used. - fix_root_link: Whether to fix the root link of the articulation. This is a non-USD, - spawner-level behaviour flag (it is not a fragment field). See - :attr:`~isaaclab.sim.spawners.UsdFileCfg.fix_root_link` for the semantics. Defaults - to None, in which case the root link is not modified. + stage: The stage where to find the prim. Defaults to the current stage. + fix_root_link: Whether to fix the root link. This is spawner topology state, not a fragment + field. ``None`` leaves the topology unchanged. Returns: - True if the properties were successfully set. + True when every fragment/root operation succeeds. False when any fragment reports failure or + an articulation root is skipped because it is instanced. Raises: TypeError: When an item in ``fragments`` is not an - :class:`~isaaclab.sim.schemas.ArticulationRootFragment` (e.g. a legacy single cfg, which - goes through :func:`modify_articulation_root_properties` instead). - ValueError: When the prim path is not valid. - RuntimeError: When ``fix_root_link`` is True and a fixed joint must be created but there is no - active :class:`~isaaclab.sim.SimulationContext` to resolve the physics backend from. - NotImplementedError: Propagated from the backend capability when it cannot create the fixed - joint (e.g. the resolved root prim is not a rigid body). + :class:`~isaaclab.sim.schemas.ArticulationRootFragment`. + ValueError: When ``prim_path`` is not valid. + RuntimeError: When fixing requires a backend but no active + :class:`~isaaclab.sim.SimulationContext` exists, or relocation would merge roots. + NotImplementedError: Propagated when a backend cannot fix the resolved root. """ - # validate early: a clear error beats an ``AttributeError`` deep inside fragment dispatch fragments = list(fragments) - for frag in fragments: - if not isinstance(frag, schemas_cfg.ArticulationRootFragment): + for fragment in fragments: + if not isinstance(fragment, schemas_cfg.ArticulationRootFragment): raise TypeError( "apply_articulation_root_properties: expected ArticulationRootFragment instances in" - f" 'fragments', got '{type(frag).__name__}'. Pass a legacy single cfg to" + f" 'fragments', got '{type(fragment).__name__}'. Pass a legacy single cfg to" " modify_articulation_root_properties instead." ) if stage is None: stage = get_current_stage() - # resolve the existing root (it may live on a child prim in USD assets); instance proxies can't be - # authored on, so don't traverse them - articulation_prim = get_first_matching_child_prim( + + # Discover proxies as well as writable prims so a root hidden inside an instance cannot trigger a + # second anchor on the instance root. Keep only top-level matches; nested articulations are pruned. + matches = get_all_matching_child_prims( prim_path, lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), - stage, - traverse_instance_prims=False, + stage=stage, + traverse_instance_prims=True, ) - # No existing root in the subtree: define one on the input prim -- but only when fragments are - # supplied (the anchor is presence-gated). A topology-only call (empty fragments, just - # ``fix_root_link``) must not stamp an articulation root as a side effect. - if articulation_prim is None and fragments: - articulation_prim = stage.GetPrimAtPath(prim_path) - UsdPhysics.ArticulationRootAPI.Apply(articulation_prim) - - # Fix the root link BEFORE writing fragments. Some backends relocate the root when fixing (PhysX - # moves the ``ArticulationRootAPI`` to the parent), so resolving the final root first lets every - # fragment land on that single root -- otherwise a relocation would strand fragment schemas on the - # former child and leave two articulation roots. - final_root = articulation_prim - if fix_root_link is not None: - if articulation_prim is None: + top_level_roots = [] + for candidate in matches: + if not any(candidate.GetPath().HasPrefix(root.GetPath()) for root in top_level_roots): + top_level_roots.append(candidate) + + writable_roots = [] + skipped_roots = [] + for root in top_level_roots: + if root.IsInstance() or root.IsInstanceProxy() or root.IsInPrototype(): + skipped_roots.append(root) + else: + writable_roots.append(root) + + if not top_level_roots and fragments: + input_prim = stage.GetPrimAtPath(prim_path) + if input_prim.IsInstance() or input_prim.IsInstanceProxy() or input_prim.IsInPrototype(): + skipped_roots.append(input_prim) + else: + UsdPhysics.ArticulationRootAPI.Apply(input_prim) + writable_roots.append(input_prim) + + success = not skipped_roots + if skipped_roots: + logger.warning( + "Skipping articulation-root updates on instanced prims: %s.", + [root.GetPath().pathString for root in skipped_roots], + ) + if not writable_roots: + if fix_root_link is not None and not skipped_roots: logger.warning( - f"No articulation root found under '{prim_path}': ignoring fix_root_link={fix_root_link}." - " The flag only applies to articulations." + "No articulation root found under '%s': ignoring fix_root_link=%s.", prim_path, fix_root_link ) - else: - root_path = articulation_prim.GetPath().pathString - # honor an explicit stage so the lookup does not fall back to the global current stage - existing_fixed_joint_prim = find_global_fixed_joint_prim(root_path, stage=stage) - if existing_fixed_joint_prim is not None: - # a joint already exists: just enable/disable it in place (no relocation) - logger.info( - f"Found an existing fixed joint for the articulation: '{root_path}'. Setting it to:" - f" {fix_root_link}." - ) - existing_fixed_joint_prim.GetJointEnabledAttr().Set(fix_root_link) - elif fix_root_link: - logger.info(f"Creating a fixed joint for the articulation: '{root_path}'.") - # Creating (and possibly relocating) the root is backend-specific. Delegate to the - # active backend's manager capability so core carries no backend logic; the capability - # returns the resulting root prim. - from isaaclab.sim import SimulationContext - - sim = SimulationContext.instance() - if sim is None: - raise RuntimeError( - f"Cannot fix the articulation root '{root_path}': no active SimulationContext" - " to resolve the physics backend from. A simulation must be running to fix a" - " base." - ) - final_root = sim.physics_manager.fix_articulation_root(articulation_prim, stage) - - # dispatch each fragment to the resolved (possibly relocated) root via its own applier - if fragments: + success = False + return success + + physics_manager = None + if fix_root_link: + from isaaclab.sim import SimulationContext + + sim = SimulationContext.instance() + if sim is None: + raise RuntimeError( + f"Cannot fix articulation roots under '{prim_path}': no active SimulationContext to" + " resolve the physics backend from. A simulation must be running to fix a base." + ) + physics_manager = sim.physics_manager + + for articulation_prim in writable_roots: + final_root = articulation_prim + root_path = articulation_prim.GetPath().pathString + if fix_root_link: + logger.info("Ensuring a fixed root for articulation: '%s'.", root_path) + final_root = physics_manager.fix_articulation_root(articulation_prim, stage) + elif fix_root_link is False: + existing_joint = find_global_fixed_joint_prim(root_path, stage=stage) + if existing_joint is not None: + logger.info("Disabling the existing fixed joint for articulation: '%s'.", root_path) + existing_joint.GetJointEnabledAttr().Set(False) + final_root_path = final_root.GetPath().pathString - for cfg in fragments: - func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) - func(cfg, final_root_path, stage) + for fragment in fragments: + func = fragment.func if callable(fragment.func) else string_to_callable(fragment.func) + success = bool(func(fragment, final_root_path, stage)) and success - return True + return success def define_articulation_root_properties( 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 6595efaf356..985df6edca7 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -372,7 +372,7 @@ def _spawn_from_usd_file( # ``fix_root_link`` is a spawner-level topology flag (not a schema property); it is honored on the # fragment path independently of whether any articulation schema properties were supplied. articulation_props = cfg.articulation_props - articulation_fix_root_link = getattr(cfg, "fix_root_link", None) + articulation_fix_root_link = cfg.fix_root_link # transition shim, remove later: route a legacy single cfg (a dataclass, not a fragment) to the # legacy writer -- it owns its own ``fix_root_link`` field; everything else goes to the fragment # writer, routing by type so an empty list is still a valid (topology-only) fragment collection 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 6c8efe83d88..ff047d6f933 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 @@ -53,8 +53,9 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): """Whether to fix the root link of the articulation. Defaults to None. This is a non-USD, spawner-level behaviour flag consumed by - :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` when - :attr:`articulation_props` is given as a fragment list: + :func:`~isaaclab.sim.schemas.apply_articulation_root_properties` on the fragment/topology path, + including when :attr:`articulation_props` is ``None`` or an empty fragment collection. It is handled + independently of whether any schema properties are supplied: * If set to None, the root link is not modified. * If the articulation already has a fixed root link, this flag enables or disables the fixed joint. diff --git a/source/isaaclab/isaaclab/sim/utils/__init__.pyi b/source/isaaclab/isaaclab/sim/utils/__init__.pyi index 2046f41a776..cfe39d0933e 100644 --- a/source/isaaclab/isaaclab/sim/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/utils/__init__.pyi @@ -13,7 +13,6 @@ __all__ = [ "define_prim", "get_prim_type_name", "get_next_free_path", - "create_fixed_root_joint", "create_prim", "delete_prim", "make_uninstanceable", @@ -74,7 +73,6 @@ from .legacy import ( get_next_free_path, ) from .prims import ( - create_fixed_root_joint, create_prim, delete_prim, make_uninstanceable, diff --git a/source/isaaclab/isaaclab/sim/utils/prims.py b/source/isaaclab/isaaclab/sim/utils/prims.py index b52e5ea1500..d0f0e8d9042 100644 --- a/source/isaaclab/isaaclab/sim/utils/prims.py +++ b/source/isaaclab/isaaclab/sim/utils/prims.py @@ -186,60 +186,6 @@ def create_prim( return prim -def create_fixed_root_joint(articulation_prim, stage: Usd.Stage | None = None): - """Author a world<->prim ``UsdPhysics.FixedJoint`` that pins a prim at its current world pose. - - Backend-neutral USD authoring with no Kit/``omni`` dependency: it mirrors the fixed, world-anchored - joint produced by Kit's ``omni.physx`` ``createJoint`` helper -- ``body0`` is left empty (the world - frame), ``body1`` targets the prim, and the joint's world-side local frame is set to the prim's - current world transform so the constraint pins the body where it is (otherwise it would be pinned to - the world origin). The joint prim is created under the first writable (non-instanced) ancestor. - - Args: - articulation_prim: The prim to fix to the world frame. - stage: The stage the prim lives on. Defaults to None, in which case the current stage is used. - - Returns: - The created ``UsdPhysics.FixedJoint`` prim. - """ - from pxr import Gf, UsdGeom, UsdPhysics - - if stage is None: - stage = get_current_stage() - - # host the joint under the first writable ancestor (an instanced/prototype prim is not writable) - base_prim = articulation_prim - while base_prim != stage.GetPseudoRoot(): - if base_prim.IsInPrototype() or base_prim.IsInstanceProxy() or base_prim.IsInstanceable(): - base_prim = base_prim.GetParent() - else: - break - base_path = str(base_prim.GetPrimPath()) - if base_path == "/": - base_path = "" - # pick a unique child name so repeated calls do not collide - name = "FixedJoint" - if stage.GetPrimAtPath(f"{base_path}/{name}").IsValid(): - index = 0 - while stage.GetPrimAtPath(f"{base_path}/{name}{index}").IsValid(): - index += 1 - name = f"{name}{index}" - joint = UsdPhysics.FixedJoint.Define(stage, f"{base_path}/{name}") - - # body0 empty -> world; body1 -> the prim, pinned at its current world pose - world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() - joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) - joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) - joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) - joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0)) - joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0)) - # disable joint breaking (matches Kit's createJoint defaults; float32 max) - max_float = 3.40282347e38 - joint.CreateBreakForceAttr().Set(max_float) - joint.CreateBreakTorqueAttr().Set(max_float) - return joint.GetPrim() - - def delete_prim(prim_path: str | Sequence[str], stage: Usd.Stage | None = None) -> bool: """Removes the USD Prim and its descendants from the scene if able. diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 31403ad387f..2319363122e 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -157,6 +157,61 @@ def test_apply_articulation_root_properties_tunes_existing_child_root(): assert len(roots) == 1 and roots[0] == child +def test_apply_articulation_root_properties_processes_siblings_and_aggregates_results(): + """Every non-nested sibling root is visited and a failing applier makes the family result False.""" + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + for path in ("/World/A", "/World/B", "/World/A/Nested"): + root = _make_xform(stage, path) + UsdPhysics.ArticulationRootAPI.Apply(root) + + visited = [] + + def record_result(_cfg, path, _stage): + visited.append(path) + return path != "/World/B" + + result = apply_articulation_root_properties("/World", [PhysxArticulationCfg(func=record_result)], stage) + + assert visited == ["/World/A", "/World/B"] + assert result is False + + +def test_apply_articulation_root_properties_does_not_duplicate_instance_proxy_root(caplog): + """A root hidden in an instance suppresses define-fresh but is skipped because proxies are read-only.""" + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + source = _make_xform(stage, "/World/Source") + source_base = _make_xform(stage, "/World/Source/base") + UsdPhysics.ArticulationRootAPI.Apply(source_base) + instance = _make_xform(stage, "/World/Asset") + instance.GetReferences().AddInternalReference(source.GetPath()) + instance.SetInstanceable(True) + proxy_root = stage.GetPrimAtPath("/World/Asset/base") + assert proxy_root.IsInstanceProxy() and proxy_root.HasAPI(UsdPhysics.ArticulationRootAPI) + + with caplog.at_level("WARNING"): + result = apply_articulation_root_properties( + "/World/Asset", [PhysxArticulationCfg(solver_position_iteration_count=4)], stage + ) + + assert result is False + assert not instance.HasAPI(UsdPhysics.ArticulationRootAPI) + assert proxy_root.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not proxy_root.GetAttribute("physxArticulation:solverPositionIterationCount").HasAuthoredValue() + assert "/World/Asset/base" in caplog.text + + # ------------------------------------------------------------------------------------- # fix_root_link spawner-level flag: toggles an existing fixed joint # ------------------------------------------------------------------------------------- @@ -188,6 +243,30 @@ def test_apply_articulation_root_properties_toggles_existing_fixed_joint(): assert joint.GetJointEnabledAttr().Get() is False +def test_apply_articulation_root_properties_enables_existing_joint_and_relocates_root(): + """An existing joint is enabled, but PhysX root placement is still normalized through the manager.""" + from isaaclab.sim.schemas import apply_articulation_root_properties + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + parent = _make_xform(stage, "/World/ExistingJointRobot") + root = _make_xform(stage, "/World/ExistingJointRobot/base") + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + joint = UsdPhysics.FixedJoint.Define(stage, "/World/ExistingJointRobot/base/FixedJoint") + joint.CreateBody1Rel().SetTargets([root.GetPath()]) + joint.CreateJointEnabledAttr(False) + + apply_articulation_root_properties("/World/ExistingJointRobot", [], stage, fix_root_link=True) + + joints = [prim for prim in stage.Traverse() if prim.IsA(UsdPhysics.FixedJoint)] + assert joints == [joint.GetPrim()] + assert joint.GetJointEnabledAttr().Get() is True + assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) + + # ------------------------------------------------------------------------------------- # fix_root_link spawner-level flag: creates a fixed joint and reparents the root # ------------------------------------------------------------------------------------- @@ -348,10 +427,8 @@ def test_physx_fix_root_link_migrates_preauthored_newton_root_api(): assert not child.HasAPI(UsdPhysics.ArticulationRootAPI) -def test_physx_fix_root_link_migrates_preauthored_physx_attrs(): - """Asset-authored ``PhysxArticulationAPI`` attributes move to the relocated root (legacy parity): - the legacy writer copied ``physxArticulation:*`` values to the parent, so the fragment path must - not silently drop them on the former root link where the PhysX parser ignores them.""" +def test_physx_fix_root_link_preserves_complete_authored_property_spec(): + """Relocation preserves sampled, metadata, and connection opinions rather than one default value.""" from isaaclab.sim.schemas import apply_articulation_root_properties sim_utils.create_new_stage() @@ -360,17 +437,34 @@ def test_physx_fix_root_link_migrates_preauthored_physx_attrs(): _make_xform(stage, "/World/UsdBot") child = _make_xform(stage, "/World/UsdBot/base") UsdPhysics.RigidBodyAPI.Apply(child) + UsdPhysics.MassAPI.Apply(child) UsdPhysics.ArticulationRootAPI.Apply(child) - # the asset ships with PhysX articulation solver settings authored on the root link child.AddAppliedSchema("PhysxArticulationAPI") - child.CreateAttribute("physxArticulation:solverVelocityIterationCount", Sdf.ValueTypeNames.Int).Set(4) + driver = _make_xform(stage, "/World/Driver") + driver_attr = driver.CreateAttribute("output", Sdf.ValueTypeNames.Float) + driver_attr.Set(0.5) + source_attr = child.GetAttribute("physxArticulation:sleepThreshold") + source_attr.Set(0.1, Usd.TimeCode(1.0)) + source_attr.Set(0.2, Usd.TimeCode(2.0)) + source_attr.SetMetadata("documentation", "sampled sleep threshold") + source_attr.AddConnection(driver_attr.GetPath()) apply_articulation_root_properties("/World/UsdBot", [], stage, fix_root_link=True) parent = stage.GetPrimAtPath("/World/UsdBot") assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) - assert "PhysxArticulationAPI" in parent.GetAppliedSchemas() - assert parent.GetAttribute("physxArticulation:solverVelocityIterationCount").Get() == 4 + assert "PhysxArticulationAPI" in parent.GetPrimTypeInfo().GetAppliedAPISchemas() + assert not child.HasAPI(UsdPhysics.ArticulationRootAPI) + assert "PhysxArticulationAPI" not in child.GetPrimTypeInfo().GetAppliedAPISchemas() + moved_attr = parent.GetAttribute("physxArticulation:sleepThreshold") + assert not stage.GetRootLayer().GetAttributeAtPath(moved_attr.GetPath()).HasInfo("default") + assert moved_attr.GetTimeSamples() == [1.0, 2.0] + assert moved_attr.Get(Usd.TimeCode(1.0)) == pytest.approx(0.1) + assert moved_attr.Get(Usd.TimeCode(2.0)) == pytest.approx(0.2) + assert moved_attr.GetMetadata("documentation") == "sampled sleep threshold" + assert moved_attr.GetConnections() == [driver_attr.GetPath()] + assert child.HasAPI(UsdPhysics.RigidBodyAPI) and child.HasAPI(UsdPhysics.MassAPI) + assert not parent.HasAPI(UsdPhysics.RigidBodyAPI) and not parent.HasAPI(UsdPhysics.MassAPI) def test_apply_articulation_root_properties_rejects_non_fragment_items(): @@ -411,87 +505,39 @@ def test_apply_articulation_root_properties_topology_only_does_not_stamp_root(): def test_apply_articulation_root_properties_honors_explicit_stage(): - """The writer authors to the explicitly supplied stage even when it is not the current stage -- - every lookup (root resolution, fixed-joint search) is scoped to that stage.""" + """Fragment writes and fixed-joint lookup both stay on the explicitly supplied stage.""" from isaaclab_physx.sim.schemas import PhysxArticulationCfg from isaaclab.sim.schemas import apply_articulation_root_properties sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) - # an in-memory stage that is deliberately NOT the current stage + current_stage = sim_utils.get_current_stage() + current_root = _make_xform(current_stage, "/World/AltStageRoot") + UsdPhysics.ArticulationRootAPI.Apply(current_root) + current_joint = UsdPhysics.FixedJoint.Define(current_stage, "/World/AltStageRoot/FixedJoint") + current_joint.CreateBody1Rel().SetTargets([current_root.GetPath()]) + current_joint.CreateJointEnabledAttr(True) + other_stage = Usd.Stage.CreateInMemory() UsdGeom.Xform.Define(other_stage, "/World") - root = UsdGeom.Xform.Define(other_stage, "/World/AltStageRoot").GetPrim() - UsdPhysics.ArticulationRootAPI.Apply(root) + other_root = UsdGeom.Xform.Define(other_stage, "/World/AltStageRoot").GetPrim() + UsdPhysics.ArticulationRootAPI.Apply(other_root) + other_joint = UsdPhysics.FixedJoint.Define(other_stage, "/World/AltStageRoot/FixedJoint") + other_joint.CreateBody1Rel().SetTargets([other_root.GetPath()]) + other_joint.CreateJointEnabledAttr(True) apply_articulation_root_properties( "/World/AltStageRoot", [PhysxArticulationCfg(solver_position_iteration_count=4)], other_stage, + fix_root_link=False, ) - # authored on the supplied stage ... - assert root.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 4 - # ... and not leaked onto the current stage - assert not sim_utils.get_current_stage().GetPrimAtPath("/World/AltStageRoot").IsValid() - - -# ------------------------------------------------------------------------------------- -# create_fixed_root_joint -- backend-neutral, pure-USD world<->prim fixed joint -# ------------------------------------------------------------------------------------- - - -def test_create_fixed_root_joint_authors_world_anchored_joint(): - """create_fixed_root_joint authors a world<->prim ``UsdPhysics.FixedJoint``: ``body1`` targets the - prim, ``body0`` is left empty (the world frame), and the body1-side local frame is identity.""" - from isaaclab.sim.utils import create_fixed_root_joint - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/Fix1") - joint_prim = create_fixed_root_joint(prim, stage) - - assert joint_prim.IsA(UsdPhysics.FixedJoint) - joint = UsdPhysics.FixedJoint(joint_prim) - assert list(joint.GetBody1Rel().GetTargets()) == [prim.GetPath()] - # body0 left empty -> the world frame - body0_rel = joint.GetBody0Rel() - assert not body0_rel or list(body0_rel.GetTargets()) == [] - # the body1-side local frame is identity - assert joint_prim.GetAttribute("physics:localPos1").Get() == Gf.Vec3f(0.0) - assert joint_prim.GetAttribute("physics:localRot1").Get() == Gf.Quatf(1.0) - - -def test_create_fixed_root_joint_pins_prim_at_current_world_pose(): - """The world-side local frame is set to the prim's current world transform, so the constraint pins - the body where it is rather than teleporting it to the world origin.""" - from isaaclab.sim.utils import create_fixed_root_joint - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/Fix2") - UsdGeom.Xform(prim).AddTranslateOp().Set(Gf.Vec3d(1.0, 2.0, 3.0)) - - joint_prim = create_fixed_root_joint(prim, stage) - pos0 = joint_prim.GetAttribute("physics:localPos0").Get() - assert (pos0[0], pos0[1], pos0[2]) == pytest.approx((1.0, 2.0, 3.0)) - - -def test_create_fixed_root_joint_uses_unique_names(): - """Repeated calls author distinct joint prims (no path collision).""" - from isaaclab.sim.utils import create_fixed_root_joint - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/Fix3") - first = create_fixed_root_joint(prim, stage) - second = create_fixed_root_joint(prim, stage) - assert first.GetPath() != second.GetPath() - assert first.IsA(UsdPhysics.FixedJoint) and second.IsA(UsdPhysics.FixedJoint) + assert other_root.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 4 + assert other_joint.GetJointEnabledAttr().Get() is False + assert not current_root.GetAttribute("physxArticulation:solverPositionIterationCount").HasAuthoredValue() + assert current_joint.GetJointEnabledAttr().Get() is True # ------------------------------------------------------------------------------------- @@ -499,13 +545,8 @@ def test_create_fixed_root_joint_uses_unique_names(): # ------------------------------------------------------------------------------------- -def test_base_manager_fix_articulation_root_authors_joint_without_reparenting(): - """The base :class:`~isaaclab.physics.PhysicsManager` capability authors a ``UsdPhysics.FixedJoint`` - and leaves the articulation root in place, returning the same prim. - - This is the default inherited by backends whose parser reads a fixed joint directly (e.g. Newton, - OVPhysx) and by any manager subclass that does not override it -- no PhysX-style relocation. - """ +def test_base_manager_fix_articulation_root_is_world_anchored_and_idempotent(): + """The neutral capability pins the current pose and enables rather than duplicates an existing joint.""" from isaaclab.physics import PhysicsManager sim_utils.create_new_stage() @@ -513,17 +554,23 @@ def test_base_manager_fix_articulation_root_authors_joint_without_reparenting(): stage = sim_utils.get_current_stage() _make_xform(stage, "/World/BaseRobot") root = _make_xform(stage, "/World/BaseRobot/base") + UsdGeom.Xform(root).AddTranslateOp().Set(Gf.Vec3d(1.0, 2.0, 3.0)) UsdPhysics.RigidBodyAPI.Apply(root) UsdPhysics.ArticulationRootAPI.Apply(root) result = PhysicsManager.fix_articulation_root(root, stage) - - assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) - # base default returns the same prim and does NOT relocate the root to the parent - assert result == root - assert root.HasAPI(UsdPhysics.ArticulationRootAPI) + joints = [prim for prim in stage.Traverse() if prim.IsA(UsdPhysics.FixedJoint)] + assert result == root and len(joints) == 1 + joint = UsdPhysics.FixedJoint(joints[0]) + assert list(joint.GetBody1Rel().GetTargets()) == [root.GetPath()] + assert joint.GetPrim().GetAttribute("physics:localPos0").Get() == Gf.Vec3f(1.0, 2.0, 3.0) assert not stage.GetPrimAtPath("/World/BaseRobot").HasAPI(UsdPhysics.ArticulationRootAPI) + joint.CreateJointEnabledAttr(False) + assert PhysicsManager.fix_articulation_root(root, stage) == root + assert joint.GetJointEnabledAttr().Get() is True + assert len([prim for prim in stage.Traverse() if prim.IsA(UsdPhysics.FixedJoint)]) == 1 + def test_base_manager_fix_articulation_root_requires_rigid_body(): """The capability raises NotImplementedError on a non-rigid-body root (cannot anchor the joint).""" @@ -538,56 +585,60 @@ def test_base_manager_fix_articulation_root_requires_rigid_body(): PhysicsManager.fix_articulation_root(root, stage) -def test_fix_articulation_root_capability_inherited_by_manager_subclass(): - """A backend manager subclass inherits its parent backend's ``fix_articulation_root`` through the - normal method-resolution order. +def test_articulation_fragment_and_legacy_cfg_match_physx_schema(): + """Both interfaces cover exactly all six attributes registered by PhysxArticulationAPI.""" + import dataclasses - This is what makes the capability robust where the previous cfg-type registry was not: manager - dispatch is resolved from ``cfg.physics.class_type``, so a subclassed cfg (e.g. an in-tree - ``DeformableNewtonCfg(NewtonCfg)``) whose ``class_type`` is an unchanged or subclassed manager - inherits the correct behaviour, rather than missing an exact-type registry key. - """ - from isaaclab.physics import PhysicsManager + from isaaclab_physx.sim.schemas import PhysxArticulationCfg, PhysxArticulationRootPropertiesCfg - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) + from pxr import PhysxSchema - # a minimal concrete backend that OVERRIDES the capability (as PhysX does, relocating the root) ... - class _BackendLike(PhysicsManager): - @classmethod - def fix_articulation_root(cls, articulation_prim, stage=None): - from isaaclab.sim.utils import create_fixed_root_joint + from isaaclab.utils.string import to_camel_case - cls._require_rigid_body_root(articulation_prim) - create_fixed_root_joint(articulation_prim, stage) - parent = articulation_prim.GetParent() - UsdPhysics.ArticulationRootAPI.Apply(parent) - articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) - return parent + def fields(cls): + return {field.name for field in dataclasses.fields(cls) if field.name != "func"} - # ... and a subclass that does not override it (stand-in for a subclassed cfg's class_type) - class _SubBackend(_BackendLike): - pass + schema_attrs = {name.split(":", 1)[1] for name in PhysxSchema.PhysxArticulationAPI.GetSchemaAttributeNames()} + fragment_attrs = {to_camel_case(name, "cC") for name in fields(PhysxArticulationCfg)} + legacy_attrs = { + to_camel_case(name, "cC") for name in fields(PhysxArticulationRootPropertiesCfg) - {"fix_root_link"} + } + assert fragment_attrs == schema_attrs + assert legacy_attrs == schema_attrs - stage = sim_utils.get_current_stage() - _make_xform(stage, "/World/SubRobot") - root = _make_xform(stage, "/World/SubRobot/base") - UsdPhysics.RigidBodyAPI.Apply(root) - UsdPhysics.ArticulationRootAPI.Apply(root) - result = _SubBackend.fix_articulation_root(root, stage) +def test_newton_legacy_cfg_matches_equivalent_fragment_composition(): + """A Newton legacy subclass still routes its inherited articulation_enabled base field.""" + from isaaclab_newton.sim.schemas import NewtonArticulationCfg, NewtonArticulationRootPropertiesCfg + from isaaclab_physx.sim.schemas import PhysxArticulationCfg - parent = stage.GetPrimAtPath("/World/SubRobot") - # the subclass inherited the overriding backend's behaviour through the MRO - assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) - assert result == parent - assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) - assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) + from isaaclab.sim.schemas import apply_articulation_root_properties, modify_articulation_root_properties - # and the real active backend (PhysX) genuinely overrides the base -- so backends specialise via - # this same inheritance mechanism rather than a parallel registry - active_manager = SimulationContext.instance().physics_manager - assert active_manager.fix_articulation_root.__func__ is not PhysicsManager.fix_articulation_root.__func__ + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + legacy = _make_xform(stage, "/World/LegacyNewton") + fragments = _make_xform(stage, "/World/FragmentNewton") + UsdPhysics.ArticulationRootAPI.Apply(legacy) + UsdPhysics.ArticulationRootAPI.Apply(fragments) + + modify_articulation_root_properties( + legacy.GetPath(), + NewtonArticulationRootPropertiesCfg(articulation_enabled=False, self_collision_enabled=True), + stage, + ) + apply_articulation_root_properties( + fragments.GetPath(), + [ + PhysxArticulationCfg(articulation_enabled=False), + NewtonArticulationCfg(self_collision_enabled=True), + ], + stage, + ) + + for prim in (legacy, fragments): + assert prim.GetAttribute("physxArticulation:articulationEnabled").Get() is False + assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True # ------------------------------------------------------------------------------------- @@ -606,13 +657,9 @@ def _author_articulation_usd(path: str) -> None: asset_stage.Save() -def test_spawn_from_usd_file_empty_articulation_props_with_fix_root_link(tmp_path): - """An empty ``articulation_props=[]`` on a USD spawn is a valid (topology-only) fragment collection: - it must route to the fragment writer and honor ``fix_root_link`` -- not crash in the legacy writer. - - Regression: the legacy path called ``dataclasses.fields([])`` on the empty list and raised - ``TypeError``, so the direct-writer and spawner entry points disagreed on whether ``[]`` was valid. - """ +@pytest.mark.parametrize("articulation_props", [None, []], ids=["none", "empty"]) +def test_spawn_from_usd_file_topology_only_honors_fix_root_link(tmp_path, articulation_props): + """None and an empty fragment collection both honor the independent topology flag.""" from isaaclab.sim.spawners.from_files.from_files import _spawn_from_usd_file from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg @@ -621,33 +668,44 @@ def test_spawn_from_usd_file_empty_articulation_props_with_fix_root_link(tmp_pat usd_path = os.path.join(tmp_path, "articulation.usda") _author_articulation_usd(usd_path) - cfg = UsdFileCfg(usd_path=usd_path, articulation_props=[], fix_root_link=True) - _spawn_from_usd_file("/World/FromUsdA", usd_path, cfg) + cfg = UsdFileCfg(usd_path=usd_path, articulation_props=articulation_props, fix_root_link=True) + _spawn_from_usd_file("/World/FromUsdTopology", usd_path, cfg) stage = sim_utils.get_current_stage() - # the topology flag was honored: a fixed joint was created and the root relocated to the parent - assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) - parent = stage.GetPrimAtPath("/World/FromUsdA") - assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) - assert not stage.GetPrimAtPath("/World/FromUsdA/base").HasAPI(UsdPhysics.ArticulationRootAPI) + assert any(prim.IsA(UsdPhysics.FixedJoint) for prim in stage.Traverse()) + assert stage.GetPrimAtPath("/World/FromUsdTopology").HasAPI(UsdPhysics.ArticulationRootAPI) + assert not stage.GetPrimAtPath("/World/FromUsdTopology/base").HasAPI(UsdPhysics.ArticulationRootAPI) -def test_spawn_from_usd_file_none_articulation_props_honors_fix_root_link(tmp_path): - """``fix_root_link`` is a spawner-level flag processed independently of ``articulation_props``: - with ``articulation_props=None`` it must still fix the base (previously it was silently ignored).""" +def test_spawn_from_usd_file_applies_composed_fragment_list(tmp_path): + """The real FileCfg transition bridge composes backend fragments on the relocated root.""" + from isaaclab_newton.sim.schemas import NewtonArticulationCfg + from isaaclab_physx.sim.schemas import PhysxArticulationCfg + from isaaclab.sim.spawners.from_files.from_files import _spawn_from_usd_file from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) - usd_path = os.path.join(tmp_path, "articulation.usda") + usd_path = os.path.join(tmp_path, "fragment_articulation.usda") _author_articulation_usd(usd_path) + cfg = UsdFileCfg( + usd_path=usd_path, + articulation_props=[ + PhysxArticulationCfg(solver_position_iteration_count=8), + NewtonArticulationCfg(self_collision_enabled=True), + ], + fix_root_link=True, + ) - cfg = UsdFileCfg(usd_path=usd_path, articulation_props=None, fix_root_link=True) - _spawn_from_usd_file("/World/FromUsdB", usd_path, cfg) + _spawn_from_usd_file("/World/FromUsdFragments", usd_path, cfg) stage = sim_utils.get_current_stage() - assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) + root = stage.GetPrimAtPath("/World/FromUsdFragments") + roots = [prim for prim in stage.Traverse() if prim.HasAPI(UsdPhysics.ArticulationRootAPI)] + assert roots == [root] + assert root.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 + assert root.GetAttribute("newton:selfCollisionEnabled").Get() is True # ------------------------------------------------------------------------------------- @@ -664,4 +722,3 @@ def test_public_imports(): SchemaFragment, apply_articulation_root_properties, ) - from isaaclab.sim.utils import create_fixed_root_joint # noqa: F401 diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py index f5a65d933f5..e79e72e10df 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/__init__.py @@ -8,8 +8,3 @@ from isaaclab.utils.module import lazy_export lazy_export() - -# Fixing an articulation base is a backend capability on the physics manager -# (:meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root`). Newton reads a -# ``UsdPhysics.FixedJoint`` on the root directly, so it inherits the base manager behaviour (author the -# neutral fixed joint, leave the root in place) without a Newton-specific override here. 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 9b9e68e6f80..ae19d511fe2 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -575,7 +575,6 @@ class NewtonArticulationRootPropertiesCfg(ArticulationRootBaseCfg): _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. diff --git a/source/isaaclab_ovphysx/changelog.d/vidurv-schema-frag-articulation.rst b/source/isaaclab_ovphysx/changelog.d/vidurv-schema-frag-articulation.rst new file mode 100644 index 00000000000..6825dbe2d71 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/vidurv-schema-frag-articulation.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed ``fix_root_link=True`` for OVPhysX fragment-based articulation spawns by relocating the + articulation root to the parser-required parent, including when an existing disabled world fixed + joint is re-enabled. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 458f38f9b54..c0e2b2872b5 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -228,6 +228,34 @@ def get_dt(cls) -> float: """Get the physics timestep. Alias for get_physics_dt().""" return cls.get_physics_dt() + @classmethod + def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: + """Fix an articulation base using the parent-root topology required by OVPhysX. + + OVPhysX uses the PhysX articulation parser, so a world joint on a rigid root link is not by + itself classified as a fixed-base articulation. The base manager creates or enables the + joint; this override then relocates the complete articulation-root schema family to the root + link's parent, preserving authored property specifications. + + Args: + articulation_prim: The resolved articulation-root prim to fix to the world frame. + stage: The stage the prim lives on. Defaults to None, in which case the current stage is + used. + + Returns: + The parent prim, which becomes the articulation root after relocation. + + Raises: + NotImplementedError: When the root prim is not a rigid body. + """ + fixed_root = super().fix_articulation_root(articulation_prim, stage) + if not fixed_root.HasAPI(UsdPhysics.RigidBodyAPI): + return fixed_root + return cls._relocate_articulation_root( + fixed_root, + companion_schema_namespaces={"PhysxArticulationAPI": "physxArticulation"}, + ) + @classmethod def register_clone( cls, source: str, targets: list[str], parent_positions: list[tuple[float, float, float]] | None = None diff --git a/source/isaaclab_ovphysx/test/assets/test_articulation.py b/source/isaaclab_ovphysx/test/assets/test_articulation.py index 9dff7ae6bca..17a029a826d 100644 --- a/source/isaaclab_ovphysx/test/assets/test_articulation.py +++ b/source/isaaclab_ovphysx/test/assets/test_articulation.py @@ -58,6 +58,8 @@ import torch import warp as wp +from pxr import UsdPhysics + # The OVPhysX runtime wheel is optional. Skip gracefully when it is not installed; # CI jobs that need OVPhysX coverage install it explicitly. pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") @@ -732,6 +734,47 @@ def test_initialization_floating_base_made_fixed_base(sim, num_articulations, de torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) +@pytest.mark.parametrize("device", ["cpu"]) +def test_fragment_fix_root_reenables_existing_joint(sim, device): + """The fragment path must normalize OVPhysX topology even when a disabled fixed joint exists.""" + articulation_cfg = generate_articulation_cfg(articulation_type="anymal").copy() + articulation_cfg.spawn.articulation_props = [] + articulation_cfg.spawn.fix_root_link = None + articulation, _ = generate_articulation(articulation_cfg, num_articulations=1, device=device) + + stage = sim.stage + asset_path = "/World/Env_0/Robot" + root = sim_utils.get_first_matching_child_prim( + asset_path, lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), stage=stage + ) + assert root is not None and root.HasAPI(UsdPhysics.RigidBodyAPI) + old_root_path = root.GetPath().pathString + final_root = root.GetParent() + + joint = UsdPhysics.FixedJoint.Define(stage, f"{old_root_path}/PreAuthoredFixedJoint") + joint.CreateBody1Rel().SetTargets([root.GetPath()]) + joint.CreateJointEnabledAttr().Set(False) + + assert sim_utils.apply_articulation_root_properties(asset_path, [], stage, fix_root_link=True) + assert joint.GetJointEnabledAttr().Get() is True + world_joints = [] + for prim in sim_utils.get_all_matching_child_prims( + asset_path, lambda prim: prim.IsA(UsdPhysics.FixedJoint), stage=stage + ): + usd_joint = UsdPhysics.Joint(prim) + has_body_0 = bool(usd_joint.GetBody0Rel().GetTargets()) + has_body_1 = bool(usd_joint.GetBody1Rel().GetTargets()) + if has_body_0 != has_body_1: + world_joints.append(prim) + assert world_joints == [joint.GetPrim()] + assert final_root.HasAPI(UsdPhysics.ArticulationRootAPI) + assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) + + sim.reset() + assert articulation.is_initialized + assert articulation.is_fixed_base + + @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 87f769e5167..9499b3f0779 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -306,18 +306,15 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any """Fix an articulation base for the PhysX parser and return the relocated root prim. The PhysX parser does not treat a fixed joint on a rigid body as a fixed-base articulation -- - it folds it into the maximal-coordinate tree. To work around that, this authors the - world<->root fixed joint and then relocates the ``UsdPhysics.ArticulationRootAPI`` anchor from - the root link to its parent prim, returning the parent as the new root. Articulation-root - fragments are applied by the caller *after* this call, so they land on the returned root. + it folds it into the maximal-coordinate tree. The base manager first creates or enables the + world-to-root joint, then this override relocates the complete articulation-root schema family + from the rigid root link to its parent. Articulation-root fragments are applied by the caller + after this call, so they land on the returned root. Asset-authored root schemas move with the root: any API schema directly authored on the former - root that composes ``PhysicsArticulationRootAPI`` (e.g. Newton's root API on URDF/MJCF-imported - assets) is migrated to the parent together with its authored attributes -- removing only the - directly applied anchor would leave such a schema behind and the former root would remain a - second articulation root through schema composition. The ``PhysxArticulationAPI`` companion - and its ``physxArticulation:*`` attributes are migrated the same way, matching the legacy - writer's relocation. + root that composes ``PhysicsArticulationRootAPI`` is migrated without naming another backend. + The ``PhysxArticulationAPI`` companion moves with it. Property migration preserves complete + authored USD state, including time samples, metadata, connections, and referenced opinions. Args: articulation_prim: The resolved articulation-root prim to fix to the world frame. @@ -330,63 +327,15 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any Raises: NotImplementedError: When the root prim is not a rigid body. """ - from isaaclab.sim.utils import create_fixed_root_joint - - cls._require_rigid_body_root(articulation_prim) - create_fixed_root_joint(articulation_prim, stage) - # Relocate the articulation root to the parent so the PhysX parser recognises a fixed base. - parent_prim = articulation_prim.GetParent() - UsdPhysics.ArticulationRootAPI.Apply(parent_prim) - articulation_prim.RemoveAPI(UsdPhysics.ArticulationRootAPI) - cls._migrate_authored_root_schemas(articulation_prim, parent_prim) - return parent_prim - - @staticmethod - def _migrate_authored_root_schemas(old_root: Usd.Prim, new_root: Usd.Prim) -> None: - """Move asset-authored articulation-root API schemas from the former root to the new root. - - Detection is backend-agnostic: a directly authored API schema is migrated when the USD schema - registry reports that it composes ``PhysicsArticulationRootAPI`` (so e.g. Newton's root API is - recognised without naming it). The PhysX ``PhysxArticulationAPI`` companion is migrated too -- - it does not compose the root API but carries the root's solver attributes. For each migrated - schema, its authored attributes are copied to the new root (attribute values already authored - on the new root are not overwritten by schema defaults; only authored values are copied). - - Args: - old_root: The former articulation-root prim (the root link being fixed). - new_root: The prim taking over the articulation-root role (the parent). - """ - registry = Usd.SchemaRegistry() - new_root_applied = set(new_root.GetPrimTypeInfo().GetAppliedAPISchemas()) - for schema_name in list(old_root.GetPrimTypeInfo().GetAppliedAPISchemas()): - # multi-apply instances carry an ":instance" suffix; the registry keys on the base name - base_name = schema_name.split(":", 1)[0] - prim_def = registry.FindAppliedAPIPrimDefinition(base_name) - composes_root = prim_def is not None and "PhysicsArticulationRootAPI" in prim_def.GetAppliedAPISchemas() - if not composes_root and base_name != "PhysxArticulationAPI": - continue - # move the applied schema to the new root - old_root.RemoveAppliedSchema(schema_name) - if schema_name not in new_root_applied: - new_root.AddAppliedSchema(schema_name) - new_root_applied.add(schema_name) - # copy the schema's authored attributes (property names come from the schema definition; - # fall back to the namespace prefix when the schema is not registered) - if prim_def is not None: - prop_names = list(prim_def.GetPropertyNames()) - else: - prefix = "physxArticulation:" if base_name == "PhysxArticulationAPI" else None - prop_names = [ - a.GetName() for a in old_root.GetAttributes() if prefix and a.GetName().startswith(prefix) - ] - for prop_name in prop_names: - attr = old_root.GetAttribute(prop_name) - if not attr or not attr.HasAuthoredValue(): - continue - new_attr = new_root.GetAttribute(prop_name) - if not new_attr: - new_attr = new_root.CreateAttribute(prop_name, attr.GetTypeName()) - new_attr.Set(attr.Get()) + fixed_root = super().fix_articulation_root(articulation_prim, stage) + # A previously relocated root is already a non-rigid parent; the manager call above only + # re-enables its existing joint, so there is nothing further to normalize. + if not fixed_root.HasAPI(UsdPhysics.RigidBodyAPI): + return fixed_root + return cls._relocate_articulation_root( + fixed_root, + companion_schema_namespaces={"PhysxArticulationAPI": "physxArticulation"}, + ) @classmethod def reset(cls, soft: bool = False) -> None: diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py index d5cb72c6b81..35574c63006 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/__init__.py @@ -10,11 +10,6 @@ lazy_export() -# Fixing an articulation base is a backend capability on the physics manager -# (:meth:`~isaaclab_physx.physics.PhysxManager.fix_articulation_root`): PhysX authors the fixed joint -# and relocates the articulation root to the parent prim (its parser does not treat a fixed joint on a -# rigid body as a fixed-base articulation). That PhysX-specific logic lives on the manager, so this -# schema package no longer registers a creator. def _is_physx_tendon_child(prim) -> bool: From e211621102f3c8044fe3bb3897d0b34e14270b84 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:06:27 +0000 Subject: [PATCH 079/139] [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: 8.0.3 → 8.1.0 - isaaclab_assets: 0.4.0 → 0.4.1 - isaaclab_newton: 1.5.1 → 1.6.0 - isaaclab_physx: 2.6.2 → 2.7.0 - isaaclab_tasks: 8.1.3 → 8.1.4 --- .../capture-env-sensors-tests.skip | 1 - .../mataylor-smoke-test-marker.skip | 3 -- .../vidurv-schema-frag-materials.minor.rst | 45 ---------------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 50 +++++++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../changelog.d/aserifi-drlegs.rst | 5 -- source/isaaclab_assets/config/extension.toml | 2 +- source/isaaclab_assets/docs/CHANGELOG.rst | 10 ++++ source/isaaclab_assets/pyproject.toml | 2 +- .../mataylor-smoke-test-marker.skip | 2 - .../changelog.d/rgrandia-fix-kamino-reset.rst | 27 ---------- .../vidurv-schema-frag-materials.minor.rst | 21 -------- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 54 +++++++++++++++++++ source/isaaclab_newton/pyproject.toml | 2 +- .../vidurv-schema-frag-materials.minor.rst | 12 ----- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 17 ++++++ source/isaaclab_physx/pyproject.toml | 2 +- .../changelog.d/aserifi-drlegs.rst | 14 ----- .../mataylor-smoke-test-marker.skip | 2 - .../changelog.d/mt-visual-sensor-logs.skip | 1 - source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 19 +++++++ source/isaaclab_tasks/pyproject.toml | 2 +- 26 files changed, 160 insertions(+), 143 deletions(-) delete mode 100644 source/isaaclab/changelog.d/capture-env-sensors-tests.skip delete mode 100644 source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip delete mode 100644 source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst delete mode 100644 source/isaaclab_assets/changelog.d/aserifi-drlegs.rst delete mode 100644 source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip delete mode 100644 source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst delete mode 100644 source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst delete mode 100644 source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip delete mode 100644 source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip diff --git a/source/isaaclab/changelog.d/capture-env-sensors-tests.skip b/source/isaaclab/changelog.d/capture-env-sensors-tests.skip deleted file mode 100644 index a34e75ee2c7..00000000000 --- a/source/isaaclab/changelog.d/capture-env-sensors-tests.skip +++ /dev/null @@ -1 +0,0 @@ -Tests only. diff --git a/source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip b/source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip deleted file mode 100644 index 8cc2b98fdf9..00000000000 --- a/source/isaaclab/changelog.d/mataylor-smoke-test-marker.skip +++ /dev/null @@ -1,3 +0,0 @@ -Test-only: add a ``smoke`` pytest marker and register it, then tag the -install-CI smoke tests with it so quick installation smoke checks can be -selected as a group. diff --git a/source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst b/source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst deleted file mode 100644 index 1cfe675cb0b..00000000000 --- a/source/isaaclab/changelog.d/vidurv-schema-frag-materials.minor.rst +++ /dev/null @@ -1,45 +0,0 @@ -Added -^^^^^ - -* Added the rigid-body physics-material "fragment" classes - :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` (marker base) and - :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg` (solver-common - ``physics:*`` friction/restitution/density), plus the family writer - :func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments` and the slot - dispatcher :func:`~isaaclab.sim.spawners.materials.spawn_physics_material`. Relevant - ``physics_material`` slots now accept a single fragment or list alongside their legacy cfg form. - Legacy material cfgs are current-stage-only: the dispatcher raises ``ValueError`` for an explicit - non-current stage, while fragment-based materials support explicit-stage authoring. - -* Added :attr:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg.density` (writes - ``physics:density``), completing the fragment's coverage of ``UsdPhysics.MaterialAPI``. -* Added :attr:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg.density`, so the legacy - rigid material base authors every attribute the fragment authors. - -Changed -^^^^^^^ - -* Narrowed :attr:`~isaaclab.sim.spawners.ShapeCfg.physics_material` from the broad - :class:`~isaaclab.sim.spawners.materials.PhysicsMaterialCfg` to the rigid material base, a single - rigid-material fragment, or a list of fragments. -* Extended the already rigid-only :attr:`~isaaclab.sim.spawners.GroundPlaneCfg.physics_material` - and :attr:`~isaaclab.terrains.TerrainImporterCfg.physics_material` slots from the legacy - :class:`~isaaclab_physx.sim.spawners.materials.RigidBodyMaterialCfg` type to - :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`, a single - :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of fragments. - Deformable materials remain accepted where deformables can spawn - (:class:`~isaaclab.sim.spawners.FileCfg` and :class:`~isaaclab.sim.spawners.MeshCfg`). -* Changed the generated-terrain material path to use - :func:`~isaaclab.sim.spawners.materials.spawn_physics_material`, enabling fragment lists there. - -Fixed -^^^^^ - -* Fixed mesh spawners rejecting valid rigid physics materials: the rigid-material check compared - against the deprecated leaf class, so canonical - :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` and - :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg` instances raised - ``ValueError`` on rigid meshes. The check now accepts any - :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`. -* Fixed malformed fragment inputs reaching an opaque legacy ``func`` call. Direct and dispatched - fragment calls now reject empty, mixed, and non-fragment inputs before creating a material prim. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 3617610fce2..1debfe7754a 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 = "8.0.3" +version = "8.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 48f84fcad40..05ec117ac45 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,56 @@ Changelog --------- +8.1.0 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the rigid-body physics-material "fragment" classes + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` (marker base) and + :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg` (solver-common + ``physics:*`` friction/restitution/density), plus the family writer + :func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments` and the slot + dispatcher :func:`~isaaclab.sim.spawners.materials.spawn_physics_material`. Relevant + ``physics_material`` slots now accept a single fragment or list alongside their legacy cfg form. + Legacy material cfgs are current-stage-only: the dispatcher raises ``ValueError`` for an explicit + non-current stage, while fragment-based materials support explicit-stage authoring. + +* Added :attr:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg.density` (writes + ``physics:density``), completing the fragment's coverage of ``UsdPhysics.MaterialAPI``. +* Added :attr:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg.density`, so the legacy + rigid material base authors every attribute the fragment authors. + +Changed +^^^^^^^ + +* Narrowed :attr:`~isaaclab.sim.spawners.ShapeCfg.physics_material` from the broad + :class:`~isaaclab.sim.spawners.materials.PhysicsMaterialCfg` to the rigid material base, a single + rigid-material fragment, or a list of fragments. +* Extended the already rigid-only :attr:`~isaaclab.sim.spawners.GroundPlaneCfg.physics_material` + and :attr:`~isaaclab.terrains.TerrainImporterCfg.physics_material` slots from the legacy + :class:`~isaaclab_physx.sim.spawners.materials.RigidBodyMaterialCfg` type to + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`, a single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of fragments. + Deformable materials remain accepted where deformables can spawn + (:class:`~isaaclab.sim.spawners.FileCfg` and :class:`~isaaclab.sim.spawners.MeshCfg`). +* Changed the generated-terrain material path to use + :func:`~isaaclab.sim.spawners.materials.spawn_physics_material`, enabling fragment lists there. + +Fixed +^^^^^ + +* Fixed mesh spawners rejecting valid rigid physics materials: the rigid-material check compared + against the deprecated leaf class, so canonical + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` and + :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg` instances raised + ``ValueError`` on rigid meshes. The check now accepts any + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`. +* Fixed malformed fragment inputs reaching an opaque legacy ``func`` call. Direct and dispatched + fragment calls now reject empty, mixed, and non-fragment inputs before creating a material prim. + + 8.0.3 (2026-07-03) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 3d1b04ea4ab..3f3616255b9 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.0.3" +version = "8.1.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_assets/changelog.d/aserifi-drlegs.rst b/source/isaaclab_assets/changelog.d/aserifi-drlegs.rst deleted file mode 100644 index 92397541b02..00000000000 --- a/source/isaaclab_assets/changelog.d/aserifi-drlegs.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added :data:`~isaaclab_assets.robots.dr_legs.DR_LEGS_IMPLICIT_PD_CFG` for the Disney DR Legs - closed-loop biped. diff --git a/source/isaaclab_assets/config/extension.toml b/source/isaaclab_assets/config/extension.toml index 38e4707a809..0a560242c28 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.4.0" +version = "0.4.1" # Description title = "Isaac Lab Assets" diff --git a/source/isaaclab_assets/docs/CHANGELOG.rst b/source/isaaclab_assets/docs/CHANGELOG.rst index a11a177c97f..8a2a4dd58bf 100644 --- a/source/isaaclab_assets/docs/CHANGELOG.rst +++ b/source/isaaclab_assets/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.4.1 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :data:`~isaaclab_assets.robots.dr_legs.DR_LEGS_IMPLICIT_PD_CFG` for the Disney DR Legs + closed-loop biped. + + 0.4.0 (2026-06-27) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_assets/pyproject.toml b/source/isaaclab_assets/pyproject.toml index cbd4b3a156e..b75ffb3e97a 100644 --- a/source/isaaclab_assets/pyproject.toml +++ b/source/isaaclab_assets/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_assets" -version = "0.4.0" +version = "0.4.1" description = "Extension containing configuration instances of different assets and sensors." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip b/source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip deleted file mode 100644 index 797f40bfd46..00000000000 --- a/source/isaaclab_contrib/changelog.d/mataylor-smoke-test-marker.skip +++ /dev/null @@ -1,2 +0,0 @@ -Test-only: tag the rigid/deformable coupling smoke test with the shared -``smoke`` pytest marker. diff --git a/source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst b/source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst deleted file mode 100644 index fc9ba91c17f..00000000000 --- a/source/isaaclab_newton/changelog.d/rgrandia-fix-kamino-reset.rst +++ /dev/null @@ -1,27 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** Changed :class:`~isaaclab_newton.physics.NewtonKaminoManager` to require exactly - one articulation per environment. :meth:`~isaaclab_newton.physics.NewtonKaminoManager._build_solver` - raises a ``RuntimeError`` at solver initialization when an environment contains multiple - articulations. Multiple articulations per environment are not yet supported in IsaacLab's Kamino integration. - -* Changed :class:`~isaaclab_newton.physics.NewtonManager` to route forward kinematics through a - solver-specialized hook bound during solver initialization. Kamino overrides this hook to call - :meth:`SolverKamino.reset` with :class:`SolverKamino.ResetConfig.from_joints` when - :attr:`~isaaclab_newton.physics.KaminoSolverCfg.use_fk_solver` is enabled. Environment resets - now share a single per-articulation mask for both :meth:`~isaaclab_newton.physics.NewtonManager.forward` - and pre-step reconcile, replacing the separate per-world Kamino reset mask. - -Fixed -^^^^^ - -* Fixed environment resets writing updated state into the wrong double-buffered simulation - state when ``use_cuda_graph`` was disabled. With an odd number of substeps the canonical input - state buffer flipped each step while asset write paths kept targeting the original binding, so - reset environments stayed inconsistent for solvers with separate input/output states (e.g. - :class:`~isaaclab_newton.physics.NewtonKaminoManager`). - -* Fixed Kamino forward kinematics on environment resets leaving incorrect body poses for - closed-loop systems. Reset environments are now always updated through Kamino's loop-closure FK - solver instead of Newton's articulated ``eval_fk``. diff --git a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst b/source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst deleted file mode 100644 index 977d1590eca..00000000000 --- a/source/isaaclab_newton/changelog.d/vidurv-schema-frag-materials.minor.rst +++ /dev/null @@ -1,21 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`, a single-namespace - ``newton`` rigid-body physics-material fragment (torsional and rolling friction) backing - ``NewtonMaterialAPI``. Composes with other rigid-body material fragments (e.g. - :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg`) in a fragment list. -* Added :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_stiffness`, - :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_damping`, - :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_friction_gain`, and - :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_adhesion` (writing - ``newton:contactStiffness``, ``newton:contactDamping``, ``newton:contactFrictionGain``, and - ``newton:contactAdhesion``), matching the per-material contact attributes Newton's USD schema - resolver reads in place of the deprecated per-shape ``ke``/``kd``/``kf``/``ka`` parameters. -* Added the contact attributes - (:attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_stiffness`, - :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_damping`, - :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_friction_gain`, and - :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_adhesion`) to - :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg`, matching - :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index ebdec1167e1..4894b26e39b 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 = "1.5.1" +version = "1.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 8a642cda939..d44e6d25e88 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,60 @@ Changelog --------- +1.6.0 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`, a single-namespace + ``newton`` rigid-body physics-material fragment (torsional and rolling friction) backing + ``NewtonMaterialAPI``. Composes with other rigid-body material fragments (e.g. + :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg`) in a fragment list. +* Added :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_stiffness`, + :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_damping`, + :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_friction_gain`, and + :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_adhesion` (writing + ``newton:contactStiffness``, ``newton:contactDamping``, ``newton:contactFrictionGain``, and + ``newton:contactAdhesion``), matching the per-material contact attributes Newton's USD schema + resolver reads in place of the deprecated per-shape ``ke``/``kd``/``kf``/``ka`` parameters. +* Added the contact attributes + (:attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_stiffness`, + :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_damping`, + :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_friction_gain`, and + :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_adhesion`) to + :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg`, matching + :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`. + +Changed +^^^^^^^ + +* **Breaking:** Changed :class:`~isaaclab_newton.physics.NewtonKaminoManager` to require exactly + one articulation per environment. :meth:`~isaaclab_newton.physics.NewtonKaminoManager._build_solver` + raises a ``RuntimeError`` at solver initialization when an environment contains multiple + articulations. Multiple articulations per environment are not yet supported in IsaacLab's Kamino integration. + +* Changed :class:`~isaaclab_newton.physics.NewtonManager` to route forward kinematics through a + solver-specialized hook bound during solver initialization. Kamino overrides this hook to call + :meth:`SolverKamino.reset` with :class:`SolverKamino.ResetConfig.from_joints` when + :attr:`~isaaclab_newton.physics.KaminoSolverCfg.use_fk_solver` is enabled. Environment resets + now share a single per-articulation mask for both :meth:`~isaaclab_newton.physics.NewtonManager.forward` + and pre-step reconcile, replacing the separate per-world Kamino reset mask. + +Fixed +^^^^^ + +* Fixed environment resets writing updated state into the wrong double-buffered simulation + state when ``use_cuda_graph`` was disabled. With an odd number of substeps the canonical input + state buffer flipped each step while asset write paths kept targeting the original binding, so + reset environments stayed inconsistent for solvers with separate input/output states (e.g. + :class:`~isaaclab_newton.physics.NewtonKaminoManager`). + +* Fixed Kamino forward kinematics on environment resets leaving incorrect body poses for + closed-loop systems. Reset environments are now always updated through Kamino's loop-closure FK + solver instead of Newton's articulated ``eval_fk``. + + 1.5.1 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index c01a6cc1eba..a855efb7ae6 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_newton" -version = "1.5.1" +version = "1.6.0" description = "Extension providing IsaacLab with Newton specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst b/source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst deleted file mode 100644 index b580d3ad631..00000000000 --- a/source/isaaclab_physx/changelog.d/vidurv-schema-frag-materials.minor.rst +++ /dev/null @@ -1,12 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg`, a single-namespace - ``physxMaterial`` rigid-body physics-material fragment (compliant-contact spring stiffness/damping - and the friction/restitution combine-mode tokens) backing ``PhysxMaterialAPI``. -* Added :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.damping_combine_mode` (writes - ``physxMaterial:dampingCombineMode``) and - :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.compliant_contact_acceleration_spring` - (writes ``physxMaterial:compliantContactAccelerationSpring``), completing the fragment's coverage - of ``PhysxMaterialAPI``. Also added the same two fields to the legacy - :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 9ea4a736d54..3e148db6c69 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 = "2.6.2" +version = "2.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 88a48a0ce62..c4f44204ed2 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog --------- +2.7.0 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg`, a single-namespace + ``physxMaterial`` rigid-body physics-material fragment (compliant-contact spring stiffness/damping + and the friction/restitution combine-mode tokens) backing ``PhysxMaterialAPI``. +* Added :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.damping_combine_mode` (writes + ``physxMaterial:dampingCombineMode``) and + :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.compliant_contact_acceleration_spring` + (writes ``physxMaterial:compliantContactAccelerationSpring``), completing the fragment's coverage + of ``PhysxMaterialAPI``. Also added the same two fields to the legacy + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. + + 2.6.2 (2026-07-02) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index 63d0495a2a7..82a80f7e532 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "2.6.2" +version = "2.7.0" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst b/source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst deleted file mode 100644 index 65b7ea95e46..00000000000 --- a/source/isaaclab_tasks/changelog.d/aserifi-drlegs.rst +++ /dev/null @@ -1,14 +0,0 @@ -Added -^^^^^ - -* Added ``Isaac-DrLegs-HoldPose-v0`` and ``Isaac-DrLegs-Walk-v0`` Kamino closed-loop locomotion - tasks via :class:`~isaaclab_tasks.contrib.dr_legs.hold_pose_env_cfg.DrLegsHoldPoseEnvCfg` and - :class:`~isaaclab_tasks.contrib.dr_legs.walk_env_cfg.DrLegsWalkEnvCfg`. - -* Added ``newton_kamino`` physics presets to core and contrib velocity, reach, cabinet, and Shadow - Hand environment configurations. - -Fixed -^^^^^ - -* Fixed reach task table spawn offset for Newton ``newton_mjwarp`` and ``newton_kamino`` presets. diff --git a/source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip b/source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip deleted file mode 100644 index 8487220a16c..00000000000 --- a/source/isaaclab_tasks/changelog.d/mataylor-smoke-test-marker.skip +++ /dev/null @@ -1,2 +0,0 @@ -Test-only: tag the contrib environments smoke test with the shared ``smoke`` -pytest marker. diff --git a/source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip b/source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip deleted file mode 100644 index b408a2fb78f..00000000000 --- a/source/isaaclab_tasks/changelog.d/mt-visual-sensor-logs.skip +++ /dev/null @@ -1 +0,0 @@ -Tests only: refactored rendering test utils to use shared camera image helpers. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index c93829e05c3..be7ef714b7f 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 = "8.1.3" +version = "8.1.4" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index a22dd0a0c47..7228be56533 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,25 @@ Changelog --------- +8.1.4 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``Isaac-DrLegs-HoldPose-v0`` and ``Isaac-DrLegs-Walk-v0`` Kamino closed-loop locomotion + tasks via :class:`~isaaclab_tasks.contrib.dr_legs.hold_pose_env_cfg.DrLegsHoldPoseEnvCfg` and + :class:`~isaaclab_tasks.contrib.dr_legs.walk_env_cfg.DrLegsWalkEnvCfg`. + +* Added ``newton_kamino`` physics presets to core and contrib velocity, reach, cabinet, and Shadow + Hand environment configurations. + +Fixed +^^^^^ + +* Fixed reach task table spawn offset for Newton ``newton_mjwarp`` and ``newton_kamino`` presets. + + 8.1.3 (2026-07-03) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index 4db1f1d78a3..e328a713bf0 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.1.3" +version = "8.1.4" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 9b2cb9e3c1d7119f2482647e4e5ea1101bf66c8b Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 3 Jul 2026 23:19:37 -0700 Subject: [PATCH 080/139] Simplify articulation root handling --- .../isaaclab/physics/physics_manager.py | 169 +++++------------- .../isaaclab/isaaclab/sim/schemas/schemas.py | 113 +++++------- .../physics/ovphysx_manager.py | 34 +--- .../isaaclab_physx/physics/physx_manager.py | 42 +---- 4 files changed, 108 insertions(+), 250 deletions(-) diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 4a818c16d7d..04544583b6e 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -101,175 +101,98 @@ def provides_implicit_damping(cls) -> bool: @classmethod def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: - """Fix an articulation base to the world frame and return the resulting root prim. + """Ensure that an articulation root has one enabled world fixed joint. - Ensures that a world-to-root fixed joint exists and is enabled. The operation is idempotent: - an existing disabled joint is enabled rather than duplicated. The base implementation leaves - ``articulation_prim`` unchanged, which is sufficient for backends whose parser reads a fixed - joint on the root directly (e.g. Newton). Backends whose parser requires the articulation-root - schema on the root link's parent override this method, call ``super()``, then relocate the root - and return the resulting prim. - - This is a backend capability rather than a registry keyed by cfg type: the active backend's - manager class is resolved from ``cfg.physics.class_type``, so subclassed cfgs and every - supported backend inherit the correct behaviour through the normal method-resolution order. + The base implementation leaves the root in place. Backends whose parser requires a different + root topology may relocate it and return the resulting root prim. Args: - articulation_prim: The resolved articulation-root prim to fix to the world frame. - stage: The stage the prim lives on. Defaults to None, in which case the current stage is - used. + articulation_prim: The articulation-root prim to fix. + stage: The stage containing the prim. Defaults to the current stage. Returns: - The articulation-root prim after fixing. May differ from ``articulation_prim`` when the - backend relocates the root (e.g. PhysX moves it to the parent prim). + The articulation-root prim after backend normalization. Raises: - NotImplementedError: When the root prim is not a rigid body and the first rigid-body link - cannot be determined to anchor the fixed joint. + NotImplementedError: If a new joint is needed and the root is not a rigid body. """ + from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 from isaaclab.sim.utils.stage import get_current_stage # noqa: PLC0415 if stage is None: stage = get_current_stage() - existing_joint = find_global_fixed_joint_prim(articulation_prim.GetPath().pathString, stage=stage) - if existing_joint is not None: - existing_joint.GetJointEnabledAttr().Set(True) - return articulation_prim - - cls._create_fixed_root_joint(articulation_prim, stage) - return articulation_prim - - @staticmethod - def _create_fixed_root_joint(articulation_prim: Any, stage: Any) -> Any: - """Author a world-to-root fixed joint at the articulation's current world pose.""" - from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 - root_path = articulation_prim.GetPath().pathString + joint = find_global_fixed_joint_prim(root_path, stage=stage) + if joint is not None: + joint.GetJointEnabledAttr().Set(True) + return articulation_prim if not articulation_prim.HasAPI(UsdPhysics.RigidBodyAPI): - raise NotImplementedError( - f"The articulation prim '{root_path}' does not have the" - " RigidBodyAPI applied. To create a fixed joint, we need to determine the first rigid" - " body link in the articulation tree. However, this is not implemented yet." - ) + raise NotImplementedError(f"Cannot fix non-rigid articulation root '{root_path}'.") - joint_parent = articulation_prim - while joint_parent != stage.GetPseudoRoot(): - if joint_parent.IsInPrototype() or joint_parent.IsInstanceProxy() or joint_parent.IsInstanceable(): - joint_parent = joint_parent.GetParent() - else: - break - parent_path = joint_parent.GetPath().pathString - if parent_path == "/": - parent_path = "" - joint_name = "FixedJoint" - if stage.GetPrimAtPath(f"{parent_path}/{joint_name}").IsValid(): - index = 0 - while stage.GetPrimAtPath(f"{parent_path}/{joint_name}{index}").IsValid(): - index += 1 - joint_name = f"{joint_name}{index}" + joint_path = f"{root_path}/FixedJoint" + index = 0 + while stage.GetPrimAtPath(joint_path).IsValid(): + index += 1 + joint_path = f"{root_path}/FixedJoint{index}" world_xform = UsdGeom.XformCache().GetLocalToWorldTransform(articulation_prim).RemoveScaleShear() - joint = UsdPhysics.FixedJoint.Define(stage, f"{parent_path}/{joint_name}") + joint = UsdPhysics.FixedJoint.Define(stage, joint_path) joint.CreateBody1Rel().SetTargets([articulation_prim.GetPath()]) joint.CreateLocalPos0Attr().Set(Gf.Vec3f(world_xform.ExtractTranslation())) joint.CreateLocalRot0Attr().Set(Gf.Quatf(world_xform.ExtractRotationQuat())) - joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0)) - joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0)) - max_float = 3.40282347e38 - joint.CreateBreakForceAttr().Set(max_float) - joint.CreateBreakTorqueAttr().Set(max_float) - return joint.GetPrim() + return articulation_prim @staticmethod def _relocate_articulation_root( articulation_prim: Any, - companion_schema_namespaces: dict[str, str] | None = None, + companion_schema: str, + companion_namespace: str, ) -> Any: - """Move an articulation-root schema family to the root link's parent. - - The pure-USD operation is shared by backends whose parser requires parent-root topology. - Every applied API that composes ``PhysicsArticulationRootAPI`` is moved, together with any - backend companion schemas named by ``companion_schema_namespaces``. Authored properties are - flattened before the old schemas are removed, preserving defaults, time samples, metadata, - connections, and relationship targets even when opinions come through a referenced asset. - """ + """Move root-bearing schemas and authored properties to the root link's parent.""" from pxr import Usd, UsdPhysics # noqa: PLC0415 - companion_schema_namespaces = companion_schema_namespaces or {} - if not articulation_prim.HasAPI(UsdPhysics.ArticulationRootAPI): - raise ValueError( - f"Prim '{articulation_prim.GetPath()}' is not an articulation root and cannot be relocated." - ) new_root = articulation_prim.GetParent() if new_root.HasAPI(UsdPhysics.ArticulationRootAPI): raise RuntimeError( - f"Cannot relocate articulation root '{articulation_prim.GetPath()}' to" - f" '{new_root.GetPath()}': the destination is already an articulation root." - " Relocation would merge distinct articulations." + f"Cannot relocate '{articulation_prim.GetPath()}' to existing articulation root '{new_root.GetPath()}'." ) - registry = Usd.SchemaRegistry() - root_schema_name = UsdPhysics.Tokens.PhysicsArticulationRootAPI - schemas_to_move: list[tuple[str, list[Any]]] = [] - has_root_schema = False + registry = Usd.SchemaRegistry() + root_schema = UsdPhysics.Tokens.PhysicsArticulationRootAPI + schemas_to_move = [] for schema_name in articulation_prim.GetPrimTypeInfo().GetAppliedAPISchemas(): - base_name, instance_name = registry.GetTypeNameAndInstance(schema_name) - prim_def = registry.FindAppliedAPIPrimDefinition(base_name) - is_root_anchor = base_name == root_schema_name - composes_root = prim_def is not None and root_schema_name in prim_def.GetAppliedAPISchemas() - if not is_root_anchor and not composes_root and base_name not in companion_schema_namespaces: + definition = registry.FindAppliedAPIPrimDefinition(schema_name) + if schema_name == companion_schema: + properties = list(articulation_prim.GetAuthoredPropertiesInNamespace(companion_namespace)) + elif schema_name == root_schema or ( + definition is not None and root_schema in definition.GetAppliedAPISchemas() + ): + properties = [] + if definition is not None: + for property_name in definition.GetPropertyNames(): + prop = articulation_prim.GetProperty(property_name) + if prop and prop.IsAuthored(): + properties.append(prop) + else: continue - if is_root_anchor or composes_root: - has_root_schema = True - - property_names = list(prim_def.GetPropertyNames()) if prim_def is not None else [] - if instance_name: - property_names = [ - registry.MakeMultipleApplyNameInstance(name, instance_name) for name in property_names - ] - fallback_namespace = companion_schema_namespaces.get(base_name) - if fallback_namespace is not None: - property_names.extend( - prop.GetName() for prop in articulation_prim.GetAuthoredPropertiesInNamespace(fallback_namespace) - ) - properties = [] - for property_name in dict.fromkeys(property_names): - prop = articulation_prim.GetProperty(property_name) - if prop and prop.IsAuthored(): - properties.append(prop) schemas_to_move.append((schema_name, properties)) - if not has_root_schema: - raise RuntimeError( - f"Cannot relocate articulation root '{articulation_prim.GetPath()}': its root API is" - " built in or auto-applied, so there is no movable root-schema opinion." - ) - new_root_schemas = set(new_root.GetPrimTypeInfo().GetAppliedAPISchemas()) for schema_name, properties in schemas_to_move: - if schema_name not in new_root_schemas: - if not new_root.AddAppliedSchema(schema_name): - raise RuntimeError(f"Failed to apply schema '{schema_name}' to '{new_root.GetPath()}'.") - new_root_schemas.add(schema_name) + if not new_root.AddAppliedSchema(schema_name): + raise RuntimeError(f"Failed to apply '{schema_name}' to '{new_root.GetPath()}'.") for prop in properties: if not prop.FlattenTo(new_root): - raise RuntimeError( - f"Failed to move property '{prop.GetPath()}' to articulation root '{new_root.GetPath()}'." - ) - + raise RuntimeError(f"Failed to move '{prop.GetPath()}' to '{new_root.GetPath()}'.") for schema_name, _ in schemas_to_move: if not articulation_prim.RemoveAppliedSchema(schema_name): - raise RuntimeError( - f"Failed to remove schema '{schema_name}' from old articulation root" - f" '{articulation_prim.GetPath()}'." - ) - if not new_root.HasAPI(UsdPhysics.ArticulationRootAPI) or articulation_prim.HasAPI( + raise RuntimeError(f"Failed to remove '{schema_name}' from '{articulation_prim.GetPath()}'.") + if articulation_prim.HasAPI(UsdPhysics.ArticulationRootAPI) or not new_root.HasAPI( UsdPhysics.ArticulationRootAPI ): raise RuntimeError( - f"Relocating articulation root '{articulation_prim.GetPath()}' to '{new_root.GetPath()}'" - " did not produce exactly one root at the destination." + f"Failed to relocate articulation root '{articulation_prim.GetPath()}' to '{new_root.GetPath()}'." ) return new_root diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index 309dd8cc9fb..cb687ad40d0 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -281,84 +281,69 @@ def apply_articulation_root_properties( stage: Usd.Stage | None = None, fix_root_link: bool | None = None, ) -> bool: - """Apply articulation-root fragments to every top-level articulation under a prim. + """Apply fragments to every top-level articulation root under a prim. - Existing articulation roots may live on descendant root links, and a USD file may contain - several sibling articulations. This writer discovers every top-level (non-nested) root and - dispatches each fragment to each root, matching the traversal semantics of the legacy - :func:`modify_articulation_root_properties` writer. Only when no root exists and fragments are - present does it apply ``UsdPhysics.ArticulationRootAPI`` to ``prim_path`` as a fresh anchor. + Existing roots are discovered before a fresh anchor is applied, including roots hidden in + instances. Nested roots are pruned, while sibling roots are all processed. Instance roots are + reported but not authored. - Instance proxies are traversed for discovery so a hidden root suppresses that define-fresh - fallback, but they are never authored through. Skipped instance roots make the operation return - ``False`` with a warning, matching the legacy nested writer. - - When ``fix_root_link`` is ``True``, each writable root is normalized by the active backend's - :meth:`~isaaclab.physics.PhysicsManager.fix_articulation_root` capability before fragments are - written. The capability creates or enables the world joint and returns the final root prim; - PhysX-based parsers may relocate it to the parent. ``False`` disables an existing world joint - without creating one. Backend fragments carry backend-specific funcs, so core imports no backend. + When fix_root_link is True, the active physics manager creates or enables the world joint and + returns the backend's final root prim. False only disables an existing joint. Args: prim_path: The prim path whose subtree is searched for articulation roots. - fragments: An iterable of :class:`~isaaclab.sim.schemas.ArticulationRootFragment` instances. - stage: The stage where to find the prim. Defaults to the current stage. - fix_root_link: Whether to fix the root link. This is spawner topology state, not a fragment - field. ``None`` leaves the topology unchanged. + fragments: Articulation-root fragments to apply. + stage: The stage containing the prim. Defaults to the current stage. + fix_root_link: Whether to fix the root link. None leaves topology unchanged. Returns: - True when every fragment/root operation succeeds. False when any fragment reports failure or - an articulation root is skipped because it is instanced. + True if every writable root and fragment succeeds and no instance root is skipped. Raises: - TypeError: When an item in ``fragments`` is not an - :class:`~isaaclab.sim.schemas.ArticulationRootFragment`. - ValueError: When ``prim_path`` is not valid. - RuntimeError: When fixing requires a backend but no active - :class:`~isaaclab.sim.SimulationContext` exists, or relocation would merge roots. - NotImplementedError: Propagated when a backend cannot fix the resolved root. + TypeError: If fragments contains a non-articulation fragment. + ValueError: If prim_path is invalid. + RuntimeError: If fixing cannot resolve the active backend or relocate the root. + NotImplementedError: If the backend cannot fix the resolved root. """ fragments = list(fragments) for fragment in fragments: if not isinstance(fragment, schemas_cfg.ArticulationRootFragment): raise TypeError( - "apply_articulation_root_properties: expected ArticulationRootFragment instances in" - f" 'fragments', got '{type(fragment).__name__}'. Pass a legacy single cfg to" - " modify_articulation_root_properties instead." + f"Expected ArticulationRootFragment, got '{type(fragment).__name__}'." + " Pass legacy cfgs to modify_articulation_root_properties." ) + dispatchers = [ + fragment.func if callable(fragment.func) else string_to_callable(fragment.func) for fragment in fragments + ] if stage is None: stage = get_current_stage() - # Discover proxies as well as writable prims so a root hidden inside an instance cannot trigger a - # second anchor on the instance root. Keep only top-level matches; nested articulations are pruned. - matches = get_all_matching_child_prims( + roots = [] + for candidate in get_all_matching_child_prims( prim_path, lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), stage=stage, traverse_instance_prims=True, - ) - top_level_roots = [] - for candidate in matches: - if not any(candidate.GetPath().HasPrefix(root.GetPath()) for root in top_level_roots): - top_level_roots.append(candidate) + ): + if not any(candidate.GetPath().HasPrefix(root.GetPath()) for root in roots): + roots.append(candidate) writable_roots = [] skipped_roots = [] - for root in top_level_roots: - if root.IsInstance() or root.IsInstanceProxy() or root.IsInPrototype(): + for root in roots: + if root.IsInstance() or root.IsInstanceProxy(): skipped_roots.append(root) else: writable_roots.append(root) - if not top_level_roots and fragments: - input_prim = stage.GetPrimAtPath(prim_path) - if input_prim.IsInstance() or input_prim.IsInstanceProxy() or input_prim.IsInPrototype(): - skipped_roots.append(input_prim) + if not roots and fragments: + root = stage.GetPrimAtPath(prim_path) + if root.IsInstance() or root.IsInstanceProxy(): + skipped_roots.append(root) else: - UsdPhysics.ArticulationRootAPI.Apply(input_prim) - writable_roots.append(input_prim) + UsdPhysics.ArticulationRootAPI.Apply(root) + writable_roots.append(root) - success = not skipped_roots if skipped_roots: logger.warning( "Skipping articulation-root updates on instanced prims: %s.", @@ -369,37 +354,27 @@ def apply_articulation_root_properties( logger.warning( "No articulation root found under '%s': ignoring fix_root_link=%s.", prim_path, fix_root_link ) - success = False - return success + return not skipped_roots and fix_root_link is None - physics_manager = None if fix_root_link: from isaaclab.sim import SimulationContext sim = SimulationContext.instance() if sim is None: - raise RuntimeError( - f"Cannot fix articulation roots under '{prim_path}': no active SimulationContext to" - " resolve the physics backend from. A simulation must be running to fix a base." - ) - physics_manager = sim.physics_manager + raise RuntimeError(f"Cannot fix articulation roots under '{prim_path}' without an active simulation.") - for articulation_prim in writable_roots: - final_root = articulation_prim - root_path = articulation_prim.GetPath().pathString + success = not skipped_roots + for root in writable_roots: if fix_root_link: - logger.info("Ensuring a fixed root for articulation: '%s'.", root_path) - final_root = physics_manager.fix_articulation_root(articulation_prim, stage) + root = sim.physics_manager.fix_articulation_root(root, stage) elif fix_root_link is False: - existing_joint = find_global_fixed_joint_prim(root_path, stage=stage) - if existing_joint is not None: - logger.info("Disabling the existing fixed joint for articulation: '%s'.", root_path) - existing_joint.GetJointEnabledAttr().Set(False) - - final_root_path = final_root.GetPath().pathString - for fragment in fragments: - func = fragment.func if callable(fragment.func) else string_to_callable(fragment.func) - success = bool(func(fragment, final_root_path, stage)) and success + joint = find_global_fixed_joint_prim(root.GetPath().pathString, stage=stage) + if joint is not None: + joint.GetJointEnabledAttr().Set(False) + + root_path = root.GetPath().pathString + for fragment, func in zip(fragments, dispatchers): + success = bool(func(fragment, root_path, stage)) and success return success diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index c0e2b2872b5..9e6efb7752b 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -230,31 +230,15 @@ def get_dt(cls) -> float: @classmethod def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: - """Fix an articulation base using the parent-root topology required by OVPhysX. - - OVPhysX uses the PhysX articulation parser, so a world joint on a rigid root link is not by - itself classified as a fixed-base articulation. The base manager creates or enables the - joint; this override then relocates the complete articulation-root schema family to the root - link's parent, preserving authored property specifications. - - Args: - articulation_prim: The resolved articulation-root prim to fix to the world frame. - stage: The stage the prim lives on. Defaults to None, in which case the current stage is - used. - - Returns: - The parent prim, which becomes the articulation root after relocation. - - Raises: - NotImplementedError: When the root prim is not a rigid body. - """ - fixed_root = super().fix_articulation_root(articulation_prim, stage) - if not fixed_root.HasAPI(UsdPhysics.RigidBodyAPI): - return fixed_root - return cls._relocate_articulation_root( - fixed_root, - companion_schema_namespaces={"PhysxArticulationAPI": "physxArticulation"}, - ) + """Fix and normalize an articulation root for the OVPhysX parser.""" + root = super().fix_articulation_root(articulation_prim, stage) + if root.HasAPI(UsdPhysics.RigidBodyAPI): + return cls._relocate_articulation_root( + root, + companion_schema="PhysxArticulationAPI", + companion_namespace="physxArticulation", + ) + return root @classmethod def register_clone( diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 9499b3f0779..d1d1ead0fe8 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -303,39 +303,15 @@ def initialize(cls, sim_context: SimulationContext) -> None: @classmethod def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any: - """Fix an articulation base for the PhysX parser and return the relocated root prim. - - The PhysX parser does not treat a fixed joint on a rigid body as a fixed-base articulation -- - it folds it into the maximal-coordinate tree. The base manager first creates or enables the - world-to-root joint, then this override relocates the complete articulation-root schema family - from the rigid root link to its parent. Articulation-root fragments are applied by the caller - after this call, so they land on the returned root. - - Asset-authored root schemas move with the root: any API schema directly authored on the former - root that composes ``PhysicsArticulationRootAPI`` is migrated without naming another backend. - The ``PhysxArticulationAPI`` companion moves with it. Property migration preserves complete - authored USD state, including time samples, metadata, connections, and referenced opinions. - - Args: - articulation_prim: The resolved articulation-root prim to fix to the world frame. - stage: The stage the prim lives on. Defaults to None, in which case the current stage is - used. - - Returns: - The parent prim, which becomes the articulation root after relocation. - - Raises: - NotImplementedError: When the root prim is not a rigid body. - """ - fixed_root = super().fix_articulation_root(articulation_prim, stage) - # A previously relocated root is already a non-rigid parent; the manager call above only - # re-enables its existing joint, so there is nothing further to normalize. - if not fixed_root.HasAPI(UsdPhysics.RigidBodyAPI): - return fixed_root - return cls._relocate_articulation_root( - fixed_root, - companion_schema_namespaces={"PhysxArticulationAPI": "physxArticulation"}, - ) + """Fix and normalize an articulation root for the PhysX parser.""" + root = super().fix_articulation_root(articulation_prim, stage) + if root.HasAPI(UsdPhysics.RigidBodyAPI): + return cls._relocate_articulation_root( + root, + companion_schema="PhysxArticulationAPI", + companion_namespace="physxArticulation", + ) + return root @classmethod def reset(cls, soft: bool = False) -> None: From 0d4b413f46825d95b50bd6bd55f82bc16f681a17 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 3 Jul 2026 23:45:55 -0700 Subject: [PATCH 081/139] Test Newton inherited fixed-root handling --- .../test/assets/test_articulation.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index 287c88762c5..16cb5b9d55d 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -28,6 +28,8 @@ from isaaclab_newton.physics import NewtonManager as SimulationManager from newton.solvers import SolverNotifyFlags +from pxr import UsdPhysics + import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils import isaaclab.utils.string as string_utils @@ -782,6 +784,29 @@ def test_initialization_hand_with_tendons(sim, num_articulations, device, articu articulation.update(sim.cfg.dt) +@pytest.mark.parametrize("device", ["cpu"]) +@pytest.mark.parametrize("add_ground_plane", [True]) +@pytest.mark.parametrize("articulation_type", ["anymal"]) +def test_fragment_fix_root_link_uses_base_manager(sim, device, add_ground_plane, articulation_type): + """Newton consumes the base manager's world joint without relocating the root API.""" + articulation_cfg = deepcopy(generate_articulation_cfg(articulation_type=articulation_type)) + articulation_cfg.spawn.articulation_props = [] + articulation_cfg.spawn.fix_root_link = True + articulation, _ = generate_articulation(articulation_cfg, num_articulations=1, device=device) + + root = sim_utils.get_first_matching_child_prim( + "/World/Env_0/Robot", + lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI), + stage=sim.stage, + ) + assert root is not None and root.HasAPI(UsdPhysics.RigidBodyAPI) + assert sim_utils.find_global_fixed_joint_prim("/World/Env_0/Robot", stage=sim.stage) is not None + + sim.reset() + assert articulation.is_initialized + assert articulation.is_fixed_base + + @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) From 7774ba318ba37c89399efa2161cf27e9caad6dd0 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Sat, 4 Jul 2026 00:52:19 -0700 Subject: [PATCH 082/139] Avoid local imports in articulation root handling --- .../isaaclab/physics/physics_manager.py | 12 ++-- .../isaaclab/isaaclab/sim/utils/__init__.pyi | 2 +- source/isaaclab/isaaclab/sim/utils/joints.py | 72 +++++++++++++++++++ source/isaaclab/isaaclab/sim/utils/queries.py | 65 +---------------- 4 files changed, 80 insertions(+), 71 deletions(-) create mode 100644 source/isaaclab/isaaclab/sim/utils/joints.py diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 04544583b6e..d2f7bbff288 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -14,6 +14,11 @@ from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar +from pxr import Gf, Usd, UsdGeom, UsdPhysics + +from isaaclab.sim.utils.joints import find_global_fixed_joint_prim +from isaaclab.sim.utils.stage import get_current_stage + if TYPE_CHECKING: from isaaclab.scene_data import SceneDataBackend from isaaclab.sim.simulation_context import SimulationContext @@ -116,11 +121,6 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any Raises: NotImplementedError: If a new joint is needed and the root is not a rigid body. """ - from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 - - from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 - from isaaclab.sim.utils.stage import get_current_stage # noqa: PLC0415 - if stage is None: stage = get_current_stage() root_path = articulation_prim.GetPath().pathString @@ -151,8 +151,6 @@ def _relocate_articulation_root( companion_namespace: str, ) -> Any: """Move root-bearing schemas and authored properties to the root link's parent.""" - from pxr import Usd, UsdPhysics # noqa: PLC0415 - new_root = articulation_prim.GetParent() if new_root.HasAPI(UsdPhysics.ArticulationRootAPI): raise RuntimeError( diff --git a/source/isaaclab/isaaclab/sim/utils/__init__.pyi b/source/isaaclab/isaaclab/sim/utils/__init__.pyi index cfe39d0933e..a13d34fc2e9 100644 --- a/source/isaaclab/isaaclab/sim/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/utils/__init__.pyi @@ -89,6 +89,7 @@ from .prims import ( get_usd_references, select_usd_variants, ) +from .joints import find_global_fixed_joint_prim from .queries import ( get_next_free_prim_path, get_first_matching_ancestor_prim, @@ -99,7 +100,6 @@ from .queries import ( matches_path_expr_prefix, resolve_matching_prims_from_source, find_matching_prim_paths, - find_global_fixed_joint_prim, ) from .semantics import ( add_labels, diff --git a/source/isaaclab/isaaclab/sim/utils/joints.py b/source/isaaclab/isaaclab/sim/utils/joints.py new file mode 100644 index 00000000000..2847e76955c --- /dev/null +++ b/source/isaaclab/isaaclab/sim/utils/joints.py @@ -0,0 +1,72 @@ +# 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 + +"""Utilities for querying USD physics joints.""" + +from __future__ import annotations + +from pxr import Sdf, Usd, UsdPhysics + +from .stage import get_current_stage + + +def find_global_fixed_joint_prim( + prim_path: str | Sdf.Path, check_enabled_only: bool = False, stage: Usd.Stage | None = None +) -> UsdPhysics.Joint | None: + """Find the fixed joint prim under the specified prim path that connects the target to the simulation world. + + A joint is a connection between two bodies. A fixed joint is a joint that does not allow relative motion + between the two bodies. When a fixed joint has only one target body, it is considered to attach the body + to the simulation world. + + This function finds the fixed joint prim that has only one target under the specified prim path. If no such + fixed joint prim exists, it returns None. + + Args: + prim_path: The prim path to search for the fixed joint prim. + check_enabled_only: Whether to consider only enabled fixed joints. Defaults to False. + If False, then all joints (enabled or disabled) are considered. + stage: The stage where the prim exists. Defaults to None, in which case the current stage is used. + + Returns: + The fixed joint prim that has only one target. If no such fixed joint prim exists, it returns None. + + Raises: + ValueError: If the prim path is not global (i.e: does not start with '/'). + ValueError: If the prim path does not exist on the stage. + """ + # get stage handle + if stage is None: + stage = get_current_stage() + + # check prim path is global + if not prim_path.startswith("/"): + raise ValueError(f"Prim path '{prim_path}' is not global. It must start with '/'.") + + # check if prim exists + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsValid(): + raise ValueError(f"Prim at path '{prim_path}' is not valid.") + + fixed_joint_prim = None + # we check all joints under the root prim and classify the asset as fixed base if there exists + # a fixed joint that has only one target (i.e. the root link). + for prim in Usd.PrimRange(prim): + # note: ideally checking if it is FixedJoint would have been enough, but some assets use "Joint" as the + # schema name which makes it difficult to distinguish between the two. + joint_prim = UsdPhysics.Joint(prim) + if joint_prim: + # if check_enabled_only is True, we only consider enabled joints + if check_enabled_only and not joint_prim.GetJointEnabledAttr().Get(): + continue + # check body 0 and body 1 exist + body_0_exist = joint_prim.GetBody0Rel().GetTargets() != [] + body_1_exist = joint_prim.GetBody1Rel().GetTargets() != [] + # if either body 0 or body 1 does not exist, we have a fixed joint that connects to the world + if not (body_0_exist and body_1_exist): + fixed_joint_prim = joint_prim + break + + return fixed_joint_prim diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index 4d5dcb3ac02..de7e728c8e1 100644 --- a/source/isaaclab/isaaclab/sim/utils/queries.py +++ b/source/isaaclab/isaaclab/sim/utils/queries.py @@ -15,10 +15,11 @@ from isaaclab.cloner.cloner_utils import resolve_clone_plan_source from isaaclab.sim.simulation_context import SimulationContext +from .joints import find_global_fixed_joint_prim as find_global_fixed_joint_prim from .stage import get_current_stage if TYPE_CHECKING: - from pxr import Sdf, Usd, UsdPhysics # noqa: F401 + from pxr import Sdf, Usd # noqa: F401 # import logger logger = logging.getLogger(__name__) @@ -500,65 +501,3 @@ def find_matching_prim_paths(prim_path_regex: str, stage: Usd.Stage | None = Non for prim in output_prims: output_prim_paths.append(prim.GetPath().pathString) return output_prim_paths - - -def find_global_fixed_joint_prim( - prim_path: str | Sdf.Path, check_enabled_only: bool = False, stage: Usd.Stage | None = None -) -> UsdPhysics.Joint | None: - """Find the fixed joint prim under the specified prim path that connects the target to the simulation world. - - A joint is a connection between two bodies. A fixed joint is a joint that does not allow relative motion - between the two bodies. When a fixed joint has only one target body, it is considered to attach the body - to the simulation world. - - This function finds the fixed joint prim that has only one target under the specified prim path. If no such - fixed joint prim exists, it returns None. - - Args: - prim_path: The prim path to search for the fixed joint prim. - check_enabled_only: Whether to consider only enabled fixed joints. Defaults to False. - If False, then all joints (enabled or disabled) are considered. - stage: The stage where the prim exists. Defaults to None, in which case the current stage is used. - - Returns: - The fixed joint prim that has only one target. If no such fixed joint prim exists, it returns None. - - Raises: - ValueError: If the prim path is not global (i.e: does not start with '/'). - ValueError: If the prim path does not exist on the stage. - """ - from pxr import Usd, UsdPhysics # noqa: PLC0415 - - # get stage handle - if stage is None: - stage = get_current_stage() - - # check prim path is global - if not prim_path.startswith("/"): - raise ValueError(f"Prim path '{prim_path}' is not global. It must start with '/'.") - - # check if prim exists - prim = stage.GetPrimAtPath(prim_path) - if not prim.IsValid(): - raise ValueError(f"Prim at path '{prim_path}' is not valid.") - - fixed_joint_prim = None - # we check all joints under the root prim and classify the asset as fixed base if there exists - # a fixed joint that has only one target (i.e. the root link). - for prim in Usd.PrimRange(prim): - # note: ideally checking if it is FixedJoint would have been enough, but some assets use "Joint" as the - # schema name which makes it difficult to distinguish between the two. - joint_prim = UsdPhysics.Joint(prim) - if joint_prim: - # if check_enabled_only is True, we only consider enabled joints - if check_enabled_only and not joint_prim.GetJointEnabledAttr().Get(): - continue - # check body 0 and body 1 exist - body_0_exist = joint_prim.GetBody0Rel().GetTargets() != [] - body_1_exist = joint_prim.GetBody1Rel().GetTargets() != [] - # if either body 0 or body 1 does not exist, we have a fixed joint that connects to the world - if not (body_0_exist and body_1_exist): - fixed_joint_prim = joint_prim - break - - return fixed_joint_prim From 280c20a02e57f01f56e50c180d40a00e65e78b52 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Sat, 4 Jul 2026 02:57:43 -0700 Subject: [PATCH 083/139] Keep cyclic fixed-joint query import local --- .../isaaclab/physics/physics_manager.py | 5 +- .../isaaclab/isaaclab/sim/utils/__init__.pyi | 2 +- source/isaaclab/isaaclab/sim/utils/joints.py | 72 ------------------- source/isaaclab/isaaclab/sim/utils/queries.py | 65 ++++++++++++++++- 4 files changed, 68 insertions(+), 76 deletions(-) delete mode 100644 source/isaaclab/isaaclab/sim/utils/joints.py diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index d2f7bbff288..840eef44e69 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -16,7 +16,6 @@ from pxr import Gf, Usd, UsdGeom, UsdPhysics -from isaaclab.sim.utils.joints import find_global_fixed_joint_prim from isaaclab.sim.utils.stage import get_current_stage if TYPE_CHECKING: @@ -121,6 +120,10 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any Raises: NotImplementedError: If a new joint is needed and the root is not a rigid body. """ + # Keep this import local to avoid the SimulationContext -> PhysicsManager -> + # sim.utils.queries -> SimulationContext import cycle. + from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 + if stage is None: stage = get_current_stage() root_path = articulation_prim.GetPath().pathString diff --git a/source/isaaclab/isaaclab/sim/utils/__init__.pyi b/source/isaaclab/isaaclab/sim/utils/__init__.pyi index a13d34fc2e9..cfe39d0933e 100644 --- a/source/isaaclab/isaaclab/sim/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/utils/__init__.pyi @@ -89,7 +89,6 @@ from .prims import ( get_usd_references, select_usd_variants, ) -from .joints import find_global_fixed_joint_prim from .queries import ( get_next_free_prim_path, get_first_matching_ancestor_prim, @@ -100,6 +99,7 @@ from .queries import ( matches_path_expr_prefix, resolve_matching_prims_from_source, find_matching_prim_paths, + find_global_fixed_joint_prim, ) from .semantics import ( add_labels, diff --git a/source/isaaclab/isaaclab/sim/utils/joints.py b/source/isaaclab/isaaclab/sim/utils/joints.py deleted file mode 100644 index 2847e76955c..00000000000 --- a/source/isaaclab/isaaclab/sim/utils/joints.py +++ /dev/null @@ -1,72 +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 - -"""Utilities for querying USD physics joints.""" - -from __future__ import annotations - -from pxr import Sdf, Usd, UsdPhysics - -from .stage import get_current_stage - - -def find_global_fixed_joint_prim( - prim_path: str | Sdf.Path, check_enabled_only: bool = False, stage: Usd.Stage | None = None -) -> UsdPhysics.Joint | None: - """Find the fixed joint prim under the specified prim path that connects the target to the simulation world. - - A joint is a connection between two bodies. A fixed joint is a joint that does not allow relative motion - between the two bodies. When a fixed joint has only one target body, it is considered to attach the body - to the simulation world. - - This function finds the fixed joint prim that has only one target under the specified prim path. If no such - fixed joint prim exists, it returns None. - - Args: - prim_path: The prim path to search for the fixed joint prim. - check_enabled_only: Whether to consider only enabled fixed joints. Defaults to False. - If False, then all joints (enabled or disabled) are considered. - stage: The stage where the prim exists. Defaults to None, in which case the current stage is used. - - Returns: - The fixed joint prim that has only one target. If no such fixed joint prim exists, it returns None. - - Raises: - ValueError: If the prim path is not global (i.e: does not start with '/'). - ValueError: If the prim path does not exist on the stage. - """ - # get stage handle - if stage is None: - stage = get_current_stage() - - # check prim path is global - if not prim_path.startswith("/"): - raise ValueError(f"Prim path '{prim_path}' is not global. It must start with '/'.") - - # check if prim exists - prim = stage.GetPrimAtPath(prim_path) - if not prim.IsValid(): - raise ValueError(f"Prim at path '{prim_path}' is not valid.") - - fixed_joint_prim = None - # we check all joints under the root prim and classify the asset as fixed base if there exists - # a fixed joint that has only one target (i.e. the root link). - for prim in Usd.PrimRange(prim): - # note: ideally checking if it is FixedJoint would have been enough, but some assets use "Joint" as the - # schema name which makes it difficult to distinguish between the two. - joint_prim = UsdPhysics.Joint(prim) - if joint_prim: - # if check_enabled_only is True, we only consider enabled joints - if check_enabled_only and not joint_prim.GetJointEnabledAttr().Get(): - continue - # check body 0 and body 1 exist - body_0_exist = joint_prim.GetBody0Rel().GetTargets() != [] - body_1_exist = joint_prim.GetBody1Rel().GetTargets() != [] - # if either body 0 or body 1 does not exist, we have a fixed joint that connects to the world - if not (body_0_exist and body_1_exist): - fixed_joint_prim = joint_prim - break - - return fixed_joint_prim diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index de7e728c8e1..4d5dcb3ac02 100644 --- a/source/isaaclab/isaaclab/sim/utils/queries.py +++ b/source/isaaclab/isaaclab/sim/utils/queries.py @@ -15,11 +15,10 @@ from isaaclab.cloner.cloner_utils import resolve_clone_plan_source from isaaclab.sim.simulation_context import SimulationContext -from .joints import find_global_fixed_joint_prim as find_global_fixed_joint_prim from .stage import get_current_stage if TYPE_CHECKING: - from pxr import Sdf, Usd # noqa: F401 + from pxr import Sdf, Usd, UsdPhysics # noqa: F401 # import logger logger = logging.getLogger(__name__) @@ -501,3 +500,65 @@ def find_matching_prim_paths(prim_path_regex: str, stage: Usd.Stage | None = Non for prim in output_prims: output_prim_paths.append(prim.GetPath().pathString) return output_prim_paths + + +def find_global_fixed_joint_prim( + prim_path: str | Sdf.Path, check_enabled_only: bool = False, stage: Usd.Stage | None = None +) -> UsdPhysics.Joint | None: + """Find the fixed joint prim under the specified prim path that connects the target to the simulation world. + + A joint is a connection between two bodies. A fixed joint is a joint that does not allow relative motion + between the two bodies. When a fixed joint has only one target body, it is considered to attach the body + to the simulation world. + + This function finds the fixed joint prim that has only one target under the specified prim path. If no such + fixed joint prim exists, it returns None. + + Args: + prim_path: The prim path to search for the fixed joint prim. + check_enabled_only: Whether to consider only enabled fixed joints. Defaults to False. + If False, then all joints (enabled or disabled) are considered. + stage: The stage where the prim exists. Defaults to None, in which case the current stage is used. + + Returns: + The fixed joint prim that has only one target. If no such fixed joint prim exists, it returns None. + + Raises: + ValueError: If the prim path is not global (i.e: does not start with '/'). + ValueError: If the prim path does not exist on the stage. + """ + from pxr import Usd, UsdPhysics # noqa: PLC0415 + + # get stage handle + if stage is None: + stage = get_current_stage() + + # check prim path is global + if not prim_path.startswith("/"): + raise ValueError(f"Prim path '{prim_path}' is not global. It must start with '/'.") + + # check if prim exists + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsValid(): + raise ValueError(f"Prim at path '{prim_path}' is not valid.") + + fixed_joint_prim = None + # we check all joints under the root prim and classify the asset as fixed base if there exists + # a fixed joint that has only one target (i.e. the root link). + for prim in Usd.PrimRange(prim): + # note: ideally checking if it is FixedJoint would have been enough, but some assets use "Joint" as the + # schema name which makes it difficult to distinguish between the two. + joint_prim = UsdPhysics.Joint(prim) + if joint_prim: + # if check_enabled_only is True, we only consider enabled joints + if check_enabled_only and not joint_prim.GetJointEnabledAttr().Get(): + continue + # check body 0 and body 1 exist + body_0_exist = joint_prim.GetBody0Rel().GetTargets() != [] + body_1_exist = joint_prim.GetBody1Rel().GetTargets() != [] + # if either body 0 or body 1 does not exist, we have a fixed joint that connects to the world + if not (body_0_exist and body_1_exist): + fixed_joint_prim = joint_prim + break + + return fixed_joint_prim From 17136c6fc2e16af098a6e08a9d75905dc53e09cc Mon Sep 17 00:00:00 2001 From: ooctipus Date: Sat, 4 Jul 2026 03:54:45 -0700 Subject: [PATCH 084/139] Fix contact sensor activation for nested rigid bodies (#6259) ## Summary - use `get_all_matching_child_prims` when activating PhysX contact report schemas - continue matching rigid descendants under nested rigid-body trees instead of stopping at the first rigid body - add regression coverage for nested rigid bodies receiving `PhysxContactReportAPI` ## Testing - ./isaaclab.sh -p -m pytest source/isaaclab/test/sim/test_schemas.py -k activate_contact_sensors_nested_rigid_bodies -q - ./isaaclab.sh -p -m py_compile source/isaaclab/isaaclab/sim/schemas/schemas.py source/isaaclab/test/sim/test_schemas.py ## Note - Attempted `./isaaclab.sh -p -m pytest source/isaaclab_physx/test/sensors/test_contact_sensor.py -k contact_sensor_threshold -q`, but collection failed because `flaky` is not installed in the local environment. --- ...te-contact-sensors-nested-rigid-bodies.rst | 4 ++ .../isaaclab/isaaclab/sim/schemas/schemas.py | 44 ++++++++----------- source/isaaclab/test/sim/test_schemas.py | 27 ++++++++++++ 3 files changed, 49 insertions(+), 26 deletions(-) create mode 100644 source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst diff --git a/source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst b/source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst new file mode 100644 index 00000000000..c6df8d9b9c9 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed contact sensor activation for rigid bodies nested below other rigid bodies. diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index bdcc073d35c..035f5509f4c 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -933,33 +933,25 @@ def activate_contact_sensors(prim_path: str, threshold: float = 0.0, stage: Usd. # check if prim is valid if not prim.IsValid(): raise ValueError(f"Prim path '{prim_path}' is not valid.") - # iterate over all children - num_contact_sensors = 0 - all_prims = [prim] - while len(all_prims) > 0: - # get current prim - child_prim = all_prims.pop(0) - # check if prim is a rigid body - # nested rigid bodies are not allowed by SDK so we can safely assume that - # if a prim has a rigid body API, it is a rigid body and we don't need to - # check its children - if child_prim.HasAPI(UsdPhysics.RigidBodyAPI): - # set sleep threshold to zero - child_applied = child_prim.GetAppliedSchemas() - if "PhysxRigidBodyAPI" not in child_applied: - child_prim.AddAppliedSchema("PhysxRigidBodyAPI") - safe_set_attribute_on_usd_prim(child_prim, "physxRigidBody:sleepThreshold", 0.0, camel_case=False) - # add contact report API with threshold of zero - if "PhysxContactReportAPI" not in child_applied: - child_prim.AddAppliedSchema("PhysxContactReportAPI") - safe_set_attribute_on_usd_prim(child_prim, "physxContactReport:threshold", threshold, camel_case=False) - # increment number of contact sensors - num_contact_sensors += 1 - else: - # add all children to tree - all_prims += child_prim.GetChildren() + # collect all rigid bodies under the prim, including nested rigid-body trees + rigid_body_prims = get_all_matching_child_prims( + prim_path, + predicate=lambda child_prim: child_prim.HasAPI(UsdPhysics.RigidBodyAPI), + stage=stage, + traverse_instance_prims=False, + ) + for child_prim in rigid_body_prims: + # set sleep threshold to zero + child_applied = child_prim.GetAppliedSchemas() + if "PhysxRigidBodyAPI" not in child_applied: + child_prim.AddAppliedSchema("PhysxRigidBodyAPI") + safe_set_attribute_on_usd_prim(child_prim, "physxRigidBody:sleepThreshold", 0.0, camel_case=False) + # add contact report API with threshold of zero + if "PhysxContactReportAPI" not in child_applied: + child_prim.AddAppliedSchema("PhysxContactReportAPI") + safe_set_attribute_on_usd_prim(child_prim, "physxContactReport:threshold", threshold, camel_case=False) # check if no contact sensors were found - if num_contact_sensors == 0: + if not rigid_body_prims: descendant_count = 0 frontier = [prim] while frontier: diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index 32143a8bb43..f22c7db4ad7 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -752,6 +752,33 @@ def test_modify_properties_on_articulation_usd(setup_simulation): _validate_articulation_properties_on_prim("/World/asset", arti_cfg, True) +@pytest.mark.isaacsim_ci +def test_activate_contact_sensors_nested_rigid_bodies(setup_simulation): + """Test contact-report schemas are applied to nested rigid-body trees.""" + stage = sim_utils.get_current_stage() + + rigid_body_paths = [ + "/World/Robot/Geometry/pelvis", + "/World/Robot/Geometry/pelvis/left_hip", + "/World/Robot/Geometry/pelvis/left_hip/left_knee", + ] + sim_utils.create_prim("/World/Robot", prim_type="Xform") + sim_utils.create_prim("/World/Robot/Geometry", prim_type="Xform") + for prim_path in rigid_body_paths: + sim_utils.create_prim(prim_path, prim_type="Xform") + UsdPhysics.RigidBodyAPI.Apply(stage.GetPrimAtPath(prim_path)) + + schemas.activate_contact_sensors("/World/Robot", threshold=2.5) + + for prim_path in rigid_body_paths: + prim = stage.GetPrimAtPath(prim_path) + applied_schemas = prim.GetAppliedSchemas() + assert "PhysxRigidBodyAPI" in applied_schemas + assert "PhysxContactReportAPI" in applied_schemas + assert prim.GetAttribute("physxRigidBody:sleepThreshold").Get() == pytest.approx(0.0) + assert prim.GetAttribute("physxContactReport:threshold").Get() == pytest.approx(2.5) + + @pytest.mark.isaacsim_ci def test_defining_rigid_body_properties_on_prim(setup_simulation): """Test defining rigid body properties on a prim.""" From 186f152e1f2448f5004bda413aa69c67345b1a34 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:21:08 +0000 Subject: [PATCH 085/139] [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: 8.1.0 → 8.1.1 --- .../fix-activate-contact-sensors-nested-rigid-bodies.rst | 4 ---- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 9 +++++++++ source/isaaclab/pyproject.toml | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) delete mode 100644 source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst diff --git a/source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst b/source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst deleted file mode 100644 index c6df8d9b9c9..00000000000 --- a/source/isaaclab/changelog.d/fix-activate-contact-sensors-nested-rigid-bodies.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Fixed contact sensor activation for rigid bodies nested below other rigid bodies. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 1debfe7754a..965ca56e758 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 = "8.1.0" +version = "8.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 05ec117ac45..ee3515ac562 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +8.1.1 (2026-07-05) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed contact sensor activation for rigid bodies nested below other rigid bodies. + + 8.1.0 (2026-07-04) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 3f3616255b9..c5696f21100 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.1.0" +version = "8.1.1" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 5aafdb59626499d014e7007f48afae2db5c9c096 Mon Sep 17 00:00:00 2001 From: Richard Lei Date: Mon, 6 Jul 2026 13:54:18 +1200 Subject: [PATCH 086/139] Added 'motion_vectors' data type support to OVRTX renderer integration (#6317) # Description Added 'motion_vectors' data type support to OVRTX renderer integration Maps the "motion_vectors" data type to OVRTX's TargetMotionSD render var, mirroring the AOV used by Isaac RTX/Replicator's built-in motion_vectors annotator. Extracts only the first two (u, v) channels via a dedicated kernel. Also extends the shared rendering-correctness test utilities to cover motion_vectors across cartpole, shadow hand, and dexsuite kuka allegro for both kit (isaacsim_rtx) and kitless (ovrtx) renderers, including a normalization path for the 2-channel output and an optional zero/non-zero action step so motion vectors have real inter-frame motion to encode before the first captured frame. ## Type of change - New feature (non-breaking change which adds functionality) - Documentation update ## Screenshots See golden images as part of 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 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 --- .../overview/core-concepts/sensors/camera.rst | 2 +- .../changelog.d/ovrtx-motion-vectors.rst | 8 ++ source/isaaclab/isaaclab/utils/images.py | 11 +++ source/isaaclab/test/utils/test_images.py | 10 ++ .../ovrtx-motion-vectors.minor.rst | 4 + .../isaaclab_ov/renderers/ovrtx_renderer.py | 9 ++ .../isaaclab_ov/renderers/ovrtx_usd.py | 45 +++++++-- .../test/test_ovrtx_renderer_contract.py | 2 + .../test/test_ovrtx_renderer_kernels.py | 55 +++++++++++ source/isaaclab_ov/test/test_ovrtx_usd.py | 14 +++ .../ovrtx-motion-vectors.minor.rst | 7 ++ .../physics/ovphysx_manager.py | 16 +++- .../physics/ovphysx_manager_cfg.py | 15 +++ .../changelog.d/ovrtx-motion-vectors.rst | 12 +++ .../ovphysx-ovrtx_renderer-motion_vectors.png | 3 + ...x-isaacsim_rtx_renderer-motion_vectors.png | 3 + ...x-isaacsim_rtx_renderer-motion_vectors.png | 3 + ...x-isaacsim_rtx_renderer-motion_vectors.png | 3 + ...x-isaacsim_rtx_renderer-motion_vectors.png | 3 + .../test/rendering_test_utils.py | 94 ++++++++++++++++++- 20 files changed, 306 insertions(+), 13 deletions(-) create mode 100644 source/isaaclab/changelog.d/ovrtx-motion-vectors.rst create mode 100644 source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst create mode 100644 source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-motion_vectors.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-motion_vectors.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-motion_vectors.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-motion_vectors.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-motion_vectors.png diff --git a/docs/source/overview/core-concepts/sensors/camera.rst b/docs/source/overview/core-concepts/sensors/camera.rst index 6928477f2a2..47ef069d107 100644 --- a/docs/source/overview/core-concepts/sensors/camera.rst +++ b/docs/source/overview/core-concepts/sensors/camera.rst @@ -239,7 +239,7 @@ is :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`, and ``Newton Warp`` is - ✅ * - ``motion_vectors`` - ✅ - - ❌ + - ✅ - ❌ * - ``semantic_segmentation`` - ✅ diff --git a/source/isaaclab/changelog.d/ovrtx-motion-vectors.rst b/source/isaaclab/changelog.d/ovrtx-motion-vectors.rst new file mode 100644 index 00000000000..b71fc27c48b --- /dev/null +++ b/source/isaaclab/changelog.d/ovrtx-motion-vectors.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Changed :func:`~isaaclab.utils.images.normalize_camera_output_for_display` to map the + ``motion_vectors`` data type, normalizing the per-pixel ``(u, v)`` offsets by their peak + magnitude and packing them into an RGB image (``u`` -> red, ``v`` -> green) instead of + scaling by ``255`` and returning a two-channel tensor. Callers that consumed the previous + two-channel output should update to expect a three-channel ``[0, 1]`` image. diff --git a/source/isaaclab/isaaclab/utils/images.py b/source/isaaclab/isaaclab/utils/images.py index 19361e73b7a..bb02548d55c 100644 --- a/source/isaaclab/isaaclab/utils/images.py +++ b/source/isaaclab/isaaclab/utils/images.py @@ -108,6 +108,17 @@ def normalize_camera_output_for_display(tensor: torch.Tensor, data_type: str) -> normalized = normalized[..., :3] / 255.0 elif data_type in {"normals"}: normalized = (normalized + 1.0) * 0.5 + elif data_type in {"motion_vectors"}: + # Motion vectors are per-pixel (u, v) offsets that can be positive or negative. Normalize by the + # peak magnitude to map into [-1, 1], remap to [0, 1], and pack the two channels into an RGB image + # (u -> R, v -> G, unused B -> 0) so the result can be composed into a grid and saved as an image. + uv = normalized[..., :2] + max_mag = uv.abs().max() + if max_mag > 0: + uv = uv / max_mag + uv = (uv + 1.0) * 0.5 + blue = torch.zeros_like(uv[..., :1]) + normalized = torch.cat([uv, blue], dim=-1) else: normalized = normalized / 255.0 diff --git a/source/isaaclab/test/utils/test_images.py b/source/isaaclab/test/utils/test_images.py index 881961b5d01..3cf5b9faac2 100644 --- a/source/isaaclab/test/utils/test_images.py +++ b/source/isaaclab/test/utils/test_images.py @@ -209,6 +209,16 @@ def test_albedo_keeps_rgb_channels(self, device): expected = torch.tensor([[[[1.0, 128.0 / 255.0, 64.0 / 255.0]]]], device=device) torch.testing.assert_close(out, expected) + def test_motion_vectors_map_uv_to_rgb(self, device): + from isaaclab.utils.images import normalize_camera_output_for_display + + # (u, v) offsets; peak magnitude is 4.0, so values map to [-1, 1] -> [0, 1] and gain a zero B channel. + src = torch.tensor([[[[4.0, -2.0], [0.0, 4.0]]]], device=device) + out = normalize_camera_output_for_display(src, "motion_vectors") + expected = torch.tensor([[[[1.0, 0.25, 0.0], [0.5, 1.0, 0.0]]]], device=device) + assert out.shape[-1] == 3 + torch.testing.assert_close(out, expected) + class TestMakeCameraOutputGrid: """Grid composition for multi-env camera capture.""" diff --git a/source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst b/source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst new file mode 100644 index 00000000000..44e9084afca --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added ``motion_vectors`` data type support to :class:`~isaaclab_ov.renderers.OVRTXRenderer`. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 61bf985646a..f802ba79450 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -265,6 +265,7 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: 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), } @property @@ -894,6 +895,14 @@ def _process_render_frame(self, render_data: OVRTXRenderData, frame, output_buff tiled_normals_data = wp.from_dlpack(mapping.tensor) self._launch_extract_all_tiles(render_data, tiled_normals_data, output_buffers["normals"]) + # For motion vectors, extract only the first two (u, v) channels from the tiled buffer. + # Note: mirrors the Isaac RTX renderer's handling of the "TargetMotionSD" AOV + # (check: https://github.com/isaac-sim/IsaacLab/issues/2003). + if "TargetMotionSD" in frame.render_vars and "motion_vectors" in output_buffers: + with frame.render_vars["TargetMotionSD"].map(device=Device.CUDA) as mapping: + tiled_motion_vectors_data = wp.from_dlpack(mapping.tensor) + self._launch_extract_all_tiles(render_data, tiled_motion_vectors_data, output_buffers["motion_vectors"]) + def render(self, render_data: OVRTXRenderData) -> None: """Render the scene into the provided RenderData.""" if not self._initialized_scene: diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py index 443e0d21e4d..d0e98aa6e0a 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py @@ -26,20 +26,36 @@ def get_render_var_config(data_types: list[str]) -> tuple[str, str, str]: use_instance_seg = "instance_segmentation_fast" in data_types use_instance_id_seg = "instance_id_segmentation_fast" in data_types use_normals = "normals" in data_types + use_motion_vectors = "motion_vectors" in data_types use_rgb = any(dt in ["rgb", "rgba"] for dt in data_types) use_hdr = "rgb_hdr" in data_types if use_depth and not ( - use_rgb or use_albedo or use_semantic or use_instance_seg or use_instance_id_seg or use_normals + use_rgb + or use_albedo + or use_semantic + or use_instance_seg + or use_instance_id_seg + or use_normals + or use_motion_vectors ): source = "DistanceToCameraSD" if use_distance_to_camera else "DistanceToImagePlaneSD" return "/Render/Vars/depth", "depth", source - if use_albedo and not (use_rgb or use_semantic or use_instance_seg or use_instance_id_seg or use_normals): + if use_albedo and not ( + use_rgb or use_semantic or use_instance_seg or use_instance_id_seg or use_normals or use_motion_vectors + ): return "/Render/Vars/albedo", "albedo", "DiffuseAlbedoSD" - if use_semantic and not (use_rgb or use_albedo or use_normals): + if use_semantic and not (use_rgb or use_albedo or use_normals or use_motion_vectors): return "/Render/Vars/semantic", "semantic", "SemanticSegmentation" if use_instance_seg and not ( - use_rgb or use_albedo or use_semantic or use_instance_id_seg or use_normals or use_depth or use_hdr + use_rgb + or use_albedo + or use_semantic + or use_instance_id_seg + or use_normals + or use_depth + or use_hdr + or use_motion_vectors ): return ( "/Render/Vars/NonStableInstanceSegmentation", @@ -47,13 +63,30 @@ def get_render_var_config(data_types: list[str]) -> tuple[str, str, str]: "NonStableInstanceSegmentation", ) if use_instance_id_seg and not ( - use_rgb or use_albedo or use_semantic or use_instance_seg or use_normals or use_depth or use_hdr + use_rgb + or use_albedo + or use_semantic + or use_instance_seg + or use_normals + or use_depth + or use_hdr + or use_motion_vectors ): return "/Render/Vars/InstanceSegmentationSD", "InstanceSegmentationSD", "InstanceSegmentationSD" if use_normals and not ( - use_rgb or use_albedo or use_semantic or use_instance_seg or use_instance_id_seg or use_depth + use_rgb + or use_albedo + or use_semantic + or use_instance_seg + or use_instance_id_seg + or use_depth + or use_motion_vectors ): return "/Render/Vars/NormalSD", "NormalSD", "NormalSD" + if use_motion_vectors and not ( + use_rgb or use_albedo or use_semantic or use_instance_seg or use_instance_id_seg or use_depth or use_normals + ): + return "/Render/Vars/TargetMotionSD", "TargetMotionSD", "TargetMotionSD" if use_hdr and not use_rgb: return "/Render/Vars/HdrColor", "HdrColor", "HdrColor" return "/Render/Vars/LdrColor", "LdrColor", "LdrColor" diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index 3520a78bea3..a968baebee3 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -88,10 +88,12 @@ def test_ovrtx_supported_output_types_key_set(): RenderBufferKind.DISTANCE_TO_IMAGE_PLANE, RenderBufferKind.DISTANCE_TO_CAMERA, RenderBufferKind.NORMALS, + RenderBufferKind.MOTION_VECTORS, } assert specs[RenderBufferKind.RGBA] == RenderBufferSpec(4, wp.uint8) assert specs[RenderBufferKind.RGB_HDR] == RenderBufferSpec(3, wp.float32) assert specs[RenderBufferKind.DEPTH] == RenderBufferSpec(1, wp.float32) + assert specs[RenderBufferKind.MOTION_VECTORS] == RenderBufferSpec(2, wp.float32) def test_ovrtx_set_outputs_wraps_caller_torch_zero_copy(): diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py index 27099d25cd7..31b35350a37 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py @@ -158,6 +158,61 @@ def _reference_extract_all_rgb_float_tiles( return out +def _reference_extract_all_motion_vector_tiles( + tiled_np: np.ndarray, + num_envs: int, + num_cols: int, + tile_width: int, + tile_height: int, +) -> np.ndarray: + """NumPy reference for the motion-vector case of ``extract_all_tiles_kernel``.""" + out = np.zeros((num_envs, tile_height, tile_width, 2), dtype=np.float32) + for env_idx in range(num_envs): + tile_x = env_idx % num_cols + tile_y = env_idx // num_cols + for y in range(tile_height): + for x in range(tile_width): + src_y = tile_y * tile_height + y + src_x = tile_x * tile_width + x + out[env_idx, y, x, 0] = tiled_np[src_y, src_x, 0] + out[env_idx, y, x, 1] = tiled_np[src_y, src_x, 1] + return out + + +class TestExtractAllMotionVectorTilesKernel: + """Tests for the motion-vector case of ``extract_all_tiles_kernel`` used by OVRTX TargetMotionSD.""" + + def test_two_by_two_tile_grid_drops_extra_channels(self): + """The kernel reads only the first two channels, even if the source buffer has more (e.g. 4).""" + num_cols = 2 + num_envs = 4 + tile_width = 2 + tile_height = 3 + tiled_h = (num_envs // num_cols) * tile_height + tiled_w = num_cols * tile_width + tiled_np = np.zeros((tiled_h, tiled_w, 4), dtype=np.float32) + for h in range(tiled_h): + for w in range(tiled_w): + tiled_np[h, w, 0] = float(h * 1000 + w) + tiled_np[h, w, 1] = float(h * 1000 + w + 100) + tiled_np[h, w, 2] = float(h * 1000 + w + 200) + tiled_np[h, w, 3] = float(h * 1000 + w + 300) + + tiled_wp = wp.array(tiled_np, dtype=wp.float32, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 2), dtype=wp.float32, device=DEVICE) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_motion_vector_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_allclose(output_wp.numpy(), expected, rtol=0, atol=0) + + class TestExtractAllDepthTilesKernel: """Tests for the depth case of ``extract_all_tiles_kernel``.""" diff --git a/source/isaaclab_ov/test/test_ovrtx_usd.py b/source/isaaclab_ov/test/test_ovrtx_usd.py index d5fb78c38d0..41e002997f3 100644 --- a/source/isaaclab_ov/test/test_ovrtx_usd.py +++ b/source/isaaclab_ov/test/test_ovrtx_usd.py @@ -99,6 +99,20 @@ def test_ovrtx_instance_id_segmentation_fast_uses_instance_segmentation_sd_rende ) +def test_ovrtx_motion_vectors_uses_target_motion_render_var(): + """Requesting motion vectors from OVRTX selects the TargetMotionSD render variable.""" + assert get_render_var_config(["motion_vectors"]) == ( + "/Render/Vars/TargetMotionSD", + "TargetMotionSD", + "TargetMotionSD", + ) + + +def test_ovrtx_motion_vectors_with_rgb_falls_back_to_rgb(): + """OVRTX only supports one main AOV at a time; combining motion vectors with RGB keeps RGB.""" + assert get_render_var_config(["rgb", "motion_vectors"]) == ("/Render/Vars/LdrColor", "LdrColor", "LdrColor") + + def test_ovrtx_rgb_and_rgb_hdr_author_both_render_vars(): """Requesting LDR RGB and RGB_HDR keeps both OVRTX render variables.""" render_var_configs = get_render_var_configs(["rgb", "rgb_hdr"]) diff --git a/source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst b/source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst new file mode 100644 index 00000000000..7aa0b56a6c3 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst @@ -0,0 +1,7 @@ +Added +^^^^^ + +* Added :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_enhanced_determinism` and + :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_external_forces_every_iteration`, mirroring the + equivalent PhysX solver settings already exposed on + :class:`~isaaclab_physx.physics.PhysxCfg`. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 458f38f9b54..626d693c10b 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -714,15 +714,15 @@ def _configure_physx_scene_prim(scene_prim, cfg, device: str) -> None: so we write the apiSchemas list entry and scene attributes directly via raw Sdf metadata manipulation instead of using the high-level USD API. - The schema and scene-query-support attribute are applied regardless of - device. The GPU-specific dynamics/broadphase/capacity attributes are + The schema, scene-query-support, and solver-determinism/accuracy attributes 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 configured for GPU execution. Args: scene_prim: The /World/PhysicsScene prim to configure. - cfg: The :class:`OvPhysxCfg` carrying GPU buffer-capacity values. - Only consulted when ``device == "gpu"``. + cfg: The :class:`OvPhysxCfg` carrying solver-determinism flags and GPU buffer-capacity + values. The GPU buffer-capacity values are only consulted when ``device == "gpu"``. device: Resolved physics device — one of ``"cpu"`` or ``"gpu"``. """ from pxr import Sdf @@ -741,6 +741,14 @@ def _configure_physx_scene_prim(scene_prim, cfg, device: str) -> None: enable_sq = getattr(sim_cfg, "enable_scene_query_support", False) scene_prim.CreateAttribute("physxScene:enableSceneQuerySupport", Sdf.ValueTypeNames.Bool).Set(enable_sq) + if cfg is not None: + scene_prim.CreateAttribute("physxScene:enableEnhancedDeterminism", Sdf.ValueTypeNames.Bool).Set( + cfg.enable_enhanced_determinism + ) + scene_prim.CreateAttribute("physxScene:enableExternalForcesEveryIteration", Sdf.ValueTypeNames.Bool).Set( + cfg.enable_external_forces_every_iteration + ) + if device == "gpu": scene_prim.CreateAttribute("physxScene:enableGPUDynamics", Sdf.ValueTypeNames.Bool).Set(True) scene_prim.CreateAttribute("physxScene:broadphaseType", Sdf.ValueTypeNames.String).Set("GPU") 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 96047e46363..5de6e902e44 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py @@ -22,6 +22,21 @@ class OvPhysxCfg(PhysicsCfg): class_type = "{DIR}.ovphysx_manager:OvPhysxManager" + enable_enhanced_determinism: bool = False + """Enable/disable improved determinism at the expense of performance. Defaults to False. + + For more information on PhysX determinism, please check `here`_. + + .. _here: https://nvidia-omniverse.github.io/PhysX/physx/5.4.1/docs/RigidBodyDynamics.html#enhanced-determinism + """ + + enable_external_forces_every_iteration: bool = False + """Enable/disable external forces every position iteration in the TGS solver. Default is False. + + This can help improve the accuracy of velocity updates. Consider enabling this flag if the + velocities generated by the simulation are noisy. + """ + gpu_max_rigid_contact_count: int = 2**23 """Size of the GPU rigid-body contact buffer.""" diff --git a/source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst b/source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst new file mode 100644 index 00000000000..58dc39c804e --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst @@ -0,0 +1,12 @@ +Added +^^^^^ + +* Added ``motion_vectors`` to the rendering correctness test matrix for cartpole, shadow hand, and + dexsuite kuka allegro lift environments. + +Fixed +^^^^^ + +* Fixed flaky ``motion_vectors`` golden-image comparisons on PhysX backends (``physx`` and + ``ovphysx``) by enabling enhanced determinism and per-iteration external forces on the PhysX + solver, which otherwise produces run-to-run noisy velocities that this AOV encodes directly. diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-motion_vectors.png new file mode 100644 index 00000000000..4de42c3eb81 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15a6be1c6d16f00eee85812248ae0751d74809a1941c46199fc9d70da846b0a3 +size 20631 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-motion_vectors.png new file mode 100644 index 00000000000..65c7eeb235c --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e72c9c3b8052b67b26cc8553dd097915860af12ba997083a28e746babd322a28 +size 20584 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-motion_vectors.png new file mode 100644 index 00000000000..926df161aa1 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f79522cbf6d3e84c61dcde71f23984971a645ceefece920418e4d837e1d5d7de +size 8670 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-motion_vectors.png new file mode 100644 index 00000000000..147efe8d21d --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7f2cfee1e6415ec92374319331ea496299471f37ea1ee0f66ffe9618e82b604 +size 8629 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-motion_vectors.png new file mode 100644 index 00000000000..6100a39069b --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c387e14db0c8662374d37709da3c657cb4b2488f4329f686c8d84b6e56d2b995 +size 28482 diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index e5bce7ecebe..bf25f3e723a 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -60,12 +60,15 @@ # outputs where the per-pixel value distribution is highly non-uniform after normalisation (e.g. depth, where we # divide by the max value so tiny absolute differences near the far plane dominate windowed variance). For these # data types we still compute SSIM for reporting, but only the per-pixel L2 gate is used to decide pass/fail. +# ``motion_vectors`` is included for the same reason: per-pixel (u, v) offsets are normalized by their max +# magnitude, so small absolute differences in near-static regions can dominate windowed variance. _SSIM_DISABLED_DATA_TYPES: set[str] = { "depth", "distance_to_camera", "distance_to_image_plane", "instance_segmentation_fast", "instance_id_segmentation_fast", + "motion_vectors", } # Directory for comparison images saved during the test session. @@ -95,6 +98,7 @@ "normals", "instance_segmentation_fast", "instance_id_segmentation_fast", + "motion_vectors", ) @@ -239,6 +243,44 @@ def _redirect_ovrtx_renderer_log_to_stdout(env_cfg: Any) -> None: renderer_cfg.log_file_path = "CON" if os.name == "nt" else "/dev/stdout" +def _maybe_enable_physx_determinism_for_motion(env_cfg: Any, physics_backend: str, data_type: str) -> None: + """Trade PhysX solver performance for determinism/accuracy when testing ``motion_vectors``. + + PhysX's default TGS solver settings produce noisy per-step velocities (see the "TGS solver ... may + cause noisy velocities" warning logged by ``physx_manager``), and ``motion_vectors`` encodes velocity + directly. That noise differs run-to-run, making golden-image comparisons for this AOV flaky on CI. + Applies to both PhysX backends (``"physx"`` and ``"ovphysx"``, which share the same underlying PhysX + solver). No-op for any other ``(physics_backend, data_type)`` combination. + + Args: + env_cfg: The resolved environment config, exposing ``sim.physics`` as a + :class:`~isaaclab_physx.physics.PhysxCfg` when ``physics_backend == "physx"``, or an + :class:`~isaaclab_ovphysx.physics.OvPhysxCfg` when ``physics_backend == "ovphysx"``. + physics_backend: The physics backend under test (``"physx"``, ``"newton"``, or ``"ovphysx"``). + data_type: The camera data type under test. + """ + if physics_backend not in ("physx", "ovphysx") or data_type != "motion_vectors": + return + + env_cfg.sim.physics.enable_enhanced_determinism = True + env_cfg.sim.physics.enable_external_forces_every_iteration = True + + +def _skip_if_newton_motion_vectors(physics_backend: str, data_type: str) -> None: + """Skip ``motion_vectors`` golden-image tests running on the Newton physics backend. + + Newton is not yet deterministic enough for the ``motion_vectors`` AOV: per-pixel (u, v) offsets + encode sub-step body motion, which varies run-to-run under the Newton solver, so golden-image + comparison is unreliable. No-op for any other ``(physics_backend, data_type)`` combination. + + Args: + physics_backend: The physics backend under test (``"physx"``, ``"newton"``, or ``"ovphysx"``). + data_type: The camera data type under test. + """ + if physics_backend == "newton" and data_type == "motion_vectors": + pytest.skip("Newton physics is not deterministic enough for motion_vectors golden-image testing.") + + def _physics_preset_name(physics_backend: str) -> str: """Translate the historical ``"newton"`` backend label (still used by golden-image filenames and ``pytest.param`` IDs) to the renamed Hydra preset @@ -638,6 +680,30 @@ def _alpha_only(img: Image.Image) -> Image.Image: pytest.fail(reason) +def maybe_step_env_for_motion(env: Any, data_type: str, num_steps: int = 2, action_value: float = 0.0) -> None: + """Step ``env`` so motion-vector AOVs have real inter-frame motion to encode. + + Motion vectors compare the current frame's transforms against the previous frame's; the first frame + rendered after a reset has no render history, so its motion vectors would otherwise be all zero. No-op + for any ``data_type`` other than ``"motion_vectors"``. + + Args: + env: The environment to step. Must expose ``action_space`` and ``step`` (``DirectRLEnv`` / + ``ManagerBasedRLEnv``). + data_type: The camera data type under test. + num_steps: Number of steps to take before capturing camera output. + action_value: Constant value applied to every action component on every step. Defaults to + ``0.0`` (motion then comes only from physics already in flight, e.g. gravity/initial velocity). + Pass a non-zero value to additionally induce actuated motion (e.g. cartpole cart translation). + """ + if data_type != "motion_vectors": + return + + action = torch.full(env.action_space.shape, action_value, device=env.device) + for _ in range(num_steps): + env.step(action) + + def rendering_test_shadow_hand( physics_backend: str, renderer: str, @@ -646,6 +712,7 @@ def rendering_test_shadow_hand( ) -> None: if physics_backend == "ovphysx": pytest.skip("ovphysx is not supported yet.") + _skip_if_newton_motion_vectors(physics_backend, data_type) from isaaclab.utils.configclass import configclass @@ -663,6 +730,7 @@ class _ShadowHandTiledCameraTestCfg(ShadowHandTiledCameraCfg): normals = _ShadowHandBaseTiledCameraCfg(data_types=["normals"]) instance_segmentation_fast = _ShadowHandBaseTiledCameraCfg(data_types=["instance_segmentation_fast"]) instance_id_segmentation_fast = _ShadowHandBaseTiledCameraCfg(data_types=["instance_id_segmentation_fast"]) + motion_vectors = _ShadowHandBaseTiledCameraCfg(data_types=["motion_vectors"]) @configclass class _ShadowHandCameraTestEnvCfg(ShadowHandCameraEnvCfg): @@ -678,14 +746,18 @@ class _ShadowHandCameraTestEnvCfg(ShadowHandCameraEnvCfg): if renderer == "ovrtx_renderer": _redirect_ovrtx_renderer_log_to_stdout(env_cfg) - if data_type in {"depth", "distance_to_camera", "distance_to_image_plane"}: - # Disable CNN forward pass as it cannot be meaningfully trained from depth alone and will raise a ValueError. + _maybe_enable_physx_determinism_for_motion(env_cfg, physics_backend, data_type) + + if data_type in {"depth", "distance_to_camera", "distance_to_image_plane", "motion_vectors"}: + # Disable CNN forward pass as it cannot be meaningfully trained from depth/motion vectors alone + # and will raise a ValueError. env_cfg.feature_extractor.enabled = False env = None try: env = ShadowHandCameraEnv(env_cfg) + maybe_step_env_for_motion(env, data_type) maybe_save_stage("shadow_hand", physics_backend, renderer, data_type) validate_camera_outputs( @@ -711,6 +783,8 @@ def rendering_test_cartpole( data_type: str, comparison_scores: list[dict], ) -> None: + _skip_if_newton_motion_vectors(physics_backend, data_type) + from isaaclab.utils.configclass import configclass from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env import CartpoleCameraEnv @@ -731,6 +805,7 @@ class _CartpoleTiledCameraTestCfg(CartpoleTiledCameraCfg): instance_id_segmentation_fast = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg( data_types=["instance_id_segmentation_fast"] ) + motion_vectors = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg(data_types=["motion_vectors"]) @configclass class _BaseCartpoleCameraEnvTestCfg(CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg): @@ -756,6 +831,9 @@ class _CartpoleCameraTestEnvCfg(CartpoleCameraEnvCfg): instance_id_segmentation_fast = _BaseCartpoleCameraEnvTestCfg( observation_space=[4, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() ) + motion_vectors = CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg( + observation_space=[2, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() + ) env_cfg = _CartpoleCameraTestEnvCfg() env_cfg = _apply_overrides_to_env_cfg( @@ -767,10 +845,15 @@ class _CartpoleCameraTestEnvCfg(CartpoleCameraEnvCfg): if renderer == "ovrtx_renderer": _redirect_ovrtx_renderer_log_to_stdout(env_cfg) + _maybe_enable_physx_determinism_for_motion(env_cfg, physics_backend, data_type) + env = None try: env = CartpoleCameraEnv(env_cfg) + # Nudge the cart with a small constant force so motion vectors also capture cart translation, + # not just pole dynamics already in flight from the randomized reset. + maybe_step_env_for_motion(env, data_type, action_value=0.5) camera_outputs = env._tiled_camera.data.output if renderer == "ovrtx_renderer": # The first output access creates the selected OVRTX render-variable mapping. Give @@ -812,6 +895,7 @@ def rendering_test_dexsuite_kuka( ) -> None: if physics_backend == "ovphysx": pytest.skip("ovphysx is not supported yet.") + _skip_if_newton_motion_vectors(physics_backend, data_type) from isaaclab.envs import ManagerBasedRLEnv from isaaclab.sensors import CameraCfg @@ -864,6 +948,9 @@ class _DexsuiteBaseTiledCameraTestCfg(BaseTiledCameraCfg): instance_id_segmentation_fast256 = BASE_CAMERA_CFG.replace( data_types=["instance_id_segmentation_fast"], width=256, height=256 ) + motion_vectors64 = BASE_CAMERA_CFG.replace(data_types=["motion_vectors"], width=64, height=64) + motion_vectors128 = BASE_CAMERA_CFG.replace(data_types=["motion_vectors"], width=128, height=128) + motion_vectors256 = BASE_CAMERA_CFG.replace(data_types=["motion_vectors"], width=256, height=256) @configclass class _DexsuiteSingleCameraTestSceneCfg(SingleCameraSceneCfg): @@ -892,6 +979,8 @@ class _DexsuiteKukaAllegroLiftCameraTestEnvCfg(DexsuiteKukaAllegroLiftCameraEnvC if renderer == "ovrtx_renderer": _redirect_ovrtx_renderer_log_to_stdout(env_cfg) + _maybe_enable_physx_determinism_for_motion(env_cfg, physics_backend, data_type) + # Disable the observation point-cloud visualisation markers (/Visuals/ObservationPointCloud). # The underlying point sampling uses the global numpy/torch RNG, so marker positions shift # across processes and show up as random red dots in the rendered camera output. Since this @@ -913,6 +1002,7 @@ class _DexsuiteKukaAllegroLiftCameraTestEnvCfg(DexsuiteKukaAllegroLiftCameraEnvC try: env = ManagerBasedRLEnv(env_cfg) + maybe_step_env_for_motion(env, data_type) maybe_save_stage(test_name, physics_backend, renderer, data_type) validate_camera_outputs( test_name, From 1ca4a605f40fa52a2e232a6237b5e567199645eb Mon Sep 17 00:00:00 2001 From: Richard Lei Date: Mon, 6 Jul 2026 14:51:51 +1200 Subject: [PATCH 087/139] Raise instead of swallowing OVRTXRenderer failures (#6321) # Description Several methods in OVRTXRenderer (scene/camera/object binding setup, scene partition writes, and render) caught broad exceptions and only logged a warning, letting simulation or training continue on a renderer failure that should have stopped it. Let these propagate so callers can decide how to handle the failure, matching the existing pattern already used by _clone_environments. Object binding setup for Newton now explicitly skips when no SimulationContext is active yet, rather than crashing on an assertion deep in NewtonManager. ## Type of change - Refactor to fail hard on real errors ## 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: Richard Lei Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../changelog.d/fix-ovrtx-broad-except.rst | 7 + .../isaaclab_ov/renderers/ovrtx_renderer.py | 278 +++++++++--------- 2 files changed, 140 insertions(+), 145 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst diff --git a/source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst b/source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst new file mode 100644 index 00000000000..d3958d45ecf --- /dev/null +++ b/source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Removed overly broad ``except Exception`` handling in :class:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer` + that downgraded failures in scene initialization, camera and object binding setup, scene partition writes, + Newton transform syncing, and :meth:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer.render` to log + warnings and silently continue. These now propagate so callers can decide how to handle the failure. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index f802ba79450..cbbcb9a0615 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -369,64 +369,59 @@ 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 - if self._exported_usd_string is not None: - logger.info("Injecting camera definitions...") - - render_product_string, render_product_path = build_render_product_as_string( - width=width, - height=height, - num_envs=num_envs, - data_types=data_types, - minimal_mode=_resolve_rtx_minimal_mode(data_types), - camera_rel_path=self._camera_rel_path, - ) - self._render_product_paths.append(render_product_path) + logger.info("Injecting camera definitions...") + + if self._exported_usd_string is None: + raise RuntimeError("Expected an exported USD string from stage") + + render_product_string, render_product_path = build_render_product_as_string( + width=width, + height=height, + num_envs=num_envs, + data_types=data_types, + minimal_mode=_resolve_rtx_minimal_mode(data_types), + camera_rel_path=self._camera_rel_path, + ) + 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 + combined_usd_string = self._exported_usd_string + "\n\n" + render_product_string + self._exported_usd_string = None # Free memory - # If temp_usd_dir is set, write the combined USD stage to a temporary file. - if self.cfg.temp_usd_dir is not None: - _write_file(Path(self.cfg.temp_usd_dir), "ovrtx_renderer_stage.usda", combined_usd_string) + # If temp_usd_dir is set, write the combined USD stage to a temporary file. + if self.cfg.temp_usd_dir is not None: + _write_file(Path(self.cfg.temp_usd_dir), "ovrtx_renderer_stage.usda", combined_usd_string) - logger.info("Loading USD into OvRTX...") - try: - self._renderer.open_usd_from_string(combined_usd_string) - logger.info("OVRTX loaded USD from string successfully") - except Exception as e: - logger.exception("Error loading USD: %s", e) - raise + logger.info("Loading USD into OvRTX...") + self._renderer.open_usd_from_string(combined_usd_string) + logger.info("OVRTX loaded USD from string successfully") - if num_envs > 1: - self._clone_sources_in_ovrtx() - self._update_scene_partitions_after_clone(num_envs) + if num_envs > 1: + self._clone_sources_in_ovrtx() + self._update_scene_partitions_after_clone(num_envs) - self._initialized_scene = True + self._initialized_scene = True - camera_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] - self._camera_binding = self._renderer.bind_attribute( - prim_paths=camera_paths, - attribute_name="omni:xform", - semantic=Semantic.XFORM_MAT4x4, - prim_mode=PrimMode.EXISTING_ONLY, - ) + camera_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] + self._camera_binding = self._renderer.bind_attribute( + prim_paths=camera_paths, + attribute_name="omni:xform", + semantic=Semantic.XFORM_MAT4x4, + prim_mode=PrimMode.EXISTING_ONLY, + ) - # OVRTX requires omni:resetXformStack on cameras for correct world transform binding - try: - self._renderer.write_attribute( - prim_paths=camera_paths, - attribute_name="omni:resetXformStack", - tensor=np.full(num_envs, True, dtype=np.bool_), - ) - except Exception as e: - logger.warning("Failed to write omni:resetXformStack: %s", e) + # OVRTX requires omni:resetXformStack on cameras for correct world transform binding + self._renderer.write_attribute( + prim_paths=camera_paths, + attribute_name="omni:resetXformStack", + tensor=np.full(num_envs, True, dtype=np.bool_), + ) - if self._camera_binding is not None: - logger.info("Camera binding created successfully") - else: - logger.warning("Camera binding is None") + if self._camera_binding is not None: + logger.info("Camera binding created successfully") + else: + raise RuntimeError("Camera binding is None — cannot render without a valid camera binding") - self._setup_object_bindings() + self._setup_newton_object_bindings() def _clone_sources_in_ovrtx(self): """Clone sources in OVRTX using the scene :class:`~isaaclab.cloner.ClonePlan`.""" @@ -469,76 +464,72 @@ def _update_scene_partitions_after_clone(self, num_envs: int): env_prim_paths = [f"/World/envs/env_{i}" for i in range(num_envs)] camera_prim_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] - try: - self._renderer.write_attribute( - env_prim_paths, - "primvars:omni:scenePartition", - partition_tokens, - semantic=Semantic.TOKEN_STRING, - ) - logger.info("Written primvars:omni:scenePartition to %d environments", num_envs) + self._renderer.write_attribute( + env_prim_paths, + "primvars:omni:scenePartition", + partition_tokens, + semantic=Semantic.TOKEN_STRING, + ) + logger.info("Written primvars:omni:scenePartition to %d environments", num_envs) - self._renderer.write_attribute( - camera_prim_paths, - "omni:scenePartition", - partition_tokens, - semantic=Semantic.TOKEN_STRING, - ) - logger.info("Written omni:scenePartition to %d cameras", num_envs) - except Exception as e: - logger.warning("Failed to write scene partitions: %s", e, exc_info=True) + self._renderer.write_attribute( + camera_prim_paths, + "omni:scenePartition", + partition_tokens, + semantic=Semantic.TOKEN_STRING, + ) + logger.info("Written omni:scenePartition to %d cameras", num_envs) - def _setup_object_bindings(self): + def _setup_newton_object_bindings(self): """Setup OVRTX bindings for scene objects to sync with Newton physics.""" try: from isaaclab_newton.physics import NewtonManager + except ImportError: + logger.debug("isaaclab_newton physics not available, skipping object bindings") + return - newton_model = NewtonManager.get_model() - if newton_model is None: - logger.info("Newton model not available, skipping object bindings") - return + if SimulationContext.instance() is None: + logger.info("No active simulation context, will not set up ovrtx object bindings for newton") + return - all_body_paths = getattr(newton_model, "body_label", None) - if all_body_paths is None: - logger.info("Newton model has no body_label, skipping object bindings") - return + newton_model = NewtonManager.get_model() + if newton_model is None: + logger.debug("Newton model not available, skipping object bindings") + return - object_paths = [] - newton_indices = [] - for idx, path in enumerate(all_body_paths): - if "/World/envs/" in path and self._camera_rel_path not in path and "GroundPlane" not in path: - object_paths.append(path) - newton_indices.append(idx) + all_body_paths = getattr(newton_model, "body_label", None) + if all_body_paths is None: + logger.info("Newton model has no body_label, skipping object bindings") + return - if len(object_paths) == 0: - logger.info("No dynamic objects found for binding") - return + object_paths = [] + newton_indices = [] + for idx, path in enumerate(all_body_paths): + if "/World/envs/" in path and self._camera_rel_path not in path and "GroundPlane" not in path: + object_paths.append(path) + newton_indices.append(idx) - self._object_binding = self._renderer.bind_attribute( - prim_paths=object_paths, - attribute_name="omni:xform", - semantic=Semantic.XFORM_MAT4x4, - prim_mode=PrimMode.EXISTING_ONLY, - ) + if len(object_paths) == 0: + logger.info("No dynamic objects found for binding") + return - try: - self._renderer.write_attribute( - prim_paths=object_paths, - attribute_name="omni:resetXformStack", - tensor=np.full(len(object_paths), True, dtype=np.bool_), - ) - except Exception as e: - logger.warning("Failed to write omni:resetXformStack on objects: %s", e) + self._object_binding = self._renderer.bind_attribute( + prim_paths=object_paths, + attribute_name="omni:xform", + semantic=Semantic.XFORM_MAT4x4, + prim_mode=PrimMode.EXISTING_ONLY, + ) - 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=self._device) - else: - logger.warning("Object binding is None") - except ImportError: - logger.info("Newton not available, skipping object bindings") - except Exception as e: - logger.warning("Error setting up object bindings: %s", e) + self._renderer.write_attribute( + prim_paths=object_paths, + attribute_name="omni:resetXformStack", + tensor=np.full(len(object_paths), True, dtype=np.bool_), + ) + + if self._object_binding is None: + raise RuntimeError("Failed to create OVRTX object bindings") + + self._object_newton_indices = wp.array(newton_indices, dtype=wp.int32, device=self._device) def create_render_data(self, spec: CameraRenderSpec) -> OVRTXRenderData: """Create OVRTX-specific RenderData with GPU buffers. @@ -587,26 +578,26 @@ def update_transforms(self) -> None: if self._object_binding is None or self._object_newton_indices is None: return - try: - from isaaclab_newton.physics import NewtonManager + # If self._object_newton_indices is not None, then Newton's the current physics backend - newton_state = NewtonManager.get_state() - if newton_state is None: - return - body_q = getattr(newton_state, "body_q", None) - if body_q is None: - return + from isaaclab_newton.physics import NewtonManager - 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=self._device, - ) - except Exception as e: - logger.warning("Failed to update object transforms: %s", e) + newton_state = NewtonManager.get_state() + if newton_state is None: + raise RuntimeError("Newton state should not be None") + + body_q = getattr(newton_state, "body_q", None) + if body_q is None: + return + + 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=self._device, + ) def update_camera( self, @@ -909,28 +900,25 @@ def render(self, render_data: OVRTXRenderData) -> None: raise RuntimeError("Scene not initialized. Call initialize() first.") if self._renderer is None or len(self._render_product_paths) == 0: return - try: - products = self._renderer.step( - render_products=set(self._render_product_paths), - delta_time=1.0 / 60.0, + products = self._renderer.step( + render_products=set(self._render_product_paths), + delta_time=1.0 / 60.0, + ) + product_path = self._render_product_paths[0] + if product_path in products and len(products[product_path].frames) > 0: + self._process_render_frame( + render_data, + products[product_path].frames[0], + render_data.warp_buffers, ) - product_path = self._render_product_paths[0] - if product_path in products and len(products[product_path].frames) > 0: - self._process_render_frame( - render_data, - products[product_path].frames[0], - render_data.warp_buffers, - ) - # Post-render PPISP: HDR scene-linear → LDR RGBA. Source/destination - # buffers are the same warp buffer map used by extraction. - if render_data.ppisp_pipeline is not None: - render_data.ppisp_pipeline.apply( - render_data.warp_buffers[str(RenderBufferKind.RGB_HDR)], - render_data.warp_buffers[str(RenderBufferKind.RGBA)], - ) - except Exception as e: - logger.warning("OVRTX rendering failed: %s", e, exc_info=True) + # Post-render PPISP: HDR scene-linear → LDR RGBA. Source/destination + # buffers are the same warp buffer map used by extraction. + if render_data.ppisp_pipeline is not None: + render_data.ppisp_pipeline.apply( + render_data.warp_buffers[str(RenderBufferKind.RGB_HDR)], + render_data.warp_buffers[str(RenderBufferKind.RGBA)], + ) def cleanup(self, render_data: OVRTXRenderData | None) -> None: """Release renderer resources. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.cleanup`.""" From b2dd3cc37efa7c3fa346f3485fa789598e2dac75 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 06:34:33 +0000 Subject: [PATCH 088/139] [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: 8.1.1 → 8.1.2 - isaaclab_ov: 0.5.4 → 0.6.0 - isaaclab_ovphysx: 6.0.0 → 6.1.0 - isaaclab_tasks: 8.1.4 → 8.1.5 --- .../changelog.d/ovrtx-motion-vectors.rst | 8 -------- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 13 +++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../changelog.d/fix-ovrtx-broad-except.rst | 7 ------- .../changelog.d/ovrtx-motion-vectors.minor.rst | 4 ---- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 17 +++++++++++++++++ source/isaaclab_ov/pyproject.toml | 2 +- .../changelog.d/ovrtx-motion-vectors.minor.rst | 7 ------- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 12 ++++++++++++ source/isaaclab_ovphysx/pyproject.toml | 2 +- .../changelog.d/ovrtx-motion-vectors.rst | 12 ------------ source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 17 +++++++++++++++++ source/isaaclab_tasks/pyproject.toml | 2 +- 17 files changed, 67 insertions(+), 46 deletions(-) delete mode 100644 source/isaaclab/changelog.d/ovrtx-motion-vectors.rst delete mode 100644 source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst delete mode 100644 source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst delete mode 100644 source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst diff --git a/source/isaaclab/changelog.d/ovrtx-motion-vectors.rst b/source/isaaclab/changelog.d/ovrtx-motion-vectors.rst deleted file mode 100644 index b71fc27c48b..00000000000 --- a/source/isaaclab/changelog.d/ovrtx-motion-vectors.rst +++ /dev/null @@ -1,8 +0,0 @@ -Changed -^^^^^^^ - -* Changed :func:`~isaaclab.utils.images.normalize_camera_output_for_display` to map the - ``motion_vectors`` data type, normalizing the per-pixel ``(u, v)`` offsets by their peak - magnitude and packing them into an RGB image (``u`` -> red, ``v`` -> green) instead of - scaling by ``255`` and returning a two-channel tensor. Callers that consumed the previous - two-channel output should update to expect a three-channel ``[0, 1]`` image. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 965ca56e758..3207fd89b94 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 = "8.1.1" +version = "8.1.2" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index ee3515ac562..3883905e87b 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,19 @@ Changelog --------- +8.1.2 (2026-07-06) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed :func:`~isaaclab.utils.images.normalize_camera_output_for_display` to map the + ``motion_vectors`` data type, normalizing the per-pixel ``(u, v)`` offsets by their peak + magnitude and packing them into an RGB image (``u`` -> red, ``v`` -> green) instead of + scaling by ``255`` and returning a two-channel tensor. Callers that consumed the previous + two-channel output should update to expect a three-channel ``[0, 1]`` image. + + 8.1.1 (2026-07-05) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index c5696f21100..66e349f1c51 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.1.1" +version = "8.1.2" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst b/source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst deleted file mode 100644 index d3958d45ecf..00000000000 --- a/source/isaaclab_ov/changelog.d/fix-ovrtx-broad-except.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Removed overly broad ``except Exception`` handling in :class:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer` - that downgraded failures in scene initialization, camera and object binding setup, scene partition writes, - Newton transform syncing, and :meth:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer.render` to log - warnings and silently continue. These now propagate so callers can decide how to handle the failure. diff --git a/source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst b/source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst deleted file mode 100644 index 44e9084afca..00000000000 --- a/source/isaaclab_ov/changelog.d/ovrtx-motion-vectors.minor.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* Added ``motion_vectors`` data type support to :class:`~isaaclab_ov.renderers.OVRTXRenderer`. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 316e8a798f9..b097ac973a1 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.5.4" +version = "0.6.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 a288ea2cd35..fa12b2d081d 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog --------- +0.6.0 (2026-07-06) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``motion_vectors`` data type support to :class:`~isaaclab_ov.renderers.OVRTXRenderer`. + +Fixed +^^^^^ + +* Removed overly broad ``except Exception`` handling in :class:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer` + that downgraded failures in scene initialization, camera and object binding setup, scene partition writes, + Newton transform syncing, and :meth:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer.render` to log + warnings and silently continue. These now propagate so callers can decide how to handle the failure. + + 0.5.4 (2026-07-03) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/pyproject.toml b/source/isaaclab_ov/pyproject.toml index ecbefa31d22..0c250169eb8 100644 --- a/source/isaaclab_ov/pyproject.toml +++ b/source/isaaclab_ov/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ov" -version = "0.5.4" +version = "0.6.0" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst b/source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst deleted file mode 100644 index 7aa0b56a6c3..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/ovrtx-motion-vectors.minor.rst +++ /dev/null @@ -1,7 +0,0 @@ -Added -^^^^^ - -* Added :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_enhanced_determinism` and - :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_external_forces_every_iteration`, mirroring the - equivalent PhysX solver settings already exposed on - :class:`~isaaclab_physx.physics.PhysxCfg`. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index 9c879827eb1..c25e8b2f01a 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 = "6.0.0" +version = "6.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 aed24188cee..96425f9de74 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +6.1.0 (2026-07-06) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_enhanced_determinism` and + :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_external_forces_every_iteration`, mirroring the + equivalent PhysX solver settings already exposed on + :class:`~isaaclab_physx.physics.PhysxCfg`. + + 6.0.0 (2026-07-03) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index 3215f5c0dd8..aa89c97b054 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ovphysx" -version = "6.0.0" +version = "6.1.0" description = "Extension providing IsaacLab with ovphysx/TensorBindingsAPI specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst b/source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst deleted file mode 100644 index 58dc39c804e..00000000000 --- a/source/isaaclab_tasks/changelog.d/ovrtx-motion-vectors.rst +++ /dev/null @@ -1,12 +0,0 @@ -Added -^^^^^ - -* Added ``motion_vectors`` to the rendering correctness test matrix for cartpole, shadow hand, and - dexsuite kuka allegro lift environments. - -Fixed -^^^^^ - -* Fixed flaky ``motion_vectors`` golden-image comparisons on PhysX backends (``physx`` and - ``ovphysx``) by enabling enhanced determinism and per-iteration external forces on the PhysX - solver, which otherwise produces run-to-run noisy velocities that this AOV encodes directly. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index be7ef714b7f..026ae3a7c21 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 = "8.1.4" +version = "8.1.5" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 7228be56533..8750611e4ab 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog --------- +8.1.5 (2026-07-06) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``motion_vectors`` to the rendering correctness test matrix for cartpole, shadow hand, and + dexsuite kuka allegro lift environments. + +Fixed +^^^^^ + +* Fixed flaky ``motion_vectors`` golden-image comparisons on PhysX backends (``physx`` and + ``ovphysx``) by enabling enhanced determinism and per-iteration external forces on the PhysX + solver, which otherwise produces run-to-run noisy velocities that this AOV encodes directly. + + 8.1.4 (2026-07-04) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index e328a713bf0..96051ad002e 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.1.4" +version = "8.1.5" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From f3c00080d95848d941c36a094a4f39147fe3ae0e Mon Sep 17 00:00:00 2001 From: ooctipus Date: Mon, 6 Jul 2026 00:14:54 -0700 Subject: [PATCH 089/139] Run OV test jobs on fork PRs without the OVPhysX wheelhouse (#6348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Since #6314 landed, `test-isaaclab-ov` and `test-rendering-correctness-kitless` are silently skipped on every fork PR: the job-level `if:` conditions require a same-repository PR (or `develop`) whenever `ovphysx_wheelhouse_resource` is configured — which it now always is. Fork contributors and team members working from forks have had zero OVRTX / kitless rendering CI coverage since then, with no visible signal (the jobs just show "skipped"). The NGC trust boundary is correct (the wheelhouse download needs `NGC_API_KEY`, which fork PRs cannot access), and this PR keeps it intact — the `USE_OVPHYSX_WHEELHOUSE` env var from #6314 already resolves trust per run, and the NGC download step is gated on `wheelhouse-resource != ''`, so it never executes without credentials. This PR's own CI (it is a fork PR, and PR runs use the merge-commit workflow, so it exercises its own fix) established exactly which coverage is recoverable on the public pip stack: - **All `isaaclab_ov` (ovrtx) tests pass** with pip-index `ovrtx` + `ovphysx` — 0 failures. - **All 562 `isaaclab_ovphysx` test failures are one error**: `AttributeError: type object 'PhysX' has no attribute 'set_cpu_mode'` — `ovphysx_manager` now requires ovphysx ≥ 0.5.1, which only exists in the NGC wheelhouse; the newest public wheel is 0.4.13. ovrtx does not depend on ovphysx, so the fallback separates them at the finest granularity each job allows: - `test-isaaclab-ov` **runs on fork PRs** via the pip fallback with `exclude-pattern: isaaclab_ovphysx` (file-level — the two packages live in separate test trees; the shared `filter-pattern: "isaaclab_ov"` only bundles them by prefix match). - `test-rendering-correctness-kitless` **runs on fork PRs** with a new `test-k-expr` input that reaches the per-file pytest subprocesses spawned by `tools/conftest.py` (param-level: `not ovphysx` — the golden-image files parametrize physics backends inside each file, so fork runs keep the `newton + ovrtx` combinations and deselect only the ovphysx-backed params). - Trusted runs (same-repo PRs to `develop`, post-merge `develop`) are byte-for-byte unchanged. Once ovphysx ≥ 0.5.1 is published to the public pip index, the exclude and the `-k` deselect can both be dropped and fork PRs regain full parity. cc @AntoineRichard — #6314's description says "Do not merge; this PR exists only for CI validation", so flagging in case the fork-PR skip wasn't meant to reach `develop` in this form at all. Fixes the coverage gap observed on #6308, where the OVRTX-variant jobs skip on every attempt. ## 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 - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .github/actions/run-package-tests/action.yml | 7 ++++++ .github/actions/run-tests/action.yml | 23 +++++++++++++++++++- .github/workflows/build.yaml | 19 ++++++++-------- tools/conftest.py | 5 +++++ 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/.github/actions/run-package-tests/action.yml b/.github/actions/run-package-tests/action.yml index a88ec78c904..9ee6dcddd9b 100644 --- a/.github/actions/run-package-tests/action.yml +++ b/.github/actions/run-package-tests/action.yml @@ -41,6 +41,12 @@ inputs: skipped. Combines with filter-pattern (include + exclude). default: '' required: false + test-k-expr: + description: >- + Global pytest -k expression applied inside every per-file pytest run + spawned by tools/conftest.py (combined with device-split selectors). + default: '' + required: false shard-index: description: 'Zero-based shard index' default: '' @@ -286,6 +292,7 @@ runs: pytest-options: ${{ inputs.pytest-options }} filter-pattern: ${{ inputs.filter-pattern }} exclude-pattern: ${{ inputs.exclude-pattern }} + test-k-expr: ${{ inputs.test-k-expr }} shard-index: ${{ inputs.shard-index }} shard-count: ${{ inputs.shard-count }} curobo-only: ${{ inputs.curobo-only }} diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index c4a6859b639..fbc7043cdf8 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -41,6 +41,14 @@ inputs: excluded. Combines with filter-pattern (include + exclude). default: '' required: false + test-k-expr: + description: >- + Global pytest -k expression applied inside every per-file pytest run + spawned by tools/conftest.py (combined with device-split selectors). + Unlike pytest-options, this reaches the individual test processes, so it + can deselect parametrized cases (e.g. "not ovphysx"). + default: '' + required: false curobo-only: description: 'Run only cuRobo and SkillGen tests (requires the cuRobo Docker image)' default: 'false' @@ -94,6 +102,12 @@ runs: steps: - name: Run Tests in Docker Container shell: bash + env: + # Passed via env instead of inline ${{ }} interpolation: pytest options may + # contain spaces and quotes (e.g. -k "not ovphysx"), which would word-split + # the run_tests positional arguments if substituted textually. + PYTEST_OPTIONS: ${{ inputs.pytest-options }} + TEST_K_EXPR_INPUT: ${{ inputs.test-k-expr }} run: | # Function to run tests in Docker container run_tests() { @@ -116,6 +130,7 @@ runs: local test_node_ids_key="${17}" local wheelhouse_host_dir="${18}" local wheelhouse_packages="${19}" + local test_k_expr="${20}" local logs_pid="" local wait_pid="" local docker_wait_file="/tmp/.docker_exit_${container_name}" @@ -258,6 +273,12 @@ runs: docker_env_vars="$docker_env_vars -e TEST_EXTRA_PIP_PACKAGES" fi + if [ -n "$test_k_expr" ]; then + export TEST_K_EXPR="$test_k_expr" + docker_env_vars="$docker_env_vars -e TEST_K_EXPR" + echo "Setting per-file pytest -k expression: $test_k_expr" + fi + # 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="" @@ -491,7 +512,7 @@ runs: } # Call the function with provided parameters - run_tests "${{ inputs.test-path }}" "${{ inputs.result-file }}" "${{ inputs.container-name }}" "${{ inputs.image-tag }}" "${{ inputs.reports-dir }}" "${{ inputs.pytest-options }}" "${{ inputs.filter-pattern }}" "${{ inputs.exclude-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" "${{ inputs.extra-pip-packages }}" "${{ inputs.test-node-ids-file }}" "${{ inputs.test-node-ids-key }}" "${{ inputs.wheelhouse-host-dir }}" "${{ inputs.wheelhouse-packages }}" + run_tests "${{ inputs.test-path }}" "${{ inputs.result-file }}" "${{ inputs.container-name }}" "${{ inputs.image-tag }}" "${{ inputs.reports-dir }}" "$PYTEST_OPTIONS" "${{ inputs.filter-pattern }}" "${{ inputs.exclude-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" "${{ inputs.extra-pip-packages }}" "${{ inputs.test-node-ids-file }}" "${{ inputs.test-node-ids-key }}" "${{ inputs.wheelhouse-host-dir }}" "${{ inputs.wheelhouse-packages }}" "$TEST_K_EXPR_INPUT" - name: Kill container on cancellation if: cancelled() diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1bef75f6b01..39f9ffab1b2 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -596,10 +596,7 @@ jobs: needs: [build, config] if: >- github.event_name != 'push' && - needs.build.result == 'success' && - (needs.config.outputs.ovphysx_wheelhouse_resource == '' || - (github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name != 'pull_request' && github.ref_name == 'develop')) + needs.build.result == 'success' env: USE_OVPHYSX_WHEELHOUSE: ${{ needs.config.outputs.ovphysx_wheelhouse_resource != '' && ((github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name != 'pull_request' && github.ref_name == 'develop')) }} steps: @@ -613,6 +610,10 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_ov" + # isaaclab_ovphysx requires ovphysx >= 0.5.1, which is only available from the + # NGC wheelhouse; untrusted runs (fork PRs) fall back to the public pip index + # and keep only the ovrtx-based isaaclab_ov coverage. + exclude-pattern: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && '' || 'isaaclab_ovphysx' }} extra-pip-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovrtx' || 'ovrtx ovphysx' }} wheelhouse-resource: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && needs.config.outputs.ovphysx_wheelhouse_resource || '' }} wheelhouse-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovphysx' || '' }} @@ -777,11 +778,7 @@ jobs: timeout-minutes: 120 continue-on-error: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} needs: [build, config] - if: >- - needs.build.result == 'success' && - (needs.config.outputs.ovphysx_wheelhouse_resource == '' || - (github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name != 'pull_request' && github.ref_name == 'develop')) + if: needs.build.result == 'success' env: USE_OVPHYSX_WHEELHOUSE: ${{ needs.config.outputs.ovphysx_wheelhouse_resource != '' && ((github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name != 'pull_request' && github.ref_name == 'develop')) }} steps: @@ -795,6 +792,10 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" + # ovphysx-backed test params require ovphysx >= 0.5.1, which is only available + # from the NGC wheelhouse; untrusted runs (fork PRs) fall back to the public pip + # index and keep the newton/ovrtx kitless rendering coverage. + test-k-expr: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && '' || 'not ovphysx' }} extra-pip-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovrtx' || 'ovrtx ovphysx' }} wheelhouse-resource: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && needs.config.outputs.ovphysx_wheelhouse_resource || '' }} wheelhouse-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovphysx' || '' }} diff --git a/tools/conftest.py b/tools/conftest.py index a34812ddcca..963853194f3 100644 --- a/tools/conftest.py +++ b/tools/conftest.py @@ -738,6 +738,9 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci, test_node_ids_ xml_reports = [] cold_cache_applied = False test_node_ids_by_file = test_node_ids_by_file or {} + global_k_expr = os.environ.get("TEST_K_EXPR", "").strip() or None + if global_k_expr is not None: + print(f"Applying global pytest -k expression to every test file: '{global_k_expr}'") for test_file in test_files: print(f"\n\n🚀 Running {test_file} independently...\n") @@ -786,6 +789,8 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci, test_node_ids_ merged_status: dict | None = None for suffix, k_expr in passes: + if global_k_expr is not None: + k_expr = f"({k_expr}) and ({global_k_expr})" if k_expr else global_k_expr report, status, was_failure = _run_one_pass(ctx, k_expr=k_expr, suffix=suffix) if report is not None: xml_reports.append(report) From b8ed8a9bf81484d8694a4cb08a28513ba89cd4a2 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Mon, 6 Jul 2026 10:49:50 +0200 Subject: [PATCH 090/139] Add backend-agnostic benchmark core (benchmark refactor, Part 1/5) (#6197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description **Part 1 of 5** of a series that splits the (oversized) benchmark refactor into reviewable, independently-mergeable PRs: - **Part 1/5 — this PR:** backend-agnostic benchmark *core* under `isaaclab.test.benchmark`. - **Part 2/5 (#6198):** unified `runtime.py` + `startup.py` entry scripts (depends on this PR). - **Part 3/5 (#6199):** unified `training.py` dispatcher + `rsl_rl`/`rl_games`/`skrl`/`sb3` adapters (depends on Parts 1–2). - **Part 4/5 (#6201):** play/inference benchmark. - **Part 5/5:** removal of the legacy `benchmark_*` / `run_*.sh` / `utils.py` scripts. Parts 1–4 are **purely additive**: they add the new suite alongside the existing scripts, which keep working unchanged. The legacy scripts are removed only in **Part 5/5**, so downstream consumers (OmniPerf ingestion, job runners) can migrate at their own pace. This PR adds the reusable core the entry scripts build on: - New submodules under `isaaclab.test.benchmark`: - `capture` — versions / hardware / resources / run-id capture from the benchmark recorders. - `metrics` — TensorBoard log parsing, convergence detection, EMA, mean/std/peak, success-rate tracking. - `builders` — assemble the schema-v1 `RuntimeBundle` / `TrainingBundle` / `StartupBundle`. - `stepping` — backend-agnostic random-action stepping loop. - `profiling` — `cProfile` stats parsing (own/cumulative time, call counts). - `backend_descriptor` — per-RL-library TensorBoard tag descriptors. - A new `schema` output backend that serializes a benchmark bundle through the existing `BaseIsaacLabBenchmark` metrics-backend system, plus multi-backend support (e.g. `--benchmark_backend schema,omniperf`) via a new `attach_bundle` hook. Single-backend behavior and filenames are unchanged, so existing benchmarks are unaffected. Note: the shared GPU/memory recorders gain additive peak rows (`GPU [i] Memory Used peak`, `System Memory RSS/VMS/USS peak`); every pre-existing KPI row is unchanged. This is the only change visible in the legacy scripts' OmniPerf/JSON output. Builds on the v1.0 benchmark schema merged in #5840. No entry scripts or docs change here — those land with Parts 2–3. Fixes # (n/a) ## 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 (user-facing benchmark docs land with the entry scripts in Parts 2–3) - [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 - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/source/testing/benchmarks.rst | 86 +-- .../antoiner-benchmark-core.major.rst | 44 ++ source/isaaclab/isaaclab/app/sim_launcher.py | 7 +- .../isaaclab/test/benchmark/benchmark_core.py | 189 +++++- .../isaaclab/test/benchmark/builders.py | 303 ++++++++++ .../isaaclab/test/benchmark/capture.py | 449 +++++++++++++++ .../benchmark/{backends.py => formatters.py} | 150 +++-- .../isaaclab/test/benchmark/measurements.py | 2 +- .../isaaclab/test/benchmark/metrics.py | 278 +++++++++ .../isaaclab/test/benchmark/profiling.py | 128 +++++ .../recorders/record_version_info.py | 60 +- .../isaaclab/test/benchmark/schema.py | 16 +- .../isaaclab/test/benchmark/stepping.py | 85 +++ source/isaaclab/test/app/test_kwarg_launch.py | 37 ++ .../test/benchmark/test_benchmark_core.py | 536 ++++++++---------- .../isaaclab/test/benchmark/test_builders.py | 182 ++++++ .../isaaclab/test/benchmark/test_capture.py | 312 ++++++++++ .../test/benchmark/test_formatters.py | 143 +++++ .../isaaclab/test/benchmark/test_metrics.py | 178 ++++++ .../isaaclab/test/benchmark/test_profiling.py | 67 +++ .../isaaclab/test/benchmark/test_recorders.py | 140 ++++- .../isaaclab/test/benchmark/test_stepping.py | 75 +++ 22 files changed, 3043 insertions(+), 424 deletions(-) create mode 100644 source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst create mode 100644 source/isaaclab/isaaclab/test/benchmark/builders.py create mode 100644 source/isaaclab/isaaclab/test/benchmark/capture.py rename source/isaaclab/isaaclab/test/benchmark/{backends.py => formatters.py} (81%) create mode 100644 source/isaaclab/isaaclab/test/benchmark/metrics.py create mode 100644 source/isaaclab/isaaclab/test/benchmark/profiling.py create mode 100644 source/isaaclab/isaaclab/test/benchmark/stepping.py create mode 100644 source/isaaclab/test/benchmark/test_builders.py create mode 100644 source/isaaclab/test/benchmark/test_capture.py create mode 100644 source/isaaclab/test/benchmark/test_formatters.py create mode 100644 source/isaaclab/test/benchmark/test_metrics.py create mode 100644 source/isaaclab/test/benchmark/test_profiling.py create mode 100644 source/isaaclab/test/benchmark/test_stepping.py diff --git a/docs/source/testing/benchmarks.rst b/docs/source/testing/benchmarks.rst index f90ced1a78c..4e40faeaef6 100644 --- a/docs/source/testing/benchmarks.rst +++ b/docs/source/testing/benchmarks.rst @@ -6,7 +6,7 @@ Benchmarking Framework Isaac Lab provides a comprehensive benchmarking framework for measuring the performance of simulations, training workflows, and system resources. The framework is designed to work without depending on Isaac Sim's benchmark services, enabling standalone benchmarking -with pluggable output backends. +with pluggable output formatters. Overview -------- @@ -23,9 +23,9 @@ The benchmarking framework consists of several key components: ┌───────────┼───────────┐ │ │ │ ▼ ▼ ▼ - ┌───────┐ ┌─────────┐ ┌──────────┐ - │Phases │ │Recorders│ │ Backends │ - └───────┘ └─────────┘ └──────────┘ + ┌───────┐ ┌─────────┐ ┌────────────┐ + │Phases │ │Recorders│ │ Formatters │ + └───────┘ └─────────┘ └────────────┘ **Key Components:** @@ -34,7 +34,7 @@ The benchmarking framework consists of several key components: - **Metadata**: Data classes for recording context (hardware, versions, parameters) - **TestPhase**: Container for organizing measurements into logical groups - **Recorders**: System information collectors (CPU, GPU, memory, versions) -- **Backends**: Output formatters (JSON, Osmo, OmniPerf, Summary) +- **Formatters**: Output formatters (JSON, Osmo, OmniPerf, Summary, Schema) .. seealso:: @@ -59,7 +59,7 @@ Basic usage with :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark`: # Initialize benchmark benchmark = BaseIsaacLabBenchmark( benchmark_name="MyBenchmark", - backend_type="json", + formatter_type="json", output_path="./results", ) @@ -113,7 +113,7 @@ Measure environment stepping performance without training: --task Isaac-Cartpole \ --num_envs 4096 \ --num_frames 100 \ - --benchmark_backend json \ + --benchmark_formatter json \ --output_path ./results RL Training Benchmarks @@ -131,7 +131,7 @@ Measure training performance with RSL-RL: --task Isaac-Cartpole \ --num_envs 4096 \ --max_iterations 500 \ - --benchmark_backend json \ + --benchmark_formatter json \ --output_path ./results PhysX Micro-Benchmarks @@ -166,7 +166,7 @@ understanding where time is spent during initialization. ./isaaclab.sh -p scripts/benchmarks/benchmark_startup.py \ --task Isaac-Ant \ --num_envs 4096 \ - --benchmark_backend summary + --benchmark_formatter summary The script profiles five phases independently: @@ -204,7 +204,7 @@ Patterns use ``fnmatch`` syntax (``*`` and ``?`` wildcards): ./isaaclab.sh -p scripts/benchmarks/benchmark_startup.py \ --task Isaac-Ant \ --num_envs 4096 \ - --benchmark_backend omniperf \ + --benchmark_formatter omniperf \ --whitelist_config scripts/benchmarks/startup_whitelist.yaml Phases listed in the YAML use the whitelist; phases not listed fall back to @@ -233,9 +233,9 @@ A default whitelist is provided at ``scripts/benchmarks/startup_whitelist.yaml`` * - ``--whitelist_config`` - None - Path to YAML whitelist file - * - ``--benchmark_backend`` + * - ``--benchmark_formatter`` - ``omniperf`` - - Output backend (``json``, ``osmo``, ``omniperf``, ``summary``) + - Output formatter (``json``, ``osmo``, ``omniperf``, ``summary``, or ``schema``) * - ``--output_path`` - ``.`` - Directory for output files @@ -253,9 +253,9 @@ Common Arguments * - Argument - Default - Description - * - ``--benchmark_backend`` + * - ``--benchmark_formatter`` - ``json`` - - Output backend: ``json``, ``osmo``, ``omniperf``, or ``summary`` + - Output formatter: ``json``, ``osmo``, ``omniperf``, ``summary``, or ``schema`` * - ``--output_path`` - ``./`` - Directory for output files @@ -404,17 +404,17 @@ Example: benchmark.add_measurement("simulation", metadata=task_metadata) benchmark.add_measurement("training", measurement=reward_measurement) -Output Backends ---------------- +Output Formatters +----------------- -JSON Backend -~~~~~~~~~~~~ +JSON Formatter +~~~~~~~~~~~~~~ Full output with all phases, measurements, and metadata: .. code-block:: bash - ./isaaclab.sh -p ... --benchmark_backend json --output_path ./results + ./isaaclab.sh -p ... --benchmark_formatter json --output_path ./results Output structure: @@ -437,14 +437,14 @@ Output structure: } ] -Osmo Backend -~~~~~~~~~~~~ +Osmo Formatter +~~~~~~~~~~~~~~ Simplified key-value format for CI/CD integration: .. code-block:: bash - ./isaaclab.sh -p ... --benchmark_backend osmo --output_path ./results + ./isaaclab.sh -p ... --benchmark_formatter osmo --output_path ./results Output structure: @@ -457,14 +457,14 @@ Output structure: "task": "Isaac-Cartpole" } -OmniPerf Backend -~~~~~~~~~~~~~~~~ +OmniPerf Formatter +~~~~~~~~~~~~~~~~~~ Format for database upload and performance tracking: .. code-block:: bash - ./isaaclab.sh -p ... --benchmark_backend omniperf --output_path ./results + ./isaaclab.sh -p ... --benchmark_formatter omniperf --output_path ./results Output structure: @@ -479,8 +479,20 @@ Output structure: } } -Summary Backend -~~~~~~~~~~~~~~~ +Schema Formatter +~~~~~~~~~~~~~~~~ + +Writes a schema-v1 bundle attached with +:meth:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark.attach_bundle`. Use it +with a ``RuntimeBundle``, ``TrainingBundle``, or ``StartupBundle`` when a +typed, stable output contract is required. + +.. code-block:: bash + + ./isaaclab.sh -p ... --benchmark_formatter schema --output_path ./results + +Summary Formatter +~~~~~~~~~~~~~~~~~ Human-readable console report plus JSON file. Prints a formatted summary to the terminal while also writing the same data as JSON. Standard phases (runtime, @@ -491,7 +503,7 @@ entries. Use when you want a quick readout without opening the JSON: .. code-block:: bash - ./isaaclab.sh -p ... --benchmark_backend summary --output_path ./results + ./isaaclab.sh -p ... --benchmark_formatter summary --output_path ./results When ``summary`` is selected, frametime recorders are enabled automatically when running with Isaac Sim (Kit). @@ -508,7 +520,7 @@ monitoring during blocking operations like RL training loops: benchmark = BaseIsaacLabBenchmark( benchmark_name="TrainingBenchmark", - backend_type="json", + formatter_type="json", output_path="./results", ) @@ -564,13 +576,13 @@ Step 1: Initialize Benchmark from isaaclab.test.benchmark import BaseIsaacLabBenchmark parser = argparse.ArgumentParser() - parser.add_argument("--benchmark_backend", default="json") + parser.add_argument("--benchmark_formatter", default="json") parser.add_argument("--output_path", default="./") args = parser.parse_args() benchmark = BaseIsaacLabBenchmark( benchmark_name="CustomBenchmark", - backend_type=args.benchmark_backend, + formatter_type=args.benchmark_formatter, output_path=args.output_path, ) @@ -635,7 +647,7 @@ The shell scripts in ``scripts/benchmarks/`` are designed for CI/CD integration: name: benchmark-results path: ./benchmark_results/ -For Osmo integration, use the ``osmo`` backend: +For Osmo integration, use the ``osmo`` formatter: .. code-block:: bash @@ -682,15 +694,15 @@ Ensure ``_finalize_impl()`` is called before the script exits: finally: benchmark._finalize_impl() -Backend Not Recognized -~~~~~~~~~~~~~~~~~~~~~~ +Formatter Not Recognized +~~~~~~~~~~~~~~~~~~~~~~~~ -Valid backend types are: ``json``, ``osmo``, ``omniperf``, ``summary`` +Valid formatter types are: ``json``, ``osmo``, ``omniperf``, ``summary``, or ``schema`` .. code-block:: bash # Correct - --benchmark_backend json + --benchmark_formatter json # Incorrect - --benchmark_backend JSON # Case sensitive + --benchmark_formatter JSON # Case sensitive diff --git a/source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst b/source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst new file mode 100644 index 00000000000..b33d2628bfb --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst @@ -0,0 +1,44 @@ +Added +^^^^^ + +* Added a backend-agnostic benchmark core under :mod:`isaaclab.test.benchmark`, + including the ``capture``, ``metrics``, ``builders``, ``stepping``, + and ``profiling`` submodules, for assembling and emitting schema-v1 benchmark + bundles (``RuntimeBundle`` / ``TrainingBundle`` / + ``StartupBundle``). +* Added a ``schema`` output formatter that serializes a benchmark bundle through + :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark`, and taught + ``BaseIsaacLabBenchmark`` to emit several formatters in one run from a + comma-separated formatter selection and a new ``attach_bundle`` hook. +* Added runtime and package version metadata to schema benchmark bundles, + including IsaacLab extensions, OVRTX, OVPhysX, MuJoCo, CUDA bindings, and + USD Core. + +Changed +^^^^^^^ + +* **Breaking:** Renamed the benchmark metrics-formatter module + ``isaaclab.test.benchmark.backends`` to ``isaaclab.test.benchmark.formatters``, and the + ``MetricsBackend`` / ``MetricsBackendInterface`` classes to ``MetricsFormatter`` / + ``MetricsFormatterInterface``. The output formatter classes (``JSONFileMetrics``, + ``SummaryMetrics``, ``OsmoKPIFile``, ``OmniPerfKPIFile``) are unchanged but now live in the + ``formatters`` module — update imports from ``isaaclab.test.benchmark.backends`` to + ``isaaclab.test.benchmark.formatters``. The + :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark` constructor keeps ``backend_type`` as + an alias for the new ``formatter_type`` argument, so callers that pass ``backend_type=`` + continue to work unchanged. + +Fixed +^^^^^ + +* Fixed multi-phase :class:`~isaaclab.test.benchmark.OsmoKPIFile` output + overwriting earlier phases by writing one phase-suffixed JSON file per phase. +* Fixed benchmark run metadata to use resolved task defaults for physics and + rendering backends. +* Fixed simulation launch failures being reported with a zero process exit + status during Kit fast shutdown. +* Fixed benchmark metadata so Kit-full runs now report Kit and Isaac Sim + versions while Kitless runs report null. +* Fixed benchmark metadata to report the installed OVPhysX runtime version. +* Fixed benchmark metadata to preserve null values for unavailable OVRTX and + OVPhysX runtimes. diff --git a/source/isaaclab/isaaclab/app/sim_launcher.py b/source/isaaclab/isaaclab/app/sim_launcher.py index 0a5ac08a110..fc69dd3cdc9 100644 --- a/source/isaaclab/isaaclab/app/sim_launcher.py +++ b/source/isaaclab/isaaclab/app/sim_launcher.py @@ -473,16 +473,21 @@ def launch_simulation( {**base, "visualizer_explicit": True, "visualizer_disable_all": disable_all} ) + exit_code = 0 try: yield physics_cfg except Exception: + exit_code = 1 import traceback traceback.print_exc() raise finally: if close_fn is not None: - close_fn() + if exit_code: + close_fn(exit_code=exit_code) + else: + close_fn() def _ensure_isaac_sim_available() -> None: diff --git a/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py b/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py index 7daedbc132a..659929cb821 100644 --- a/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py +++ b/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py @@ -8,12 +8,33 @@ import time from collections.abc import Sequence from datetime import datetime - -from . import backends -from .backends import get_default_output_filename -from .interfaces import MeasurementDataRecorder -from .measurements import DictMetadata, FloatMetadata, IntMetadata, Measurement, MetadataBase, StringMetadata, TestPhase -from .recorders import CPUInfoRecorder, GPUInfoRecorder, MemoryInfoRecorder, VersionInfoRecorder +from typing import TYPE_CHECKING + +from isaaclab.test.benchmark import formatters +from isaaclab.test.benchmark.formatters import get_default_output_filename +from isaaclab.test.benchmark.interfaces import MeasurementDataRecorder +from isaaclab.test.benchmark.measurements import ( + DictMetadata, + FloatMetadata, + IntMetadata, + ListMeasurement, + Measurement, + MetadataBase, + SingleMeasurement, + StringMetadata, + TestPhase, +) +from isaaclab.test.benchmark.recorders import CPUInfoRecorder, GPUInfoRecorder, MemoryInfoRecorder, VersionInfoRecorder + +if TYPE_CHECKING: + from isaaclab.test.benchmark.schema import ( + LearningCurve, + MeanStd, + Runtime, + RuntimeBundle, + StartupBundle, + TrainingBundle, + ) logger = logging.getLogger(__name__) @@ -39,33 +60,136 @@ def _is_metadata_type(obj: object) -> bool: return type(obj).__name__ in _METADATA_CLASS_NAMES +def _stat_measurements(name: str, stats: "MeanStd", unit: str, scale: float = 1.0) -> list[Measurement]: + """Convert a schema aggregate to flat scalar measurements.""" + measurements: list[Measurement] = [ + SingleMeasurement(name=f"Mean {name}", value=stats.mean * scale, unit=unit), + SingleMeasurement(name=f"Std {name}", value=stats.std * scale, unit=unit), + ] + if stats.peak is not None: + measurements.append(SingleMeasurement(name=f"Max {name}", value=stats.peak * scale, unit=unit)) + return measurements + + +def _runtime_measurements(runtime: "Runtime") -> dict[str, list[Measurement]]: + """Convert schema runtime metrics to startup and runtime phases.""" + startup_fields = ( + ("app_launch", "App Launch Time"), + ("python_imports", "Python Imports Time"), + ("task_config", "Task Creation and Start Time"), + ("env_creation", "Scene Creation Time"), + ("first_step", "Simulation Start Time"), + ) + startup = [ + SingleMeasurement(name=label, value=value * 1000.0, unit="ms") + for field, label in startup_fields + if (value := getattr(runtime.startup_time_s, field)) is not None + ] + if startup: + startup.append( + SingleMeasurement( + name="Total Start Time (Launch to Train)", + value=sum(float(measurement.value) for measurement in startup), + unit="ms", + ) + ) + + runtime_metrics: list[Measurement] = [ + SingleMeasurement(name="Iterations Completed", value=runtime.iterations_completed, unit="count"), + SingleMeasurement(name="Total Wall Time", value=runtime.total_wall_time_s, unit="s"), + SingleMeasurement(name="Steps per Iteration", value=runtime.steps_per_iteration, unit="frames"), + ] + runtime_metrics.extend(_stat_measurements("Iteration Time", runtime.iteration_time_s, "ms", 1000.0)) + runtime_metrics.extend(_stat_measurements("Collection FPS", runtime.collection_fps, "FPS")) + runtime_metrics.extend(_stat_measurements("Total FPS", runtime.total_fps, "FPS")) + runtime_metrics.extend(_stat_measurements("Iterations per Second", runtime.iterations_per_s, "iterations/s")) + return {"startup": startup, "runtime": runtime_metrics} + + +def _curve_measurements(label: str, curve: "LearningCurve", ema_alpha: float) -> list[Measurement]: + """Convert one training curve to scalar and optional series measurements.""" + measurements: list[Measurement] = [ + SingleMeasurement(name=f"Last {label}", value=curve.final_raw, unit="float"), + SingleMeasurement(name=f"EMA {ema_alpha:g} {label}", value=curve.final_ema, unit="float"), + ] + if curve.series_per_iter is not None: + plural = "Rewards" if label == "Reward" else "Episode Lengths" + measurements.append(ListMeasurement(name=plural, value=curve.series_per_iter)) + if curve.series_per_iter: + measurements.append(SingleMeasurement(name=f"Max {plural}", value=max(curve.series_per_iter), unit="float")) + return measurements + + +def _measurements_from_bundle( + bundle: "RuntimeBundle | TrainingBundle | StartupBundle", +) -> dict[str, list[Measurement]]: + """Project a typed bundle into flat phases for non-schema formatters.""" + from isaaclab.test.benchmark.schema import StartupBundle, TrainingBundle + + if isinstance(bundle, StartupBundle): + projected: dict[str, list[Measurement]] = {} + for phase_name, phase in bundle.phases.items(): + measurements: list[Measurement] = [ + SingleMeasurement(name="Wall Clock Time", value=phase.total_time_s, unit="s") + ] + for function in phase.top_functions: + measurements.extend( + [ + SingleMeasurement(name=f"{function.name} Own Time", value=function.own_time_s, unit="s"), + SingleMeasurement(name=f"{function.name} Cumulative Time", value=function.cum_time_s, unit="s"), + SingleMeasurement(name=f"{function.name} Calls", value=function.calls, unit="count"), + ] + ) + projected[phase_name] = measurements + return projected + + projected = _runtime_measurements(bundle.runtime) + if isinstance(bundle, TrainingBundle): + train = _curve_measurements("Reward", bundle.learning.reward, bundle.learning.ema_alpha) + train.extend(_curve_measurements("Episode Length", bundle.learning.ep_length, bundle.learning.ema_alpha)) + if bundle.success_rate is not None: + train.append(SingleMeasurement(name="success_rate", value=bundle.success_rate, unit="float")) + projected["train"] = train + return projected + + class BaseIsaacLabBenchmark: """Base benchmark class for IsaacLab's benchmarks.""" def __init__( self, benchmark_name: str, - backend_type: str, - output_path: str, + formatter_type: str | list[str] | None = None, + output_path: str | None = None, use_recorders: bool = True, output_prefix: str | None = None, workflow_metadata: dict | None = None, frametime_recorders: bool = False, + backend_type: str | list[str] | None = None, ): """Initialize common benchmark state and recorders. Args: benchmark_name: Name of benchmark to use in outputs. - backend_type: Type of backend used to collect and print metrics. + formatter_type: Formatter(s) used to collect and print metrics. Accepts a single + type name, a list of type names, or a comma-separated string (e.g. + ``"schema,omniperf"``); each selected formatter writes its own output file. output_path: Path to output directory. use_recorders: Whether to use recorders to collect metrics. Defaults to True. - output_filename: Filename to use for the output file, defaults to None. + output_prefix: Prefix used to generate the output filename. Defaults to ``None``. workflow_metadata: Metadata describing benchmark, defaults to None. - frametime_recorders: Whether to use frametime recorders to collect metrics. Defaults to True. + frametime_recorders: Whether to use frametime recorders to collect metrics. Defaults to ``False``. + backend_type: Alias for :paramref:`formatter_type`. """ + if formatter_type is None: + formatter_type = backend_type or "omniperf" + elif backend_type is not None and backend_type != formatter_type: + raise ValueError("Specify either formatter_type or backend_type, not both.") + if output_path is None: + raise ValueError("output_path must be provided.") + self.benchmark_name = benchmark_name - # Resolve output path if not os.path.exists(output_path): try: os.makedirs(output_path) @@ -77,9 +201,12 @@ def __init__( logger.warning("No output prefix provided, using default prefix: benchmark") self.output_prefix = get_default_output_filename(output_prefix) - # Get metrics backend - logger.info("Using metrics backend = %s", backend_type) - self._metrics = backends.MetricsBackend.get_instance(instance_type=backend_type) + if isinstance(formatter_type, str): + formatter_type = [t.strip() for t in formatter_type.split(",") if t.strip()] or ["omniperf"] + formatter_type = list(dict.fromkeys(formatter_type)) + logger.info("Using metrics formatters = %s", formatter_type) + self._metrics = [(t, formatters.MetricsFormatter.get_instance(instance_type=t)) for t in formatter_type] + self._bundle = None self._phases: dict[str, TestPhase] = {} # Generate workflow-level metadata @@ -214,6 +341,17 @@ def _metadata_from_dict(self, metadata_dict: dict) -> list[MetadataBase]: metadata.append(curr_meta) return metadata + def attach_bundle(self, bundle: "RuntimeBundle | TrainingBundle | StartupBundle | None") -> None: + """Attach a typed bundle for schema serialization and flat-formatter projection. + + Args: + bundle: Runtime, training, or startup benchmark bundle. + """ + self._bundle = bundle + if bundle is not None: + for phase_name, measurements in _measurements_from_bundle(bundle).items(): + self.add_measurement(phase_name, measurement=measurements) + def update_manual_recorders(self) -> None: """Update manual recorders that don't depend on the kit timeline.""" @@ -239,32 +377,28 @@ def add_measurement( """ if phase_name not in self._phases: self._phases[phase_name] = TestPhase(phase_name=phase_name) - # Add required phase metadata for backends + # Add required phase metadata for formatters phase_metadata = StringMetadata(name="phase", data=phase_name) workflow_metadata = StringMetadata(name="workflow_name", data=self.benchmark_name) self._phases[phase_name].metadata.extend([phase_metadata, workflow_metadata]) if measurement: if isinstance(measurement, Sequence): - # Check that all the elements are of type Measurement for m in measurement: if not _is_measurement_type(m): raise ValueError(f"Measurement element {m} is not of type Measurement") self._phases[phase_name].measurements.extend(measurement) else: - # Check that the element is of type Measurement if not _is_measurement_type(measurement): raise ValueError(f"Measurement element {measurement} is not of type Measurement") self._phases[phase_name].measurements.append(measurement) if metadata: if isinstance(metadata, Sequence): - # Check that all the elements are of type MetadataBase for m in metadata: if not _is_metadata_type(m): raise ValueError(f"Metadata element {m} is not of type MetadataBase") self._phases[phase_name].metadata.extend(metadata) else: - # Check that the element is of type MetadataBase if not _is_metadata_type(metadata): raise ValueError(f"Metadata element {metadata} is not of type MetadataBase") self._phases[phase_name].metadata.append(metadata) @@ -293,15 +427,18 @@ def _finalize_impl(self) -> None: if data.measurements: self.add_measurement("frametime", measurement=data.measurements) - # Check that there are phases to write. if not self._phases: logger.warning("No phases collected.No metrics will be written.") return - # Add the phases to the metrics backend. - for phase in self._phases.values(): - self._metrics.add_metrics(phase) - - self._metrics.finalize(self.output_path, self.output_prefix) + # Add the phases to each metrics formatter and write its output file. When more than one + # formatter is selected, suffix the filename with the formatter key so they don't collide on + # the shared ".json" extension. + multi = len(self._metrics) > 1 + for formatter_key, metrics in self._metrics: + for phase in self._phases.values(): + metrics.add_metrics(phase) + filename = f"{self.output_prefix}_{formatter_key}" if multi else self.output_prefix + metrics.finalize(self.output_path, filename, bundle=self._bundle) self._manual_recorders = None self._frametime_recorders = None diff --git a/source/isaaclab/isaaclab/test/benchmark/builders.py b/source/isaaclab/isaaclab/test/benchmark/builders.py new file mode 100644 index 00000000000..93bfe3674e5 --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/builders.py @@ -0,0 +1,303 @@ +# 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 + +"""Pure assembly functions for benchmark bundle dataclasses. + +Converts already-extracted per-iteration series and scalar measurements into +the frozen :mod:`~isaaclab.test.benchmark.schema` dataclasses that are then +serialised by :func:`~isaaclab.test.benchmark.serialize.write_bundle_file`. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime + +from isaaclab.test.benchmark.metrics import ema, mean_std_peak +from isaaclab.test.benchmark.schema import ( + Hardware, + Learning, + LearningCurve, + Resources, + RunConfig, + RunIdentity, + Runtime, + RuntimeBundle, + StartupBundle, + StartupConfig, + StartupPhase, + StartupTime, + TrainingBundle, + Versions, +) + + +def build_run_config( + physics_backend: str, + rendering_backend: str = "none", + presets: Sequence[str] | None = None, +) -> RunConfig: + """Assemble a :class:`~isaaclab.test.benchmark.schema.RunConfig`. + + Args: + physics_backend: Physics solver preset used by the run + (e.g. ``"physx"``, ``"newton_mjwarp"``). + rendering_backend: Rendering backend, or ``"none"`` for headless runs + with no camera sensors. + presets: Active Hydra preset tokens (e.g. ``["rgb"]``). ``None`` + is treated as an empty list. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.RunConfig`. + """ + return RunConfig( + physics_backend=physics_backend, + rendering_backend=rendering_backend, + presets=list(presets) if presets else [], + ) + + +def build_run_identity( + *, + run_id: str, + framework: str | None, + config: RunConfig, + task: str, + seed: int, + start_utc: str, + end_utc: str, + status: str = "completed", + num_envs: int | None = None, + max_iterations: int | None = None, +) -> RunIdentity: + """Assemble a :class:`~isaaclab.test.benchmark.schema.RunIdentity`. + + The wall-clock duration is derived from the ISO-8601 timestamps; it is + clamped to zero so clock skew cannot produce a negative value. + + Args: + run_id: Stable identifier for the run. + framework: RL library (e.g. ``"rsl_rl"``), or ``None`` for non-learning + runs. + config: Physics/rendering/sensor configuration. + task: Gym task id. + seed: Environment/agent seed. + start_utc: ISO-8601 UTC start timestamp. + end_utc: ISO-8601 UTC end timestamp. + status: Terminal status of the run. + num_envs: Number of parallel environments, or ``None`` for startup runs. + max_iterations: Training iteration budget, or ``None`` for non-training + runs. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.RunIdentity` with + ``duration_s`` computed from the timestamps [s]. + """ + duration_s = max( + 0.0, + (datetime.fromisoformat(end_utc) - datetime.fromisoformat(start_utc)).total_seconds(), + ) + return RunIdentity( + run_id=run_id, + framework=framework, + config=config, + task=task, + seed=seed, + start_time_utc=start_utc, + end_time_utc=end_utc, + duration_s=duration_s, + status=status, + num_envs=num_envs, + max_iterations=max_iterations, + ) + + +def build_runtime( + *, + startup_time_s: StartupTime, + iteration_times_s: Sequence[float], + collection_fps: Sequence[float], + total_fps: Sequence[float], + steps_per_iteration: int, +) -> Runtime: + """Assemble a :class:`~isaaclab.test.benchmark.schema.Runtime` from raw series. + + Args: + startup_time_s: Per-phase startup wall-clock durations [s]. + iteration_times_s: Per-iteration wall-clock time [s]. + collection_fps: Per-iteration environment-stepping throughput + [frames/s]. + total_fps: Per-iteration end-to-end throughput [frames/s]. + steps_per_iteration: Environment steps collected per iteration. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.Runtime` with + aggregated :class:`~isaaclab.test.benchmark.schema.MeanStd` fields. + """ + iter_times = list(iteration_times_s) + iter_per_s = [1.0 / t for t in iter_times if t > 0] + return Runtime( + startup_time_s=startup_time_s, + iterations_completed=len(iter_times), + total_wall_time_s=float(sum(iter_times)), + steps_per_iteration=steps_per_iteration, + iteration_time_s=mean_std_peak(iter_times), + collection_fps=mean_std_peak(collection_fps), + total_fps=mean_std_peak(total_fps), + iterations_per_s=mean_std_peak(iter_per_s), + ) + + +def build_learning( + *, + reward_series: Sequence[float], + ep_length_series: Sequence[float], + ema_alpha: float, + keep_series: bool = True, +) -> Learning: + """Assemble a :class:`~isaaclab.test.benchmark.schema.Learning` from raw curves. + + Args: + reward_series: Per-iteration mean reward values. + ep_length_series: Per-iteration mean episode-length values. + ema_alpha: EMA smoothing factor in ``[0, 1]``; higher values weight + recent observations more. + keep_series: When ``True`` (default) the full per-iteration series is + embedded in the bundle; set to ``False`` to reduce file size. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.Learning`. + """ + rewards = list(reward_series) + ep_lengths = list(ep_length_series) + + reward_curve = LearningCurve( + final_raw=float(rewards[-1]) if rewards else 0.0, + final_ema=ema(rewards, ema_alpha), + series_per_iter=rewards if keep_series else None, + ) + ep_length_curve = LearningCurve( + final_raw=float(ep_lengths[-1]) if ep_lengths else 0.0, + final_ema=ema(ep_lengths, ema_alpha), + series_per_iter=ep_lengths if keep_series else None, + ) + return Learning(ema_alpha=ema_alpha, reward=reward_curve, ep_length=ep_length_curve) + + +def build_runtime_bundle( + *, + run: RunIdentity, + versions: Versions, + hardware: Hardware, + runtime: Runtime, + resources: Resources, + extra: dict | None = None, +) -> RuntimeBundle: + """Assemble a :class:`~isaaclab.test.benchmark.schema.RuntimeBundle`. + + Args: + run: Run identity metadata. + versions: Software versions snapshot. + hardware: Host hardware snapshot. + runtime: Aggregated runtime metrics. + resources: Aggregated resource-utilisation metrics. + extra: Optional free-form scalar values not covered by the stable + schema. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.RuntimeBundle`. + """ + return RuntimeBundle( + run=run, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + extra=extra, + ) + + +def build_training_bundle( + *, + run: RunIdentity, + versions: Versions, + hardware: Hardware, + runtime: Runtime, + resources: Resources, + learning: Learning, + success_rate: float | None = None, + checkpoint_path: str | None = None, + video_path: str | None = None, + extra: dict | None = None, +) -> TrainingBundle: + """Assemble a :class:`~isaaclab.test.benchmark.schema.TrainingBundle`. + + Args: + run: Run identity metadata. + versions: Software versions snapshot. + hardware: Host hardware snapshot. + runtime: Aggregated runtime metrics. + resources: Aggregated resource-utilisation metrics. + learning: Aggregated learning curves. + success_rate: Final success rate ``[0..1]``, or ``None`` when the task + does not track one. + checkpoint_path: Path to the final saved policy checkpoint, if any. + video_path: Path to a recorded rollout video/gif, if any. + extra: Optional free-form scalar values not covered by the stable + schema. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.TrainingBundle`. + """ + return TrainingBundle( + run=run, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + learning=learning, + success_rate=success_rate, + checkpoint_path=checkpoint_path, + video_path=video_path, + extra=extra, + ) + + +def build_startup_bundle( + *, + run: RunIdentity, + versions: Versions, + hardware: Hardware, + phases: dict[str, StartupPhase], + top_n: int, + whitelist: str | None, + extra: dict | None = None, +) -> StartupBundle: + """Assemble a :class:`~isaaclab.test.benchmark.schema.StartupBundle`. + + Args: + run: Run identity metadata (``framework``, ``num_envs``, and + ``max_iterations`` are typically ``None`` for startup profiles). + versions: Software versions snapshot. + hardware: Host hardware snapshot. + phases: Per-phase timing and cProfile data, keyed by phase name. + top_n: Number of top cProfile functions retained per phase. + whitelist: Optional cProfile name-filter pattern; ``None`` means no + filtering. + extra: Optional free-form scalar values not covered by the stable + schema. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.StartupBundle`. + """ + config = StartupConfig(top_n=top_n, whitelist=whitelist) + return StartupBundle( + run=run, + versions=versions, + hardware=hardware, + phases=phases, + config=config, + extra=extra, + ) diff --git a/source/isaaclab/isaaclab/test/benchmark/capture.py b/source/isaaclab/isaaclab/test/benchmark/capture.py new file mode 100644 index 00000000000..5f93c70f3ef --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/capture.py @@ -0,0 +1,449 @@ +# 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 + +"""Capture helpers for benchmark data extraction. + +Reads recorder data off a ``BaseIsaacLabBenchmark``-like object and maps the +raw measurements and metadata to the typed schema dataclasses +:class:`~isaaclab.test.benchmark.schema.Versions`, +:class:`~isaaclab.test.benchmark.schema.Hardware`, and +:class:`~isaaclab.test.benchmark.schema.Resources`. + +Import-time dependencies stay light: no torch, isaacsim, or RL-library +imports. Preset metadata is imported lazily when run_config_from_presets() +needs it. The benchmark object is accepted at call time; its recorder classes +are never imported here. +""" + +from __future__ import annotations + +import socket +from collections.abc import Sequence +from datetime import datetime, timezone +from typing import Any, get_args + +from isaaclab.test.benchmark.schema import ( + GpuDeviceInfo, + Hardware, + MeanStd, + PhysicsBackend, + Resources, + RunConfig, + Versions, +) + + +def _find_value(measurements: Any, name: str, default: float = 0.0) -> float: + """Scan a measurement list for a :class:`~.measurements.SingleMeasurement` by name. + + Args: + measurements: Sequence of measurement objects, each with ``.name`` and + ``.value`` attributes. + name: Exact measurement name to look up. + default: Value to return when the name is not found. Defaults to ``0.0``. + + Returns: + The ``float`` value of the first matching measurement, or *default*. + """ + if not measurements: + return default + for m in measurements: + if m.name == name: + return float(m.value) + return default + + +def _get_recorder_data(bm: Any, key: str) -> Any | None: + """Safely retrieve :meth:`get_data` output from a named recorder. + + Args: + bm: Benchmark-like object with a ``_manual_recorders`` attribute. + key: Recorder key (e.g. ``"VersionInfo"``). + + Returns: + The :class:`~.interfaces.MeasurementData` returned by the recorder, or + ``None`` when the recorders dict is absent or the key is missing. + """ + recorders = getattr(bm, "_manual_recorders", None) + if recorders is None: + return None + rec = recorders.get(key) + if rec is None: + return None + return rec.get_data() + + +def now_utc_iso() -> str: + """Return the current UTC time as an ISO-8601 string. + + Returns: + ISO-8601 formatted current UTC timestamp. + """ + return datetime.now(timezone.utc).isoformat() + + +def synth_run_id( + framework: str | None, + physics_backend: str, + task: str, + seed: int, + stamp: str, +) -> str: + """Synthesise a stable run identifier from run parameters. + + Args: + framework: RL framework name, or ``None`` for non-learning runs + (substituted with ``"runtime"``). + physics_backend: Physics backend preset string. + task: Gym task id. + seed: Environment/agent seed. + stamp: Timestamp string (e.g. ``"20260612-150000"``). + + Returns: + Underscore-joined run identifier string. + """ + fw = framework or "runtime" + return f"{fw}_{physics_backend}_{task}_{stamp}_seed{seed}" + + +def capture_versions(bm: Any) -> Versions: + """Read software version metadata from a benchmark object. + + Reads ``bm._manual_recorders["VersionInfo"].get_data().metadata`` and maps + the ``*_version`` :class:`~.measurements.StringMetadata` entries plus the + ``dev`` :class:`~.measurements.DictMetadata` to a :class:`~.schema.Versions` + instance. All fields default gracefully when the recorder is absent. + + Args: + bm: Benchmark-like object exposing ``._manual_recorders``. + + Returns: + Populated :class:`~.schema.Versions` dataclass; never raises. + """ + data = _get_recorder_data(bm, "VersionInfo") + if data is None: + return Versions( + isaaclab="unknown", + isaacsim=None, + kit=None, + newton=None, + warp=None, + mjwarp=None, + torch="unknown", + rsl_rl=None, + rl_games=None, + skrl=None, + sb3=None, + git_commit=None, + git_branch=None, + git_dirty=False, + ) + + md = {m.name: m.data for m in data.metadata or []} + dev: dict[str, Any] = md.get("dev") or {} + + return Versions( + isaaclab=md.get("isaaclab_version", "unknown"), + isaacsim=md.get("isaacsim_version"), + kit=md.get("kit_version"), + newton=md.get("newton_version"), + warp=md.get("warp_version"), + mjwarp=md.get("mujoco_warp_version"), + torch=md.get("torch_version", "unknown"), + rsl_rl=md.get("rsl_rl_version"), + rl_games=md.get("rl_games_version"), + skrl=md.get("skrl_version"), + sb3=md.get("stable_baselines3_version"), + git_commit=dev.get("commit_hash"), + git_branch=dev.get("branch"), + git_dirty=dev.get("dirty", False), + numpy=md.get("numpy_version"), + isaaclab_newton=md.get("isaaclab_newton_version"), + isaaclab_physx=md.get("isaaclab_physx_version"), + isaaclab_ov=md.get("isaaclab_ov_version"), + isaaclab_tasks=md.get("isaaclab_tasks_version"), + isaaclab_rl=md.get("isaaclab_rl_version"), + ovrtx=md.get("ovrtx_version"), + ovphysx=md.get("ovphysx_version"), + mujoco=md.get("mujoco_version"), + cuda_bindings=md.get("cuda_bindings_version"), + usd_core=md.get("usd_core_version"), + isaaclab_release=md.get("isaaclab_release_version"), + ) + + +def capture_hardware(bm: Any) -> Hardware: + """Read hardware metadata from a benchmark object. + + Reads GPU, CPU, and memory recorder metadata to populate a + :class:`~.schema.Hardware` instance. All fields default gracefully when + recorders are absent. + + Args: + bm: Benchmark-like object exposing ``._manual_recorders``. + + Returns: + Populated :class:`~.schema.Hardware` dataclass; never raises. + """ + gpu_data = _get_recorder_data(bm, "GPUInfo") + cpu_data = _get_recorder_data(bm, "CPUInfo") + mem_data = _get_recorder_data(bm, "MemoryInfo") + + # GPU devices + gpu_devices: list[GpuDeviceInfo] = [] + if gpu_data is not None: + gpu_md = {m.name: m.data for m in gpu_data.metadata or []} + raw_devices: dict[str, Any] = gpu_md.get("gpu_devices") or {} + for idx_str in sorted(raw_devices.keys(), key=lambda k: int(k)): + d = raw_devices[idx_str] + gpu_devices.append( + GpuDeviceInfo( + name=d["name"], + mem_gb=float(d["total_memory_gb"]), + compute_cap=str(d["compute_capability"]), + ) + ) + + # CPU + cpu_name = "unknown" + cpu_count = 0 + if cpu_data is not None: + cpu_md = {m.name: m.data for m in cpu_data.metadata or []} + cpu_name = cpu_md.get("cpu_name", "unknown") + cpu_count = int(cpu_md.get("physical_cores", 0)) + + # RAM + ram_gb = 0.0 + if mem_data is not None: + mem_md = {m.name: m.data for m in mem_data.metadata or []} + ram_gb = float(mem_md.get("total_ram_gb", 0.0)) + + return Hardware( + hostname=socket.gethostname(), + gpu_devices=gpu_devices, + cpu_name=cpu_name, + cpu_count=cpu_count, + ram_gb=ram_gb, + ) + + +def _preset_target_metadata() -> tuple[str, str, str, dict[str, str]]: + """Return preset selector labels and physics aliases from the preset CLI layer.""" + from isaaclab_tasks.utils.preset_target import PresetTarget + + return ( + PresetTarget.PHYSICS.value, + PresetTarget.RENDERER.value, + PresetTarget.DOMAIN.value, + dict(PresetTarget.PHYSICS.legacy_aliases), + ) + + +def _physics_backend_names() -> set[str]: + """Return physics backend names accepted by the benchmark schema.""" + return set(get_args(PhysicsBackend)) + + +def _rendering_backend_by_preset() -> dict[str, str]: + """Return renderer preset names mapped to benchmark rendering backend names.""" + try: + from isaaclab_tasks.utils.hydra import _preset_fields + from isaaclab_tasks.utils.presets import MultiBackendRendererCfg + except ImportError: + return {} + + return { + name: name.removesuffix("_renderer") for name in _preset_fields(MultiBackendRendererCfg()) if name != "default" + } + + +def _backend_defaults_from_env_cfg(env_cfg: object) -> tuple[str | None, str | None]: + """Return active backend names from a resolved environment configuration.""" + physics_cfg = getattr(getattr(env_cfg, "sim", None), "physics", None) + physics_descriptor = ( + f"{type(physics_cfg).__module__}.{type(physics_cfg).__name__} {getattr(physics_cfg, 'class_type', '')}" + ).lower() + physics = next( + ( + name + for marker, name in ( + ("ovphysx", "ovphysx"), + ("kamino", "newton_kamino"), + ("mjwarp", "newton_mjwarp"), + ("physx", "physx"), + ) + if marker in physics_descriptor + ), + None, + ) + + renderer_names = {"isaac_rtx": "isaacsim_rtx", "ovrtx": "ovrtx", "newton_warp": "newton"} + rendering = None + stack = [env_cfg] + visited: set[int] = set() + while stack and rendering is None: + node = stack.pop() + if id(node) in visited: + continue + visited.add(id(node)) + rendering = renderer_names.get(getattr(node, "renderer_type", None)) + if isinstance(node, dict): + children = node.values() + elif isinstance(node, (list, tuple)): + children = node + else: + try: + children = vars(node).values() + except TypeError: + continue + stack.extend( + child + for child in children + if child is not None and not isinstance(child, (str, bytes, int, float, bool, type)) + ) + return physics, rendering + + +def _expand_preset_tokens(tokens: Sequence[str]) -> list[tuple[str | None, str]]: + """Expand Hydra-style preset tokens into ``(selector, value)`` pairs.""" + physics_label, renderer_label, domain_label, _ = _preset_target_metadata() + expanded: list[tuple[str | None, str]] = [] + for token in tokens: + selector, has_value, raw_value = token.partition("=") + if not has_value: + value = token.strip() + if value: + expanded.append((None, value)) + continue + + selector = selector.strip() + if selector == domain_label: + expanded.extend((selector, value) for value in (v.strip() for v in raw_value.split(",")) if value) + elif selector in (physics_label, renderer_label): + value = raw_value.strip() + if value: + expanded.append((selector, value)) + else: + value = token.strip() + if value: + expanded.append((None, value)) + return expanded + + +def run_config_from_presets(tokens: Sequence[str], *, env_cfg: object | None = None) -> RunConfig: + """Build a :class:`~isaaclab.test.benchmark.RunConfig` from presets and resolved task config. + + Picks backend defaults from ``env_cfg`` when provided, then applies recognised + preset tokens. Without a resolved config, physics defaults to ``"physx"`` and + rendering to ``"none"``. Accepts bare preset names as well as Hydra-style + ``physics=...``, ``renderer=...``, and ``presets=...`` tokens. + + Args: + tokens: Active preset tokens (e.g. ``["newton_mjwarp", "rgb"]``). + env_cfg: Optional resolved task environment configuration. Its active physics + and renderer configurations take precedence over token inference. + + Returns: + Populated :class:`~isaaclab.test.benchmark.RunConfig`. + """ + if not tokens and env_cfg is None: + return RunConfig(physics_backend="physx", rendering_backend="none", presets=[]) + + physics = "physx" + rendering = "none" + expanded_tokens = _expand_preset_tokens(tokens) + physics_label, renderer_label, _, physics_aliases = _preset_target_metadata() + physics_backends = _physics_backend_names() + rendering_backends: dict[str, str] | None = None + + def rendering_backend_for(preset_name: str) -> str | None: + nonlocal rendering_backends + if rendering_backends is None: + rendering_backends = _rendering_backend_by_preset() + if preset_name in rendering_backends: + return rendering_backends[preset_name] + if preset_name.endswith("_renderer"): + return preset_name.removesuffix("_renderer") + return None + + for selector, token in expanded_tokens: + if selector == physics_label: + physics = physics_aliases.get(token, token) + elif selector == renderer_label: + rendering = rendering_backend_for(token) or token + else: + canonical = physics_aliases.get(token, token) + if canonical in physics_backends: + physics = canonical + elif token.endswith("_renderer"): + renderer = rendering_backend_for(token) + if renderer is not None: + rendering = renderer + + if env_cfg is not None: + active_physics, active_rendering = _backend_defaults_from_env_cfg(env_cfg) + physics = active_physics or physics + rendering = active_rendering or rendering + + return RunConfig( + physics_backend=physics, + rendering_backend=rendering, + presets=[token for _, token in expanded_tokens], + ) + + +def capture_resources(bm: Any) -> Resources: + """Read resource-utilisation measurements from a benchmark object. + + Reads GPU utilisation/memory, CPU utilisation, and system RAM from the + corresponding recorders and maps them to :class:`~.schema.Resources`. + + Utilisation fields leave ``peak`` as ``None``; memory fields populate it. + + Args: + bm: Benchmark-like object exposing ``._manual_recorders``. + + Returns: + Populated :class:`~.schema.Resources` dataclass; never raises. + """ + gpu_data = _get_recorder_data(bm, "GPUInfo") + cpu_data = _get_recorder_data(bm, "CPUInfo") + mem_data = _get_recorder_data(bm, "MemoryInfo") + + # --- GPU --- + gpu_meas = gpu_data.measurements if gpu_data is not None else [] + gpu_metadata = {m.name: m.data for m in gpu_data.metadata or []} if gpu_data is not None else {} + gpu_prefix = ( + f"GPU {gpu_metadata.get('gpu_current_device', 0)} " if gpu_metadata.get("gpu_device_count", 1) > 1 else "GPU " + ) + + gpu_util_mean = _find_value(gpu_meas, f"{gpu_prefix}Utilization") + gpu_util_std = _find_value(gpu_meas, f"{gpu_prefix}Utilization std") + + gpu_mem_mean = _find_value(gpu_meas, f"{gpu_prefix}Memory Used") + gpu_mem_std = _find_value(gpu_meas, f"{gpu_prefix}Memory Used std") + _gpu_mem_peak_raw = _find_value(gpu_meas, f"{gpu_prefix}Memory Used peak", default=0.0) + gpu_mem_peak = max(gpu_mem_mean, _gpu_mem_peak_raw) + + # --- CPU --- + cpu_meas = cpu_data.measurements if cpu_data is not None else [] + + cpu_util_mean = _find_value(cpu_meas, "CPU Utilization") + cpu_util_std = _find_value(cpu_meas, "CPU Utilization std") + + # --- Memory --- + mem_meas = mem_data.measurements if mem_data is not None else [] + + ram_mean = _find_value(mem_meas, "System Memory RSS") + ram_std = _find_value(mem_meas, "System Memory RSS std") + _ram_peak_raw = _find_value(mem_meas, "System Memory RSS peak", default=0.0) + ram_peak = max(ram_mean, _ram_peak_raw) + + return Resources( + gpu_util_pct=MeanStd(mean=gpu_util_mean, std=gpu_util_std, peak=None), + gpu_mem_gb=MeanStd(mean=gpu_mem_mean, std=gpu_mem_std, peak=gpu_mem_peak), + cpu_util_pct=MeanStd(mean=cpu_util_mean, std=cpu_util_std, peak=None), + ram_gb=MeanStd(mean=ram_mean, std=ram_std, peak=ram_peak), + ) diff --git a/source/isaaclab/isaaclab/test/benchmark/backends.py b/source/isaaclab/isaaclab/test/benchmark/formatters.py similarity index 81% rename from source/isaaclab/isaaclab/test/benchmark/backends.py rename to source/isaaclab/isaaclab/test/benchmark/formatters.py index 6b6c0acacb1..201892cf7a5 100644 --- a/source/isaaclab/isaaclab/test/benchmark/backends.py +++ b/source/isaaclab/isaaclab/test/benchmark/formatters.py @@ -10,9 +10,12 @@ import textwrap from abc import ABC, abstractmethod from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any -from .measurements import SingleMeasurement, StatisticalMeasurement, TestPhase, TestPhaseEncoder +from isaaclab.test.benchmark.measurements import SingleMeasurement, StatisticalMeasurement, TestPhase, TestPhaseEncoder + +if TYPE_CHECKING: + from isaaclab.test.benchmark.schema import RuntimeBundle, StartupBundle, TrainingBundle logger = logging.getLogger(__name__) @@ -30,8 +33,8 @@ def get_default_output_filename(prefix: str = "benchmark") -> str: return f"{prefix}_{datetime_str}" -class MetricsBackendInterface(ABC): - """Abstract base class for metrics backends.""" +class MetricsFormatterInterface(ABC): + """Abstract base class for metrics Formatters.""" @abstractmethod def add_metrics(self, test_phase: TestPhase) -> None: @@ -48,48 +51,49 @@ def finalize(self, output_path: str, **kwargs) -> None: Args: output_path: Path to write output file(s). - **kwargs: Additional backend-specific options. + **kwargs: Additional formatter-specific options. """ pass -class MetricsBackend: - """Factory for creating metrics backend instances.""" +class MetricsFormatter: + """Factory for creating metrics formatter instances.""" - _instances: dict[str, MetricsBackendInterface] = {} + _instances: dict[str, MetricsFormatterInterface] = {} @classmethod - def get_instance(cls, instance_type: str) -> MetricsBackendInterface: - """Get or create a backend instance by type name. + def get_instance(cls, instance_type: str) -> MetricsFormatterInterface: + """Get or create a formatter instance by type name. Args: - instance_type: Type of backend to create ("json", "osmo", or "omniperf"). + instance_type: Type of formatter to create ("json", "osmo", "omniperf", "summary", or "schema"). Returns: - Backend instance of the requested type. + Formatter instance of the requested type. Raises: ValueError: If the instance_type is not recognized. """ if instance_type not in cls._instances: - backend_map = { + formatter_map = { "json": JSONFileMetrics, "osmo": OsmoKPIFile, "omniperf": OmniPerfKPIFile, "summary": SummaryMetrics, + "schema": SchemaBundleFile, } - if instance_type not in backend_map: - raise ValueError(f"Unknown backend type: {instance_type}. Available: {list(backend_map.keys())}") - cls._instances[instance_type] = backend_map[instance_type]() + if instance_type not in formatter_map: + raise ValueError(f"Unknown formatter type: {instance_type}. Available: {list(formatter_map.keys())}") + cls._instances[instance_type] = formatter_map[instance_type]() return cls._instances[instance_type] @classmethod def reset_instances(cls) -> None: - """Reset all cached backend instances. Useful for testing.""" + """Reset all cached formatter instances. Useful for testing.""" cls._instances.clear() -class JSONFileMetrics(MetricsBackendInterface): +class JSONFileMetrics(MetricsFormatterInterface): """Write metrics to a JSON file at the end of a session.""" def __init__(self) -> None: @@ -106,7 +110,7 @@ def add_metrics(self, test_phase: TestPhase) -> None: .. code-block:: python - backend.add_metrics(test_phase) + formatter.add_metrics(test_phase) """ self.data.append(copy.deepcopy(test_phase)) @@ -116,13 +120,13 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: Args: output_path: Output path in which metrics file will be stored. output_filename: Output filename. - **kwargs: Additional backend-specific options. + **kwargs: Additional formatter-specific options. Example: .. code-block:: python - backend.finalize("/tmp/metrics", "metrics") + formatter.finalize("/tmp/metrics", "metrics") """ if not self.data: logger.warning("No test data to write. Skipping metrics file generation.") @@ -157,23 +161,23 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: self.data.clear() -class SummaryMetrics(MetricsBackendInterface): +class SummaryMetrics(MetricsFormatterInterface): """Print a human-readable summary and write JSON metrics.""" def __init__(self) -> None: - """Initialize internal phase storage and JSON backend.""" + """Initialize internal phase storage and JSON formatter.""" self._phases: list[TestPhase] = [] - self._json_backend = JSONFileMetrics() + self._json_formatter = JSONFileMetrics() self._report_width = 86 def add_metrics(self, test_phase: TestPhase) -> None: - """Add metrics from a test phase; store for summary and forward to JSON backend. + """Add metrics from a test phase; store for summary and forward to JSON formatter. Args: test_phase: Test phase containing measurements and metadata. """ self._phases.append(copy.deepcopy(test_phase)) - self._json_backend.add_metrics(test_phase) + self._json_formatter.add_metrics(test_phase) def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: """Write JSON output and print human-readable summary to console. @@ -181,9 +185,9 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: Args: output_path: Path to write output file(s). output_filename: Base filename for the JSON file. - **kwargs: Additional options passed to the JSON backend. + **kwargs: Additional options passed to the JSON formatter. """ - self._json_backend.finalize(output_path, output_filename, **kwargs) + self._json_formatter.finalize(output_path, output_filename, **kwargs) if self._phases: self._print_summary() self._phases.clear() @@ -394,7 +398,7 @@ def _get_single_measurement(self, phase: TestPhase, name: str) -> SingleMeasurem return None def _summarize_runtime_metrics(self, measurements: list) -> list[str]: - """Build min/mean/max summary rows from SingleMeasurement runtime metrics. + """Build summary rows from scalar runtime statistics. Args: measurements: List of measurements (typically from the runtime phase). @@ -424,6 +428,10 @@ def _summarize_runtime_metrics(self, measurements: list) -> list[str]: base = name[len("Mean ") :] series.setdefault(base, {})["mean"] = float(value) units.setdefault(base, unit) + elif name.startswith("Std "): + base = name[len("Std ") :] + series.setdefault(base, {})["std"] = float(value) + units.setdefault(base, unit) category_order = ["Collection", "Learning", "Step Times", "Throughput", "Other"] categorized: dict[str, list[str]] = {key: [] for key in category_order} @@ -431,10 +439,11 @@ def _summarize_runtime_metrics(self, measurements: list) -> list[str]: raw_unit = units.get(base) unit = (raw_unit or "").strip() if isinstance(raw_unit, str) else "" unit_suffix = f" {unit}" if unit else "" - min_val = self._format_scalar(stats.get("min", 0.0)) - mean_val = self._format_scalar(stats.get("mean", 0.0)) - max_val = self._format_scalar(stats.get("max", 0.0)) - row = f"{base} (min/mean/max): {min_val} / {mean_val} / {max_val}{unit_suffix}" + statistic_order = ("min", "mean", "std", "max") + available = [statistic for statistic in statistic_order if statistic in stats] + values = " / ".join(self._format_scalar(stats[statistic]) for statistic in available) + labels = "/".join(available) + row = f"{base} ({labels}): {values}{unit_suffix}" if "Collection" in base: categorized["Collection"].append(row) @@ -485,7 +494,7 @@ def _format_scalar(self, value: float | int) -> str: return str(value) -class OsmoKPIFile(MetricsBackendInterface): +class OsmoKPIFile(MetricsFormatterInterface): """Write per-phase KPI documents for Osmo ingestion. Only SingleMeasurement metrics and metadata are written as key-value pairs. @@ -504,27 +513,28 @@ def add_metrics(self, test_phase: TestPhase) -> None: .. code-block:: python - backend.add_metrics(test_phase) + formatter.add_metrics(test_phase) """ self._test_phases.append(test_phase) def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: """Write metrics to output file(s). - Each test phase's SingleMeasurement metrics and metadata are written to an output JSON file, at path - `[output_path]/[output_filename].json`. + A single phase is written to ``[output_path]/[output_filename].json``. Multiple phases + are written separately with the phase name appended to each filename. Args: output_path: Output path in which metrics files will be stored. output_filename: Output filename. - **kwargs: Additional backend-specific options. + **kwargs: Additional formatter-specific options. Example: .. code-block:: python - backend.finalize("/tmp/metrics", "kpis") + formatter.finalize("/tmp/metrics", "kpis") """ + multi_phase = len(self._test_phases) > 1 for test_phase in self._test_phases: # Retrieve useful metadata from test_phase phase_name = test_phase.get_metadata_field("phase") @@ -540,16 +550,17 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: if isinstance(measurement, SingleMeasurement): osmo_kpis[measurement.name] = measurement.value log_statements.append(f"{measurement.name}: {measurement.value} {measurement.unit}") - # Generate the output filename with timestamp - metrics_path = os.path.join(output_path, f"{output_filename}.json") + filename = f"{output_filename}_{phase_name}" if multi_phase else output_filename + metrics_path = os.path.join(output_path, f"{filename}.json") # Dump key-value pairs (fields) to the JSON document json_data = json.dumps(osmo_kpis, indent=4) with open(metrics_path, "w") as f: f.write(json_data) print(f"Results written to: {metrics_path}") + self._test_phases.clear() -class OmniPerfKPIFile(MetricsBackendInterface): +class OmniPerfKPIFile(MetricsFormatterInterface): """Write KPI metrics for upload to a PostgreSQL database.""" def __init__(self) -> None: @@ -565,7 +576,7 @@ def add_metrics(self, test_phase: TestPhase) -> None: .. code-block:: python - backend.add_metrics(test_phase) + formatter.add_metrics(test_phase) """ self._test_phases.append(test_phase) @@ -578,13 +589,13 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: Args: output_path: Output path in which metrics file will be stored. output_filename: Output filename. - **kwargs: Additional backend-specific options. + **kwargs: Additional formatter-specific options. Example: .. code-block:: python - backend.finalize("/tmp/metrics", "omniperf") + formatter.finalize("/tmp/metrics", "omniperf") """ if not self._test_phases: logger.warning("No test phases to write. Skipping metrics file generation.") @@ -623,3 +634,52 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: with open(metrics_path, "w") as f: f.write(json_data) print(f"Results written to: {metrics_path}") + self._test_phases.clear() + + +class SchemaBundleFile(MetricsFormatterInterface): + """Serialize a typed benchmark bundle to schema-v1 JSON. + + Unlike the other formatters, this one does not consume the flat measurement + phases collected during a run. Instead it serializes the typed bundle + attached via :meth:`~isaaclab.test.benchmark.benchmark_core.BaseIsaacLabBenchmark.attach_bundle`. + """ + + def add_metrics(self, test_phase: TestPhase) -> None: + """Ignore the provided test phase. + + This formatter serializes the typed bundle attached via + :meth:`~isaaclab.test.benchmark.benchmark_core.BaseIsaacLabBenchmark.attach_bundle`, + not the flat measurement phases, so accumulated phases are ignored by design. + + Args: + test_phase: Test phase to ignore. + """ + pass + + def finalize( + self, + output_path: str, + output_filename: str, + bundle: "RuntimeBundle | TrainingBundle | StartupBundle | None" = None, + **kwargs, + ) -> None: + """Write the attached bundle to a schema-v1 JSON file. + + Args: + output_path: Output path in which the schema file will be stored. + output_filename: Output filename (without extension). + bundle: Typed benchmark bundle to serialize. When ``None``, no file + is written. + **kwargs: Additional formatter-specific options (ignored). + """ + if bundle is None: + logger.warning("SchemaBundleFile selected but no bundle was attached; skipping schema file.") + return + + # Lazy import keeps formatters.py free of the schema layer at module import time. + from isaaclab.test.benchmark.serialize import write_bundle_file + + path = os.path.join(output_path, f"{output_filename}.json") + write_bundle_file(bundle, path) + logger.info("Wrote schema bundle to %s", path) diff --git a/source/isaaclab/isaaclab/test/benchmark/measurements.py b/source/isaaclab/isaaclab/test/benchmark/measurements.py index 54c14b12061..5004a694277 100644 --- a/source/isaaclab/isaaclab/test/benchmark/measurements.py +++ b/source/isaaclab/isaaclab/test/benchmark/measurements.py @@ -140,7 +140,7 @@ class StringMetadata(MetadataBase): type: Metadata type label. Defaults to "string". """ - data: str + data: str | None type: str = "string" diff --git a/source/isaaclab/isaaclab/test/benchmark/metrics.py b/source/isaaclab/isaaclab/test/benchmark/metrics.py new file mode 100644 index 00000000000..368fd9dd551 --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/metrics.py @@ -0,0 +1,278 @@ +# 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 + +"""Isaac-Sim-free metric helpers for benchmark bundles: TensorBoard parsing, convergence, EMA, MeanStd aggregation, +and success-rate tracking.""" + +from __future__ import annotations + +import glob +import logging +import os +import statistics +from collections.abc import Sequence +from dataclasses import dataclass + +from tensorboard.backend.event_processing import event_accumulator + +from isaaclab.test.benchmark.schema import Framework, MeanStd + +SUCCESS_RATE_LOG_TAGS = ("Metrics/success_rate", "Episode/Metrics/success_rate") + + +@dataclass(frozen=True) +class RLLibraryDescriptor: + """TensorBoard locations and tags for one RL library benchmark integration. + + Attributes: + framework: Schema framework id. + tfevents_pattern: Glob relative to the run log directory matching TensorBoard event files. + reward_tag: TensorBoard scalar tag for mean reward per iteration. + ep_length_tag: TensorBoard scalar tag for mean episode length per iteration. + """ + + framework: Framework + tfevents_pattern: str + reward_tag: str + ep_length_tag: str + + +RL_LIBRARY_DESCRIPTORS: dict[Framework, RLLibraryDescriptor] = { + "rsl_rl": RLLibraryDescriptor( + framework="rsl_rl", + tfevents_pattern="events*", + reward_tag="Train/mean_reward", + ep_length_tag="Train/mean_episode_length", + ), + "rl_games": RLLibraryDescriptor( + framework="rl_games", + tfevents_pattern="summaries/events*", + reward_tag="rewards/iter", + ep_length_tag="episode_lengths/iter", + ), + "skrl": RLLibraryDescriptor( + framework="skrl", + tfevents_pattern="events*", + reward_tag="Reward / Total reward (mean)", + ep_length_tag="Episode / Total timesteps (mean)", + ), + "sb3": RLLibraryDescriptor( + framework="sb3", + tfevents_pattern="PPO_*/events*", + reward_tag="rollout/ep_rew_mean", + ep_length_tag="rollout/ep_len_mean", + ), +} + + +def parse_tf_logs(log_dir: str, pattern: str = "events*") -> dict[str, list[float]]: + """Load the latest TensorBoard events file under *log_dir* into ``{tag: [values]}``. + + Args: + log_dir: Directory (glob root) to search for event files. + pattern: Glob relative to *log_dir* matching the event files, e.g. + ``"events*"`` (root), ``"summaries/events*"`` (rl_games), or + ``"PPO_*/events*"`` (sb3). + + Returns: + Mapping of each scalar tag to its per-iteration value list; empty when no event file matched. + """ + list_of_files = glob.glob(os.path.join(log_dir, pattern)) + if not list_of_files: + logging.getLogger(__name__).warning( + "No TensorBoard event files matched %r under %r; returning empty log data.", pattern, log_dir + ) + return {} + latest_file = max(list_of_files, key=os.path.getmtime) + ea = event_accumulator.EventAccumulator(latest_file) + ea.Reload() + log_data: dict[str, list[float]] = {} + for tag in ea.Tags()["scalars"]: + log_data[tag] = [event.value for event in ea.Scalars(tag)] + return log_data + + +def get_success_rate_log(log_data: dict[str, list[float]]) -> list[float] | None: + """Return the per-iteration success-rate series from parsed TensorBoard *log_data*. + + Looks up the first present tag from :data:`SUCCESS_RATE_LOG_TAGS` in *log_data* (the + ``{tag: [values]}`` mapping produced by :func:`parse_tf_logs`) and returns its value + series, or ``None`` when no success tag was logged. + + Args: + log_data: Parsed TensorBoard scalars mapping each tag to its per-iteration list. + + Returns: + The per-iteration success-rate values, or ``None`` if no success tag is present. + """ + for tag in SUCCESS_RATE_LOG_TAGS: + if tag in log_data: + return log_data[tag] + return None + + +def success_rate_step_value(extras_log: dict) -> float | None: + """Return the scalar success-rate value for one env step from a live ``extras['log']`` dict. + + Unlike :func:`get_success_rate_log` (which returns a per-iteration *series* from parsed + TensorBoard logs), this reads the single scalar logged per step in the live environment + ``extras['log']`` mapping, coercing a tensor to ``float`` via ``.item()``. + + Args: + extras_log: The ``extras['log']`` mapping from one environment step. + + Returns: + The step's success-rate value as a ``float``, or ``None`` if no success tag is present. + """ + for tag in SUCCESS_RATE_LOG_TAGS: + if tag in extras_log: + val = extras_log[tag] + return float(val.item()) if hasattr(val, "item") else float(val) + return None + + +def check_convergence( + rewards: list[float], + threshold: float, + window_pct: float = 0.2, + cv_threshold: float = 20.0, +) -> dict[str, float | bool]: + """Check whether training rewards have converged. + + Passes when the trailing window mean exceeds *threshold* and the + coefficient of variation (CV) is below *cv_threshold*. + + Args: + rewards: Per-iteration mean reward values. + threshold: Minimum reward to pass. + window_pct: Fraction of iterations for the trailing window. + cv_threshold: Maximum CV (%) for stable convergence. + + Returns: + Dict with ``tail_mean``, ``cv``, and ``passed``. + """ + if not rewards: + return {"tail_mean": 0.0, "cv": 999.9, "passed": False} + window = max(1, int(len(rewards) * window_pct)) + tail = rewards[-window:] + tail_mean = statistics.mean(tail) + tail_std = statistics.stdev(tail) if len(tail) > 1 else 0.0 + cv = (tail_std / abs(tail_mean) * 100) if tail_mean != 0 else 999.9 + passed = tail_mean >= threshold and cv <= cv_threshold + return {"tail_mean": round(tail_mean, 2), "cv": round(cv, 1), "passed": passed} + + +class SuccessRateTracker: + """Accumulates a per-iteration success-rate metric and checks trailing-window convergence. + + Args: + threshold: Minimum value to consider a pass. + window: Consecutive iterations above *threshold* to trigger convergence. + num_steps_per_env: Steps per RL iteration (for boundary detection). + """ + + def __init__(self, threshold: float, window: int, num_steps_per_env: int): + self.threshold = threshold + self.window = window + self.num_steps_per_env = num_steps_per_env + + self.history: list[float] = [] + self._step_count = 0 + self._iter_sum = 0.0 + self._iter_count = 0 + + def record_step(self, extras: dict) -> None: + """Record one env step.""" + val = success_rate_step_value(extras.get("log", {})) + if val is not None: + self._iter_sum += val + self._iter_count += 1 + self._step_count += 1 + + def end_iteration(self) -> float | None: + """Finalize the current iteration. Returns mean metric, or ``None`` if no data.""" + if self._iter_count == 0: + return None + mean = self._iter_sum / self._iter_count + self.history.append(mean) + self._iter_sum = 0.0 + self._iter_count = 0 + return mean + + @property + def at_iteration_boundary(self) -> bool: + """Whether the tracker has seen exactly a full iteration's worth of steps. + + Assumes :meth:`record_step` is called exactly once per env step. This property is + used only by the rsl_rl wrapper (whose patched ``env.step`` calls + :meth:`record_step` once per env step); the rl_games observer ends iterations + directly via ``after_steps`` and does not use this property. + Integrations that call :meth:`record_step` more or fewer times per env step will + break iteration accounting. + """ + return self.num_steps_per_env > 0 and self._step_count % self.num_steps_per_env == 0 + + @property + def converged(self) -> bool: + if len(self.history) < self.window: + return False + return all(v >= self.threshold for v in self.history[-self.window :]) + + @property + def current_iteration(self) -> int: + return len(self.history) + + @property + def tail_mean(self) -> float: + if not self.history: + return 0.0 + tail = self.history[-self.window :] if len(self.history) >= self.window else self.history + return statistics.mean(tail) + + +def mean_std_peak(values: Sequence[float]) -> MeanStd: + """Aggregate *values* into a :class:`MeanStd` with ``peak`` = max. Empty -> all zeros. + + Args: + values: Per-sample values [same unit as the field being aggregated]. + """ + vals = list(values) + if not vals: + return MeanStd(mean=0.0, std=0.0, peak=0.0) + mean = statistics.mean(vals) + std = statistics.stdev(vals) if len(vals) > 1 else 0.0 + return MeanStd(mean=mean, std=std, peak=max(vals)) + + +def mean_std(values: Sequence[float]) -> MeanStd: + """Aggregate *values* into a :class:`MeanStd` with ``peak`` omitted (``None``). + + Use for quantities where a peak is uninformative (e.g. utilisation). Empty -> zeros. + + Args: + values: Per-sample values. + """ + vals = list(values) + if not vals: + return MeanStd(mean=0.0, std=0.0, peak=None) + mean = statistics.mean(vals) + std = statistics.stdev(vals) if len(vals) > 1 else 0.0 + return MeanStd(mean=mean, std=std, peak=None) + + +def ema(series: Sequence[float], alpha: float) -> float: + """Exponential-moving-average final value of *series* (``e_0 = x_0``). Empty -> ``0.0``. + + Args: + series: Per-iteration values. + alpha: Smoothing factor in [0, 1]; higher weights recent values more. + """ + vals = list(series) + if not vals: + return 0.0 + e = float(vals[0]) + for x in vals[1:]: + e = alpha * float(x) + (1.0 - alpha) * e + return e diff --git a/source/isaaclab/isaaclab/test/benchmark/profiling.py b/source/isaaclab/isaaclab/test/benchmark/profiling.py new file mode 100644 index 00000000000..d108dcfe3ba --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/profiling.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 + +"""Isaac-Sim-free cProfile parsing for startup benchmarks. + +Filter to IsaacLab + first-level external calls and return per-function +own/cum time and call counts. +""" + +from __future__ import annotations + +import cProfile +import fnmatch +import io +import logging +import os +import pstats + +logger = logging.getLogger(__name__) + + +def parse_cprofile_stats( + profile: cProfile.Profile, + isaaclab_prefixes: list[str], + top_n: int = 30, + whitelist: list[str] | None = None, +) -> list[tuple[str, float, float, int]]: + """Parse cProfile stats, filtering to IsaacLab + first-level external calls. + + Walks the pstats data and keeps functions that are either (a) inside an + IsaacLab source directory, or (b) directly called by an IsaacLab function. + Results are sorted by own-time (tottime) descending. + + When *whitelist* is provided, only functions whose labels match at least one + ``fnmatch`` pattern are returned. Patterns that match no profiled function + emit a ``(pattern, 0.0, 0.0, 0)`` placeholder so dashboards always receive + consistent keys. The *top_n* parameter is ignored in whitelist mode. + + Args: + profile: A completed cProfile.Profile instance (after .disable()). + isaaclab_prefixes: Absolute file path prefixes identifying IsaacLab source + (e.g. ["/home/user/IsaacLab/source/isaaclab", ...]). + top_n: Maximum number of functions to return. Ignored when + *whitelist* is provided. + whitelist: Optional list of ``fnmatch`` patterns to select specific + functions (e.g. ``["isaaclab.cloner.*:usd_replicate"]``). + + Returns: + List of (label, tottime_ms, cumtime_ms, ncalls) tuples sorted by + tottime descending. + """ + stats = pstats.Stats(profile, stream=io.StringIO()) + + def _is_isaaclab(filename: str) -> bool: + return any(filename.startswith(prefix) for prefix in isaaclab_prefixes) + + def _make_label(filename: str, funcname: str) -> str: + # For builtins/C-extensions the filename is something like "~" or "" + if not filename or filename.startswith("<") or filename == "~": + return funcname + # Convert absolute path to dotted module-style label + for prefix in sorted(isaaclab_prefixes, key=len, reverse=True): + if filename.startswith(prefix): + rel = os.path.relpath(filename, prefix) + # Strip .py, replace os.sep with dot + rel = rel.replace(os.sep, ".").removesuffix(".py") + return f"{rel}:{funcname}" + # External function — try to find the top-level package name + # e.g. ".../site-packages/torch/nn/modules/linear.py" -> "torch.nn.modules.linear" + parts = filename.replace(os.sep, "/").removesuffix(".py").split("/") + # Find "site-packages" anchor or fall back to last 3 components + try: + sp_idx = parts.index("site-packages") + short = ".".join(parts[sp_idx + 1 :]) + except ValueError: + short = ".".join(parts[-3:]) if len(parts) >= 3 else ".".join(parts) + return f"{short}:{funcname}" + + # NOTE: stats.stats is an internal CPython dict, not part of the public pstats API. + # The public get_stats_profile() (Python 3.9+) doesn't expose caller info, which + # we need for the first-level external call filter. If a future Python release + # breaks this, switch to get_stats_profile() and drop the caller-based filtering. + results = [] + for func_key, (_pcalls, ncalls, tottime, cumtime, callers) in stats.stats.items(): + filename, _, funcname = func_key + if _is_isaaclab(filename): + label = _make_label(filename, funcname) + results.append((label, tottime * 1000.0, cumtime * 1000.0, ncalls)) + else: + # Check if any direct caller is an IsaacLab function + for caller_key in callers: + caller_filename = caller_key[0] + if _is_isaaclab(caller_filename): + label = _make_label(filename, funcname) + results.append((label, tottime * 1000.0, cumtime * 1000.0, ncalls)) + break + + # Sort by tottime (own-time) descending + results.sort(key=lambda x: x[1], reverse=True) + + if whitelist is None: + return results[:top_n] + + # Whitelist mode: filter by fnmatch patterns, emit placeholders for unmatched patterns + matched: dict[str, tuple[str, float, float, int]] = {} + matched_patterns: set[str] = set() + for label, tottime, cumtime, ncalls in results: + for pattern in whitelist: + if fnmatch.fnmatch(label, pattern): + if label not in matched: + matched[label] = (label, tottime, cumtime, ncalls) + matched_patterns.add(pattern) + + # Add 0 placeholders for patterns that matched nothing + for pattern in whitelist: + if pattern not in matched_patterns: + logger.warning( + "Whitelist pattern '%s' matched no profiled functions. " + "Check for typos or verify the function ran during this phase.", + pattern, + ) + matched[pattern] = (pattern, 0.0, 0.0, 0) + + filtered = list(matched.values()) + filtered.sort(key=lambda x: x[1], reverse=True) + return filtered diff --git a/source/isaaclab/isaaclab/test/benchmark/recorders/record_version_info.py b/source/isaaclab/isaaclab/test/benchmark/recorders/record_version_info.py index a4396371b3d..66ca064554a 100644 --- a/source/isaaclab/isaaclab/test/benchmark/recorders/record_version_info.py +++ b/source/isaaclab/isaaclab/test/benchmark/recorders/record_version_info.py @@ -6,6 +6,7 @@ import importlib.metadata import os import subprocess +import sys from isaaclab.test.benchmark.interfaces import MeasurementData, MeasurementDataRecorder from isaaclab.test.benchmark.measurements import DictMetadata, StringMetadata @@ -16,7 +17,7 @@ class VersionInfoRecorder(MeasurementDataRecorder): def __init__(self): - self._version_info = {} + self._version_info: dict[str, str | None] = {} self._dev_info = {} self._get_version_info() self._get_git_info() @@ -47,9 +48,45 @@ def _get_pkg_version(self, pip_name: str) -> str | None: except Exception: return None - def _record(self, key: str, version: str | None) -> None: - """Store a version entry only if version is non-empty.""" - if version: + def _get_kit_version(self) -> str | None: + """Get the version from the active Kit application.""" + app_module = sys.modules.get("omni.kit.app") + if app_module is None: + return None + try: + app = app_module.get_app() + get_version = getattr(app, "get_kit_version", None) + if not callable(get_version): + get_version = getattr(app, "get_build_version", None) + return str(get_version()) if callable(get_version) else None + except Exception: + return None + + def _get_isaacsim_version(self) -> str | None: + """Get the Isaac Sim version from an install or active Kit runtime.""" + try: + with open(os.path.join(os.environ["ISAAC_PATH"], "VERSION")) as file: + return file.read().strip() + except Exception: + pass + try: + from isaacsim.core.version import get_version + + core, prerelease, _, _, _, _, _, buildtag = get_version() + if core: + version = str(core) + if prerelease: + version += f"-{prerelease}" + if buildtag: + version += f"+{buildtag}" + return version + except Exception: + pass + return self._get_pkg_version("isaacsim") + + def _record(self, key: str, version: str | None, *, nullable: bool = False) -> None: + """Store a version entry, preserving null for explicitly nullable keys.""" + if version or nullable: self._version_info[key] = version def _get_version_info(self) -> None: @@ -60,14 +97,10 @@ def _get_version_info(self) -> None: version = self._get_version("warp", "config.version") or self._get_version("warp") self._record("warp", version) - # isaacsim - self._record("isaacsim", self._get_version("isaacsim")) - - # kit (from omni.kit if available) - version = self._get_version("omni.kit", "app.get_app().get_build_version") - if not version: - version = self._get_version("carb", "settings.get_settings().get('/app/version')") - self._record("kit", version) + # Kit and Isaac Sim are meaningful only for an active Kit runtime. + version = self._get_kit_version() + self._record("kit", version, nullable=True) + self._record("isaacsim", self._get_isaacsim_version() if version else None, nullable=True) # torch self._record("torch", self._get_version("torch")) @@ -83,7 +116,8 @@ def _get_version_info(self) -> None: self._record("isaaclab_rl", self._get_pkg_version("isaaclab_rl")) # Renderers & physics engines - self._record("ovrtx", self._get_pkg_version("ovrtx")) + self._record("ovrtx", self._get_pkg_version("ovrtx"), nullable=True) + self._record("ovphysx", self._get_pkg_version("isaaclab_ovphysx"), nullable=True) self._record("newton", self._get_pkg_version("newton")) self._record("mujoco", self._get_pkg_version("mujoco")) self._record("mujoco_warp", self._get_pkg_version("mujoco-warp")) diff --git a/source/isaaclab/isaaclab/test/benchmark/schema.py b/source/isaaclab/isaaclab/test/benchmark/schema.py index 7a0e2d733fd..84e6e07f0f2 100644 --- a/source/isaaclab/isaaclab/test/benchmark/schema.py +++ b/source/isaaclab/isaaclab/test/benchmark/schema.py @@ -94,8 +94,8 @@ class Hardware: class Versions: """Software versions captured at run time. - Framework-specific fields (``rsl_rl``, ``rl_games``, ``skrl``, ``sb3``) are - ``None`` when the corresponding framework is not used by the run. + Version fields are ``None`` when the corresponding runtime or package is + unavailable. """ isaaclab: str @@ -112,6 +112,18 @@ class Versions: git_commit: str | None git_branch: str | None git_dirty: bool + numpy: str | None = None + isaaclab_newton: str | None = None + isaaclab_physx: str | None = None + isaaclab_ov: str | None = None + isaaclab_tasks: str | None = None + isaaclab_rl: str | None = None + ovrtx: str | None = None + ovphysx: str | None = None + mujoco: str | None = None + cuda_bindings: str | None = None + usd_core: str | None = None + isaaclab_release: str | None = None @dataclass(frozen=True) diff --git a/source/isaaclab/isaaclab/test/benchmark/stepping.py b/source/isaaclab/isaaclab/test/benchmark/stepping.py new file mode 100644 index 00000000000..969ce6cd1b5 --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/stepping.py @@ -0,0 +1,85 @@ +# 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-agnostic random-action stepping helpers for benchmarks. + +This module is intentionally lightweight: ``torch`` and ``numpy`` are +imported lazily inside each function so that importing this module has +no heavy-weight side effects. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import torch + + +def sample_random_actions(env) -> torch.Tensor | dict[str, torch.Tensor]: + """Sample random actions for a single-agent or multi-agent environment. + + For multi-agent environments (those where ``env.unwrapped`` exposes an + ``action_spaces`` attribute), one batch of actions is sampled per agent + using that agent's action space. For single-agent environments a uniform + sample in [-1, 1] is returned. + + Args: + env: A Gym-compatible environment wrapper. ``env.unwrapped`` must + expose ``num_envs`` and ``device``, plus either ``action_spaces`` + (multi-agent) or ``single_action_space`` (single-agent). + + Returns: + A ``torch.Tensor`` of shape ``(num_envs, action_dim)`` for + single-agent environments, or a ``dict`` mapping agent name to a + tensor of the same shape for multi-agent environments. + """ + import numpy as np # noqa: PLC0415 + import torch # noqa: PLC0415 + + u = env.unwrapped + + if hasattr(u, "action_spaces"): + # Multi-agent: sample each agent's action space independently. + return { + agent: torch.as_tensor( + np.stack([space.sample() for _ in range(u.num_envs)]), + dtype=torch.float32, + device=u.device, + ) + for agent, space in u.action_spaces.items() + } + else: + # Single-agent: uniform random actions in [-1, 1]. + return 2.0 * torch.rand(u.num_envs, u.single_action_space.shape[0], device=u.device) - 1.0 + + +def run_runtime_loop(env, num_frames: int) -> list[float]: + """Step the environment ``num_frames`` times and record per-step wall times [s]. + + Calls ``env.reset()`` once before the loop, then on each frame samples + random actions via :func:`sample_random_actions`, steps the environment, + and records the elapsed wall-clock time for that step. + + Args: + env: A Gym-compatible environment. + num_frames: Number of environment steps to run. + + Returns: + A list of length ``num_frames`` containing per-step wall times [s]. + """ + env.reset() + + step_times: list[float] = [] + + for _ in range(num_frames): + actions = sample_random_actions(env) + t0 = time.perf_counter_ns() + env.step(actions) + t1 = time.perf_counter_ns() + step_times.append((t1 - t0) / 1e9) + + return step_times diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index e5437619c8c..8c27e9ad437 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -8,7 +8,10 @@ import pytest +import isaaclab.app as app_module import isaaclab.app.app_launcher as app_launcher_module +import isaaclab.app.sim_launcher as sim_launcher +import isaaclab.utils as utils_module from isaaclab.app import AppLauncher from isaaclab.app.sim_launcher import _ensure_livestream_kit_visualizer @@ -41,6 +44,40 @@ def test_livestream_rejects_disabled_visualizers(): _ensure_livestream_kit_visualizer(args) +def test_launch_simulation_preserves_failure_exit_code(monkeypatch: pytest.MonkeyPatch): + close_args = {} + + class _FakeApp: + def close(self, *, exit_code: int = 0) -> None: + close_args["exit_code"] = exit_code + + class _FakeAppLauncher: + def __init__(self, _launcher_args): + self.app = _FakeApp() + + scan = sim_launcher.Scan( + resolved_physics_cfg=None, + effective_cfg=object(), + visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, + has_ovrtx=False, + has_kit_camera=False, + has_kit_physics=True, + has_kitless_physics=False, + has_ovphysx_physics=False, + needs_kit=True, + ) + monkeypatch.setattr(sim_launcher, "scan", lambda cfg, physics: scan) + monkeypatch.setattr(sim_launcher, "_ensure_isaac_sim_available", lambda: None) + monkeypatch.setattr(app_module, "AppLauncher", _FakeAppLauncher) + monkeypatch.setattr(utils_module, "has_kit", lambda: False) + + with pytest.raises(RuntimeError, match="sentinel"): + with sim_launcher.launch_simulation(object(), argparse.Namespace()): + raise RuntimeError("sentinel") + + assert close_args == {"exit_code": 1} + + class _DummySettings: def __init__(self): self.values = {} diff --git a/source/isaaclab/test/benchmark/test_benchmark_core.py b/source/isaaclab/test/benchmark/test_benchmark_core.py index d679880d82c..03bba5bcb0e 100644 --- a/source/isaaclab/test/benchmark/test_benchmark_core.py +++ b/source/isaaclab/test/benchmark/test_benchmark_core.py @@ -3,321 +3,269 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Unit tests for BaseIsaacLabBenchmark class.""" +"""Tests for BaseIsaacLabBenchmark.""" import json import os -import tempfile +from dataclasses import replace import pytest -from isaaclab.test.benchmark import backends +from isaaclab.test.benchmark import formatters from isaaclab.test.benchmark.benchmark_core import BaseIsaacLabBenchmark from isaaclab.test.benchmark.measurements import SingleMeasurement, StringMetadata -# ============================================================================== -# BaseIsaacLabBenchmark Tests -# ============================================================================== +@pytest.fixture(autouse=True) +def reset_formatters(): + formatters.MetricsFormatter.reset_instances() + yield + formatters.MetricsFormatter.reset_instances() + + +def _minimal_runtime_bundle(): + from isaaclab.test.benchmark.schema import ( + GpuDeviceInfo, + Hardware, + MeanStd, + Resources, + RunConfig, + RunIdentity, + Runtime, + RuntimeBundle, + StartupTime, + Versions, + ) + + return RuntimeBundle( + run=RunIdentity( + run_id="runtime_newton_mjwarp_Isaac-Ant-Direct-v0_20260422-131500_seed42", + framework=None, + config=RunConfig(physics_backend="newton_mjwarp", rendering_backend="none"), + task="Isaac-Ant-Direct-v0", + seed=42, + start_time_utc="2026-04-22T13:15:00Z", + end_time_utc="2026-04-22T13:15:10Z", + duration_s=10.0, + status="completed", + num_envs=16, + ), + versions=Versions( + isaaclab="4.6.8", + isaacsim=None, + kit=None, + newton=None, + warp=None, + mjwarp=None, + torch="2.5.1", + rsl_rl=None, + rl_games=None, + skrl=None, + sb3=None, + git_commit=None, + git_branch=None, + git_dirty=False, + ), + hardware=Hardware( + hostname="benchmark-host", + gpu_devices=[GpuDeviceInfo(name="NVIDIA H100 80GB", mem_gb=80.0, compute_cap="9.0")], + cpu_name="AMD EPYC 7763", + cpu_count=64, + ram_gb=512.0, + ), + runtime=Runtime( + startup_time_s=StartupTime(app_launch=1.0, env_creation=2.0, first_step=0.5), + iterations_completed=1, + total_wall_time_s=4.0, + steps_per_iteration=24, + iteration_time_s=MeanStd(mean=1.0, std=0.0), + collection_fps=MeanStd(mean=100.0, std=0.0), + total_fps=MeanStd(mean=100.0, std=0.0), + iterations_per_s=MeanStd(mean=1.0, std=0.0), + ), + resources=Resources( + gpu_util_pct=MeanStd(mean=80.0, std=5.0), + gpu_mem_gb=MeanStd(mean=10.0, std=0.5, peak=12.0), + cpu_util_pct=MeanStd(mean=30.0, std=4.0), + ram_gb=MeanStd(mean=20.0, std=1.0, peak=24.0), + ), + ) + + +def _minimal_training_bundle(): + from isaaclab.test.benchmark.schema import Learning, LearningCurve, TrainingBundle + + bundle = _minimal_runtime_bundle() + return TrainingBundle( + run=replace(bundle.run, framework="rsl_rl", max_iterations=1), + versions=bundle.versions, + hardware=bundle.hardware, + runtime=bundle.runtime, + resources=bundle.resources, + learning=Learning( + ema_alpha=0.95, + reward=LearningCurve(final_raw=3.0, final_ema=2.5, series_per_iter=[1.0, 3.0]), + ep_length=LearningCurve(final_raw=20.0, final_ema=18.0, series_per_iter=[10.0, 20.0]), + ), + success_rate=0.75, + ) + + +def _minimal_startup_bundle(): + from isaaclab.test.benchmark.schema import CProfileFunction, StartupBundle, StartupConfig, StartupPhase + + bundle = _minimal_runtime_bundle() + return StartupBundle( + run=replace(bundle.run, num_envs=None), + versions=bundle.versions, + hardware=bundle.hardware, + phases={ + "python_imports": StartupPhase( + total_time_s=0.25, + top_functions=[ + CProfileFunction( + name="isaaclab_tasks.utils:import_packages", own_time_s=0.1, cum_time_s=0.2, calls=2 + ) + ], + ) + }, + config=StartupConfig(top_n=1, whitelist=None), + ) -class TestBaseIsaacLabBenchmark: - """Tests for BaseIsaacLabBenchmark.""" - @pytest.fixture - def temp_output_dir(self): - """Create a temporary output directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - yield tmpdir +def _formatter_keys(benchmark: BaseIsaacLabBenchmark) -> list[str]: + return [key for key, _ in benchmark._metrics] - @pytest.fixture(autouse=True) - def reset_backends(self): - """Reset backend instances before each test.""" - backends.MetricsBackend.reset_instances() - yield - backends.MetricsBackend.reset_instances() - def test_initialization(self, temp_output_dir): - """Test benchmark initializes correctly.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - output_prefix="test", - ) - assert benchmark.benchmark_name == "test_benchmark" - assert benchmark.output_path == temp_output_dir - assert "test_" in benchmark.output_prefix - - def test_initialization_creates_output_dir(self): - """Test that initialization creates output directory if it doesn't exist.""" - with tempfile.TemporaryDirectory() as tmpdir: - output_path = os.path.join(tmpdir, "nested", "output") - _benchmark = BaseIsaacLabBenchmark( # noqa: F841 - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=output_path, - use_recorders=False, - ) - assert os.path.exists(output_path) +def test_benchmark_collects_metadata_measurements_and_writes_json(tmp_path): + output_path = tmp_path / "nested" / "output" + benchmark = BaseIsaacLabBenchmark( + benchmark_name="my_workflow", + formatter_type="omniperf", + output_path=str(output_path), + use_recorders=False, + output_prefix="test", + ) - def test_initialization_with_recorders(self, temp_output_dir): - """Test benchmark initializes with recorders enabled.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=True, - ) - assert benchmark._use_recorders is True - assert "CPUInfo" in benchmark._manual_recorders - assert "GPUInfo" in benchmark._manual_recorders - assert "MemoryInfo" in benchmark._manual_recorders - assert "VersionInfo" in benchmark._manual_recorders - - def test_initialization_without_recorders(self, temp_output_dir): - """Test benchmark initializes with recorders disabled.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - assert benchmark._use_recorders is False - assert not hasattr(benchmark, "_manual_recorders") or benchmark._manual_recorders is None - - def test_add_measurement(self, temp_output_dir): - """Test adding measurements to phases.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - measurement = SingleMeasurement(name="test_metric", value=42.0, unit="ms") - benchmark.add_measurement("test_phase", measurement=measurement) - assert "test_phase" in benchmark._phases - assert len(benchmark._phases["test_phase"].measurements) == 1 - assert benchmark._phases["test_phase"].measurements[0].name == "test_metric" - - def test_add_multiple_measurements(self, temp_output_dir): - """Test adding multiple measurements to a phase.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - measurements = [ + benchmark.add_measurement( + "runtime", + measurement=[ SingleMeasurement(name="metric1", value=10.0, unit="ms"), SingleMeasurement(name="metric2", value=20.0, unit="ms"), - ] - benchmark.add_measurement("test_phase", measurement=measurements) - assert len(benchmark._phases["test_phase"].measurements) == 2 - - def test_add_metadata(self, temp_output_dir): - """Test adding metadata to phases.""" + ], + ) + benchmark.add_measurement("runtime", metadata=StringMetadata(name="custom", data="value")) + benchmark._finalize_impl() + + with open(benchmark.output_file_path) as f: + data = json.load(f) + + assert output_path.exists() + assert benchmark.benchmark_name == "my_workflow" + assert benchmark._use_recorders is False + assert not hasattr(benchmark, "_manual_recorders") or benchmark._manual_recorders is None + assert data["benchmark_info"]["workflow_name"] == "my_workflow" + assert "timestamp" in data["benchmark_info"] + assert data["runtime"]["metric1"] == 10.0 + assert data["runtime"]["metric2"] == 20.0 + assert data["runtime"]["custom"] == "value" + + +def test_benchmark_updates_recorders_and_cleans_up(tmp_path): + benchmark = BaseIsaacLabBenchmark( + benchmark_name="test_benchmark", + formatter_type="omniperf", + output_path=str(tmp_path), + use_recorders=True, + output_prefix="test", + ) + + benchmark.add_measurement("runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms")) + benchmark.update_manual_recorders() + assert benchmark._manual_recorders["CPUInfo"]._n >= 1 + assert benchmark._manual_recorders["MemoryInfo"]._rss_n >= 1 + + benchmark._finalize_impl() + assert os.path.exists(benchmark.output_file_path) + assert benchmark._manual_recorders is None + assert benchmark._frametime_recorders is None + + +def test_formatter_selection_and_output_filenames(tmp_path): + default_benchmark = BaseIsaacLabBenchmark( + "default", formatter_type=" ", output_path=str(tmp_path), use_recorders=False + ) + assert _formatter_keys(default_benchmark) == ["omniperf"] + + single = BaseIsaacLabBenchmark("single", formatter_type="json", output_path=str(tmp_path), use_recorders=False) + single.add_measurement("runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms")) + single._finalize_impl() + assert os.path.exists(os.path.join(str(tmp_path), f"{single.output_prefix}.json")) + assert not os.path.exists(os.path.join(str(tmp_path), f"{single.output_prefix}_json.json")) + + multi = BaseIsaacLabBenchmark( + "multi", + formatter_type="schema,json,json", + output_path=str(tmp_path), + use_recorders=False, + output_prefix="test", + ) + assert _formatter_keys(multi) == ["schema", "json"] + multi.attach_bundle(_minimal_runtime_bundle()) + multi.add_measurement("runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms")) + multi._finalize_impl() + + schema_path = os.path.join(str(tmp_path), f"{multi.output_prefix}_schema.json") + json_path = os.path.join(str(tmp_path), f"{multi.output_prefix}_json.json") + assert os.path.exists(schema_path) + assert os.path.exists(json_path) + + with open(schema_path) as f: + schema_data = json.load(f) + with open(json_path) as f: + json_data = json.load(f) + assert schema_data != json_data + assert schema_data["run"]["task"] == "Isaac-Ant-Direct-v0" + + +def test_attached_bundles_are_projected_to_flat_formatters(tmp_path): + cases = [ + (_minimal_runtime_bundle(), "runtime", "Mean Total FPS", 100.0), + (_minimal_training_bundle(), "train", "Last Reward", 3.0), + (_minimal_startup_bundle(), "python_imports", "Wall Clock Time", 0.25), + ] + + for index, (bundle, phase, metric, expected) in enumerate(cases): benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, + f"bundle_{index}", + formatter_type="omniperf", + output_path=str(tmp_path), use_recorders=False, + output_prefix=f"bundle_{index}", ) - metadata = StringMetadata(name="test_key", data="test_value") - benchmark.add_measurement("test_phase", metadata=metadata) - assert "test_phase" in benchmark._phases - # Phase metadata includes automatic "phase" and "workflow_name" entries plus our custom one - assert len(benchmark._phases["test_phase"].metadata) == 3 - metadata_names = [m.name for m in benchmark._phases["test_phase"].metadata] - assert "test_key" in metadata_names - assert "phase" in metadata_names - assert "workflow_name" in metadata_names - - def test_update_manual_recorders(self, temp_output_dir): - """Test updating manual recorders.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=True, - ) - # Should not raise - benchmark.update_manual_recorders() - # Check recorders were updated - CPUInfoRecorder has _n attribute - assert benchmark._manual_recorders["CPUInfo"]._n >= 1 - assert benchmark._manual_recorders["MemoryInfo"]._rss_n >= 1 - - def test_update_manual_recorders_disabled(self, temp_output_dir): - """Test that update_manual_recorders is a no-op when recorders are disabled.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - # Should not raise - benchmark.update_manual_recorders() - - def test_finalize_generates_output(self, temp_output_dir): - """Test that finalize creates output file.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=True, - output_prefix="test", - ) - benchmark.add_measurement( - "runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms") - ) - benchmark.update_manual_recorders() + benchmark.attach_bundle(bundle) benchmark._finalize_impl() - # Check output file exists - assert os.path.exists(benchmark.output_file_path) - - def test_finalize_output_contains_measurements(self, temp_output_dir): - """Test that finalized output contains added measurements.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - output_prefix="test", - ) - benchmark.add_measurement( - "runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms") - ) - benchmark._finalize_impl() - - # Read and verify output with open(benchmark.output_file_path) as f: data = json.load(f) - - # Check that runtime phase is present with our measurement - assert "runtime" in data - assert "execution_time" in data["runtime"] - assert data["runtime"]["execution_time"] == 100.5 - - def test_finalize_cleans_up_recorders(self, temp_output_dir): - """Test that finalize cleans up recorders.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=True, - output_prefix="test", - ) - benchmark.add_measurement( - "runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms") - ) - benchmark.update_manual_recorders() - benchmark._finalize_impl() - - # Recorders should be set to None - assert benchmark._manual_recorders is None - assert benchmark._frametime_recorders is None - - def test_workflow_metadata_in_output(self, temp_output_dir): - """Test that workflow name and timestamp metadata are in output.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="my_workflow", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - output_prefix="test", - ) - benchmark._finalize_impl() - - with open(benchmark.output_file_path) as f: - data = json.load(f) - - # Check benchmark_info phase has workflow metadata - assert "benchmark_info" in data - assert "workflow_name" in data["benchmark_info"] - assert data["benchmark_info"]["workflow_name"] == "my_workflow" - assert "timestamp" in data["benchmark_info"] - - -# ============================================================================== -# MetricsBackend Factory Tests -# ============================================================================== - - -class TestMetricsBackendFactory: - """Tests for MetricsBackend factory class.""" - - @pytest.fixture - def temp_output_dir(self): - """Create a temporary output directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - yield tmpdir - - @pytest.fixture(autouse=True) - def reset_backends(self): - """Reset backend instances before each test.""" - backends.MetricsBackend.reset_instances() - yield - backends.MetricsBackend.reset_instances() - - def test_get_json_backend(self): - """Test getting JSON backend instance.""" - backend = backends.MetricsBackend.get_instance("json") - assert isinstance(backend, backends.JSONFileMetrics) - - def test_get_osmo_backend(self): - """Test getting Osmo backend instance.""" - backend = backends.MetricsBackend.get_instance("osmo") - assert isinstance(backend, backends.OsmoKPIFile) - - def test_get_omniperf_backend(self): - """Test getting OmniPerf backend instance.""" - backend = backends.MetricsBackend.get_instance("omniperf") - assert isinstance(backend, backends.OmniPerfKPIFile) - - def test_get_summary_backend(self): - """Test getting Summary backend instance.""" - backend = backends.MetricsBackend.get_instance("summary") - assert isinstance(backend, backends.SummaryMetrics) - - def test_summary_backend_finalize_writes_json(self, temp_output_dir): - """Test that SummaryMetrics finalize writes JSON output (and does not raise).""" - backend = backends.MetricsBackend.get_instance("summary") - from isaaclab.test.benchmark.measurements import StringMetadata, TestPhase - - phase = TestPhase(phase_name="runtime") - phase.measurements.append(SingleMeasurement(name="Test FPS", value=60.0, unit="FPS")) - phase.metadata.append(StringMetadata(name="runtime workflow_name", data="summary_test")) - phase.metadata.append(StringMetadata(name="runtime phase", data="runtime")) - backend.add_metrics(phase) - output_path = temp_output_dir - output_filename = "summary_test" - backend.finalize(output_path, output_filename) - expected_path = os.path.join(output_path, f"{output_filename}.json") - assert os.path.exists(expected_path) - with open(expected_path) as f: - data = json.load(f) - assert isinstance(data, list) and len(data) >= 1 - assert any(p.get("phase_name") == "runtime" for p in data) - - def test_invalid_backend_type_raises_error(self): - """Test that invalid backend type raises ValueError.""" - with pytest.raises(ValueError, match="Unknown backend type"): - backends.MetricsBackend.get_instance("invalid_type") - - def test_backend_instance_is_cached(self): - """Test that backend instances are cached and reused.""" - backend1 = backends.MetricsBackend.get_instance("omniperf") - backend2 = backends.MetricsBackend.get_instance("omniperf") - assert backend1 is backend2 - - def test_reset_instances(self): - """Test that reset_instances clears the cache.""" - backend1 = backends.MetricsBackend.get_instance("omniperf") - backends.MetricsBackend.reset_instances() - backend2 = backends.MetricsBackend.get_instance("omniperf") - assert backend1 is not backend2 - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--maxfail=1"]) + assert data[phase][metric] == expected + + +def test_metrics_formatter_factory_registration_cache_and_errors(): + expected = { + "json": formatters.JSONFileMetrics, + "osmo": formatters.OsmoKPIFile, + "omniperf": formatters.OmniPerfKPIFile, + "summary": formatters.SummaryMetrics, + "schema": formatters.SchemaBundleFile, + } + for key, cls in expected.items(): + assert isinstance(formatters.MetricsFormatter.get_instance(key), cls) + + formatter = formatters.MetricsFormatter.get_instance("omniperf") + assert formatter is formatters.MetricsFormatter.get_instance("omniperf") + formatters.MetricsFormatter.reset_instances() + assert formatter is not formatters.MetricsFormatter.get_instance("omniperf") + + with pytest.raises(ValueError, match="Unknown formatter type"): + formatters.MetricsFormatter.get_instance("invalid_type") diff --git a/source/isaaclab/test/benchmark/test_builders.py b/source/isaaclab/test/benchmark/test_builders.py new file mode 100644 index 00000000000..1555c55e565 --- /dev/null +++ b/source/isaaclab/test/benchmark/test_builders.py @@ -0,0 +1,182 @@ +# 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 benchmark bundle builders (Isaac-Sim-free).""" + +import json +import os + +import pytest + +from isaaclab.test.benchmark import builders +from isaaclab.test.benchmark.schema import ( + GpuDeviceInfo, + Hardware, + MeanStd, + Resources, + RuntimeBundle, + StartupTime, + TrainingBundle, + Versions, +) +from isaaclab.test.benchmark.serialize import write_bundle_file + + +def _versions(): + return Versions( + isaaclab="4.6.8", + isaacsim=None, + kit=None, + newton=None, + warp=None, + mjwarp=None, + torch="2.5.1", + rsl_rl=None, + rl_games=None, + skrl=None, + sb3=None, + git_commit=None, + git_branch=None, + git_dirty=False, + ) + + +def _hardware(): + return Hardware( + hostname="h", + gpu_devices=[GpuDeviceInfo(name="g", mem_gb=80.0, compute_cap="9.0")], + cpu_name="c", + cpu_count=64, + ram_gb=512.0, + ) + + +def _resources(): + return Resources( + gpu_util_pct=MeanStd(80.0, 5.0), + gpu_mem_gb=MeanStd(10.0, 0.5, 12.0), + cpu_util_pct=MeanStd(30.0, 4.0), + ram_gb=MeanStd(20.0, 1.0, 24.0), + ) + + +def test_run_config_presets_default_empty(): + assert builders.build_run_config("physx").presets == [] + assert builders.build_run_config("newton_mjwarp", presets=["rgb"]).presets == ["rgb"] + + +def test_run_identity_computes_duration(): + run = builders.build_run_identity( + run_id="x", + framework="rsl_rl", + config=builders.build_run_config("newton_mjwarp"), + task="t", + seed=0, + start_utc="2026-04-22T13:15:00+00:00", + end_utc="2026-04-22T13:15:30+00:00", + num_envs=4096, + max_iterations=500, + ) + assert run.duration_s == pytest.approx(30.0) + + +def test_build_runtime_aggregates(): + rt = builders.build_runtime( + startup_time_s=StartupTime(app_launch=1.0, env_creation=2.0, first_step=0.5), + iteration_times_s=[1.0, 1.0, 2.0], + collection_fps=[100.0, 110.0], + total_fps=[90.0, 95.0], + steps_per_iteration=24, + ) + assert rt.iterations_completed == 3 + assert rt.total_wall_time_s == pytest.approx(4.0) + assert rt.total_fps.peak == pytest.approx(95.0) + assert rt.iterations_per_s.mean > 0 + + +def test_build_training_bundle_round_trips(tmp_path): + run = builders.build_run_identity( + run_id="x", + framework="rsl_rl", + config=builders.build_run_config("physx"), + task="t", + seed=0, + start_utc="2026-04-22T13:15:00+00:00", + end_utc="2026-04-22T13:16:00+00:00", + num_envs=16, + max_iterations=5, + ) + rt = builders.build_runtime( + startup_time_s=StartupTime(1.0, 2.0, 0.5), + iteration_times_s=[1.0, 1.0], + collection_fps=[100.0], + total_fps=[90.0], + steps_per_iteration=24, + ) + learning = builders.build_learning(reward_series=[1.0, 2.0, 3.0], ep_length_series=[10.0, 12.0], ema_alpha=0.1) + b = builders.build_training_bundle( + run=run, + versions=_versions(), + hardware=_hardware(), + runtime=rt, + resources=_resources(), + learning=learning, + success_rate=0.9, + checkpoint_path="m.pt", + ) + assert isinstance(b, TrainingBundle) + assert b.learning.reward.final_raw == pytest.approx(3.0) + p = os.path.join(tmp_path, "training.json") + write_bundle_file(b, p) + with open(p) as fh: + data = json.load(fh) + assert data["success_rate"] == pytest.approx(0.9) + assert data["runtime"]["total_fps"]["mean"] == pytest.approx(90.0) + + +def test_build_learning_empty_series(): + learning = builders.build_learning(reward_series=[], ep_length_series=[], ema_alpha=0.1) + assert learning.reward.final_raw == pytest.approx(0.0) + assert learning.reward.final_ema == pytest.approx(0.0) + assert learning.reward.series_per_iter == [] + assert learning.ep_length.final_raw == pytest.approx(0.0) + assert learning.ep_length.final_ema == pytest.approx(0.0) + assert learning.ep_length.series_per_iter == [] + + +def test_build_learning_keep_series_false(): + learning = builders.build_learning( + reward_series=[1.0, 2.0], ep_length_series=[10.0], ema_alpha=0.1, keep_series=False + ) + assert learning.reward.series_per_iter is None + assert learning.ep_length.series_per_iter is None + + +def test_build_runtime_bundle_no_learning(tmp_path): + run = builders.build_run_identity( + run_id="x", + framework=None, + config=builders.build_run_config("newton_mjwarp"), + task="t", + seed=0, + start_utc="2026-04-22T13:15:00+00:00", + end_utc="2026-04-22T13:15:10+00:00", + num_envs=16, + ) + rt = builders.build_runtime( + startup_time_s=StartupTime(1.0, 2.0, 0.5), + iteration_times_s=[1.0], + collection_fps=[100.0], + total_fps=[100.0], + steps_per_iteration=24, + ) + b = builders.build_runtime_bundle( + run=run, versions=_versions(), hardware=_hardware(), runtime=rt, resources=_resources() + ) + assert isinstance(b, RuntimeBundle) + p = os.path.join(tmp_path, "runtime.json") + write_bundle_file(b, p) + with open(p) as fh: + assert json.load(fh)["run"]["framework"] is None diff --git a/source/isaaclab/test/benchmark/test_capture.py b/source/isaaclab/test/benchmark/test_capture.py new file mode 100644 index 00000000000..01c3ba794a7 --- /dev/null +++ b/source/isaaclab/test/benchmark/test_capture.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 + +"""Tests for benchmark capture helpers (Isaac-Sim-free, fake recorders).""" + +from types import SimpleNamespace +from typing import Literal + +import pytest + +import isaaclab.test.benchmark.capture as capture +from isaaclab.test.benchmark.capture import ( + capture_hardware, + capture_resources, + capture_versions, + run_config_from_presets, + synth_run_id, +) +from isaaclab.test.benchmark.interfaces import MeasurementData +from isaaclab.test.benchmark.measurements import ( + DictMetadata, + FloatMetadata, + IntMetadata, + SingleMeasurement, + StringMetadata, +) +from isaaclab.test.benchmark.schema import Hardware, Resources, Versions + + +class _Rec: + def __init__(self, data): + self._data = data + + def get_data(self): + return self._data + + +class _Bm: + def __init__(self, recorders): + self._manual_recorders = recorders + + +def test_capture_versions_renames_and_defaults(): + md = [ + StringMetadata(name="isaaclab_version", data="4.6.8"), + StringMetadata(name="torch_version", data="2.5.1"), + StringMetadata(name="mujoco_warp_version", data="0.0.4"), + StringMetadata(name="stable_baselines3_version", data="2.3.0"), + DictMetadata(name="dev", data={"commit_hash": "abc123", "branch": "develop", "dirty": True}), + ] + bm = _Bm({"VersionInfo": _Rec(MeasurementData(measurements=[], metadata=md, artefacts=[]))}) + v = capture_versions(bm) + assert isinstance(v, Versions) + assert v.isaaclab == "4.6.8" and v.torch == "2.5.1" + assert v.mjwarp == "0.0.4" + assert v.sb3 == "2.3.0" + assert v.git_commit == "abc123" and v.git_branch == "develop" and v.git_dirty is True + assert v.isaacsim is None + + +def test_capture_versions_preserves_runtime_packages(): + md = [ + StringMetadata(name="numpy_version", data="2.4.4"), + StringMetadata(name="isaaclab_newton_version", data="1.0.2"), + StringMetadata(name="isaaclab_physx_version", data="2.0.1"), + StringMetadata(name="isaaclab_ov_version", data="0.4.6"), + StringMetadata(name="isaaclab_tasks_version", data="8.0.1"), + StringMetadata(name="isaaclab_rl_version", data="0.6.1"), + StringMetadata(name="ovrtx_version", data=None), + StringMetadata(name="ovphysx_version", data="3.0.5"), + StringMetadata(name="mujoco_version", data="3.8.1"), + StringMetadata(name="cuda_bindings_version", data="12.9.4"), + StringMetadata(name="usd_core_version", data="25.11"), + StringMetadata(name="isaaclab_release_version", data="3.0.0"), + ] + bm = _Bm({"VersionInfo": _Rec(MeasurementData(measurements=[], metadata=md, artefacts=[]))}) + + versions = capture_versions(bm) + + assert versions.numpy == "2.4.4" + assert versions.isaaclab_newton == "1.0.2" + assert versions.isaaclab_physx == "2.0.1" + assert versions.isaaclab_ov == "0.4.6" + assert versions.isaaclab_tasks == "8.0.1" + assert versions.isaaclab_rl == "0.6.1" + assert versions.ovrtx is None + assert versions.ovphysx == "3.0.5" + assert versions.mujoco == "3.8.1" + assert versions.cuda_bindings == "12.9.4" + assert versions.usd_core == "25.11" + assert versions.isaaclab_release == "3.0.0" + + +def test_capture_resources_peaks(): + gpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="GPU Utilization", value=80.0, unit="%"), + SingleMeasurement(name="GPU Utilization std", value=5.0, unit="%"), + SingleMeasurement(name="GPU Memory Used", value=10.0, unit="GB"), + SingleMeasurement(name="GPU Memory Used std", value=0.5, unit="GB"), + SingleMeasurement(name="GPU Memory Used peak", value=12.0, unit="GB"), + ], + metadata=[], + artefacts=[], + ) + ) + cpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="CPU Utilization", value=30.0, unit="%"), + SingleMeasurement(name="CPU Utilization std", value=4.0, unit="%"), + ], + metadata=[], + artefacts=[], + ) + ) + mem = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="System Memory RSS", value=20.0, unit="GB"), + SingleMeasurement(name="System Memory RSS std", value=1.0, unit="GB"), + SingleMeasurement(name="System Memory RSS peak", value=24.0, unit="GB"), + ], + metadata=[], + artefacts=[], + ) + ) + r = capture_resources(_Bm({"GPUInfo": gpu, "CPUInfo": cpu, "MemoryInfo": mem})) + assert isinstance(r, Resources) + assert r.gpu_util_pct.peak is None + assert r.gpu_mem_gb.peak == pytest.approx(12.0) + assert r.ram_gb.peak == pytest.approx(24.0) + assert r.cpu_util_pct.peak is None + + +def test_capture_resources_uses_current_gpu_for_multiple_devices(): + gpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="GPU 1 Utilization", value=80.0, unit="%"), + SingleMeasurement(name="GPU 1 Utilization std", value=5.0, unit="%"), + SingleMeasurement(name="GPU 1 Memory Used", value=10.0, unit="GB"), + SingleMeasurement(name="GPU 1 Memory Used std", value=0.5, unit="GB"), + SingleMeasurement(name="GPU 1 Memory Used peak", value=12.0, unit="GB"), + ], + metadata=[ + IntMetadata(name="gpu_device_count", data=2), + IntMetadata(name="gpu_current_device", data=1), + ], + artefacts=[], + ) + ) + resources = capture_resources(_Bm({"GPUInfo": gpu})) + + assert resources.gpu_util_pct.mean == pytest.approx(80.0) + assert resources.gpu_mem_gb.peak == pytest.approx(12.0) + + +def test_capture_hardware(): + gpu = _Rec( + MeasurementData( + measurements=[], + metadata=[ + DictMetadata( + name="gpu_devices", + data={"0": {"name": "H100", "total_memory_gb": 80.0, "compute_capability": "9.0"}}, + ), + ], + artefacts=[], + ) + ) + cpu = _Rec( + MeasurementData( + measurements=[], + metadata=[ + StringMetadata(name="cpu_name", data="EPYC"), + IntMetadata(name="physical_cores", data=64), + ], + artefacts=[], + ) + ) + mem = _Rec( + MeasurementData( + measurements=[], + metadata=[FloatMetadata(name="total_ram_gb", data=512.0)], + artefacts=[], + ) + ) + h = capture_hardware(_Bm({"GPUInfo": gpu, "CPUInfo": cpu, "MemoryInfo": mem})) + assert isinstance(h, Hardware) + assert h.gpu_devices[0].name == "H100" and h.gpu_devices[0].mem_gb == pytest.approx(80.0) + assert h.gpu_devices[0].compute_cap == "9.0" + assert h.cpu_name == "EPYC" and h.cpu_count == 64 and h.ram_gb == pytest.approx(512.0) + assert isinstance(h.hostname, str) and h.hostname + + +def test_capture_handles_missing_recorders(): + bm = _Bm(None) + assert isinstance(capture_versions(bm), Versions) + assert isinstance(capture_hardware(bm), Hardware) + assert isinstance(capture_resources(bm), Resources) + + +def test_synth_run_id(): + rid = synth_run_id("rsl_rl", "physx", "Isaac-Ant-Direct-v0", 42, "20260612-150000") + assert "rsl_rl" in rid and "physx" in rid and "42" in rid + + +def test_run_config_from_presets_resolves_backend_configuration(monkeypatch): + cases = [ + ([], "physx", "none", []), + ( + ["newton_mjwarp", "ovrtx_renderer", "rgb"], + "newton_mjwarp", + "ovrtx", + ["newton_mjwarp", "ovrtx_renderer", "rgb"], + ), + (["newton"], "newton_mjwarp", "none", ["newton"]), + ( + ["physics=newton_mjwarp", "renderer=ovrtx_renderer", "presets=rgb,depth"], + "newton_mjwarp", + "ovrtx", + ["newton_mjwarp", "ovrtx_renderer", "rgb", "depth"], + ), + ] + for tokens, physics, rendering, presets in cases: + cfg = run_config_from_presets(tokens) + assert cfg.physics_backend == physics + assert cfg.rendering_backend == rendering + assert cfg.presets == presets + + monkeypatch.setattr(capture, "PhysicsBackend", Literal["physx", "newton_mjwarp_vbd"], raising=False) + assert run_config_from_presets(["newton_mjwarp_vbd"]).physics_backend == "newton_mjwarp_vbd" + + env_cfg = SimpleNamespace( + sim=SimpleNamespace(physics=SimpleNamespace(class_type="isaaclab_newton.physics:NewtonMJWarpManager")), + camera=SimpleNamespace(renderer_cfg=SimpleNamespace(renderer_type="isaac_rtx")), + ) + cfg = run_config_from_presets([], env_cfg=env_cfg) + assert cfg.physics_backend == "newton_mjwarp" + assert cfg.rendering_backend == "isaacsim_rtx" + + +def test_capture_resources_peak_clamped_to_mean_when_peak_row_absent(): + # Build a recorder that has mean/std rows but no peak rows. + # capture_resources must clamp peak to mean rather than leaving it at 0.0 + # (which would violate MeanStd.__post_init__ since peak < mean). + gpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="GPU Utilization", value=5.0, unit="%"), + SingleMeasurement(name="GPU Utilization std", value=1.0, unit="%"), + SingleMeasurement(name="GPU Memory Used", value=10.0, unit="GB"), + SingleMeasurement(name="GPU Memory Used std", value=0.5, unit="GB"), + # No "GPU Memory Used peak" row — peak defaults to 0.0 before clamping. + ], + metadata=[], + artefacts=[], + ) + ) + mem = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="System Memory RSS", value=10.0, unit="GB"), + SingleMeasurement(name="System Memory RSS std", value=0.2, unit="GB"), + # No "System Memory RSS peak" row. + ], + metadata=[], + artefacts=[], + ) + ) + cpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="CPU Utilization", value=20.0, unit="%"), + SingleMeasurement(name="CPU Utilization std", value=2.0, unit="%"), + ], + metadata=[], + artefacts=[], + ) + ) + # Must not raise ValueError from MeanStd.__post_init__. + r = capture_resources(_Bm({"GPUInfo": gpu, "CPUInfo": cpu, "MemoryInfo": mem})) + assert r.gpu_mem_gb.peak == pytest.approx(10.0) + assert r.ram_gb.peak == pytest.approx(10.0) + + +def test_capture_hardware_gpu_devices_sorted_by_numeric_index(): + # Keys "10", "2", "0" should be returned in numeric order 0, 2, 10 — not lexical "0","10","2". + gpu = _Rec( + MeasurementData( + measurements=[], + metadata=[ + DictMetadata( + name="gpu_devices", + data={ + "10": {"name": "H100-10", "total_memory_gb": 80.0, "compute_capability": "9.0"}, + "2": {"name": "H100-2", "total_memory_gb": 80.0, "compute_capability": "9.0"}, + "0": {"name": "H100-0", "total_memory_gb": 80.0, "compute_capability": "9.0"}, + }, + ), + ], + artefacts=[], + ) + ) + bm = _Bm({"GPUInfo": gpu}) + h = capture_hardware(bm) + names = [d.name for d in h.gpu_devices] + assert names == ["H100-0", "H100-2", "H100-10"] diff --git a/source/isaaclab/test/benchmark/test_formatters.py b/source/isaaclab/test/benchmark/test_formatters.py new file mode 100644 index 00000000000..c1bc3f860a2 --- /dev/null +++ b/source/isaaclab/test/benchmark/test_formatters.py @@ -0,0 +1,143 @@ +# 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 metrics formatters.""" + +import json +import logging +import os + +import pytest + +from isaaclab.test.benchmark import formatters +from isaaclab.test.benchmark.measurements import SingleMeasurement, StringMetadata, TestPhase +from isaaclab.test.benchmark.schema import ( + GpuDeviceInfo, + Hardware, + MeanStd, + Resources, + RunConfig, + RunIdentity, + Runtime, + RuntimeBundle, + StartupTime, + Versions, +) + + +def _minimal_runtime_bundle() -> RuntimeBundle: + return RuntimeBundle( + run=RunIdentity( + run_id="runtime_newton_mjwarp_Isaac-Ant-Direct-v0_20260422-131500_seed42", + framework=None, + config=RunConfig(physics_backend="newton_mjwarp", rendering_backend="none"), + task="Isaac-Ant-Direct-v0", + seed=42, + start_time_utc="2026-04-22T13:15:00Z", + end_time_utc="2026-04-22T13:15:10Z", + duration_s=10.0, + status="completed", + num_envs=16, + ), + versions=Versions( + isaaclab="4.6.8", + isaacsim=None, + kit=None, + newton=None, + warp=None, + mjwarp=None, + torch="2.5.1", + rsl_rl=None, + rl_games=None, + skrl=None, + sb3=None, + git_commit=None, + git_branch=None, + git_dirty=False, + ), + hardware=Hardware( + hostname="benchmark-host", + gpu_devices=[GpuDeviceInfo(name="NVIDIA H100 80GB", mem_gb=80.0, compute_cap="9.0")], + cpu_name="AMD EPYC 7763", + cpu_count=64, + ram_gb=512.0, + ), + runtime=Runtime( + startup_time_s=StartupTime(app_launch=1.0, env_creation=2.0, first_step=0.5), + iterations_completed=1, + total_wall_time_s=4.0, + steps_per_iteration=24, + iteration_time_s=MeanStd(mean=1.0, std=0.0), + collection_fps=MeanStd(mean=100.0, std=0.0), + total_fps=MeanStd(mean=100.0, std=0.0), + iterations_per_s=MeanStd(mean=1.0, std=0.0), + ), + resources=Resources( + gpu_util_pct=MeanStd(mean=80.0, std=5.0), + gpu_mem_gb=MeanStd(mean=10.0, std=0.5, peak=12.0), + cpu_util_pct=MeanStd(mean=30.0, std=4.0), + ram_gb=MeanStd(mean=20.0, std=1.0, peak=24.0), + ), + ) + + +@pytest.fixture(autouse=True) +def reset_formatters(): + formatters.MetricsFormatter.reset_instances() + yield + formatters.MetricsFormatter.reset_instances() + + +def test_schema_bundle_file_serializes_bundle_and_handles_missing_bundle(tmp_path, caplog): + formatter = formatters.MetricsFormatter.get_instance("schema") + phase = TestPhase(phase_name="runtime") + phase.measurements.append(SingleMeasurement(name="Test FPS", value=60.0, unit="FPS")) + formatter.add_metrics(phase) + formatter.finalize(str(tmp_path), "runtime", bundle=_minimal_runtime_bundle()) + + with open(os.path.join(str(tmp_path), "runtime.json")) as f: + data = json.load(f) + assert isinstance(formatter, formatters.SchemaBundleFile) + assert data["run"]["task"] == "Isaac-Ant-Direct-v0" + assert data["run"]["framework"] is None + assert data["runtime"]["total_fps"]["mean"] == pytest.approx(100.0) + assert data["resources"]["gpu_mem_gb"]["peak"] == pytest.approx(12.0) + assert data["schema_version"] + assert "Test FPS" not in json.dumps(data) + + with caplog.at_level(logging.WARNING, logger="isaaclab.test.benchmark.formatters"): + formatter.finalize(str(tmp_path), "missing", bundle=None) + assert not os.path.exists(os.path.join(str(tmp_path), "missing.json")) + assert any("no bundle" in record.message.lower() for record in caplog.records) + + +@pytest.mark.parametrize("formatter_cls", [formatters.OsmoKPIFile, formatters.OmniPerfKPIFile]) +def test_kpi_formatters_clear_phases_after_finalize(tmp_path, formatter_cls): + formatter = formatter_cls() + phase = TestPhase(phase_name="runtime") + phase.metadata.append(StringMetadata(name="phase", data="runtime")) + phase.measurements.append(SingleMeasurement(name="FPS", value=60.0, unit="FPS")) + + formatter.add_metrics(phase) + formatter.finalize(str(tmp_path), "first") + formatter.finalize(str(tmp_path), "second") + + assert os.path.exists(os.path.join(str(tmp_path), "first.json")) + assert not os.path.exists(os.path.join(str(tmp_path), "second.json")) + + +def test_osmo_writes_one_file_per_phase(tmp_path): + formatter = formatters.OsmoKPIFile() + for phase_name, value in (("startup", 1.0), ("runtime", 60.0)): + phase = TestPhase(phase_name=phase_name) + phase.metadata.append(StringMetadata(name="phase", data=phase_name)) + phase.measurements.append(SingleMeasurement(name="FPS", value=value, unit="FPS")) + formatter.add_metrics(phase) + + formatter.finalize(str(tmp_path), "metrics") + + assert sorted(path.name for path in tmp_path.glob("*.json")) == ["metrics_runtime.json", "metrics_startup.json"] + with open(tmp_path / "metrics_runtime.json") as f: + assert json.load(f)["FPS"] == 60.0 diff --git a/source/isaaclab/test/benchmark/test_metrics.py b/source/isaaclab/test/benchmark/test_metrics.py new file mode 100644 index 00000000000..53f14dff701 --- /dev/null +++ b/source/isaaclab/test/benchmark/test_metrics.py @@ -0,0 +1,178 @@ +# 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 benchmark metrics helpers (Isaac-Sim-free).""" + +import pytest + +from isaaclab.test.benchmark.metrics import ( + RL_LIBRARY_DESCRIPTORS, + SUCCESS_RATE_LOG_TAGS, + SuccessRateTracker, + check_convergence, + ema, + get_success_rate_log, + mean_std, + mean_std_peak, + parse_tf_logs, + success_rate_step_value, +) +from isaaclab.test.benchmark.schema import MeanStd + + +@pytest.mark.parametrize( + ("framework", "tfevents_pattern", "reward_tag", "ep_length_tag"), + [ + ("rsl_rl", "events*", "Train/mean_reward", "Train/mean_episode_length"), + ("rl_games", "summaries/events*", "rewards/iter", "episode_lengths/iter"), + ("skrl", "events*", "Reward / Total reward (mean)", "Episode / Total timesteps (mean)"), + ("sb3", "PPO_*/events*", "rollout/ep_rew_mean", "rollout/ep_len_mean"), + ], +) +def test_rl_library_descriptors( + framework: str, + tfevents_pattern: str, + reward_tag: str, + ep_length_tag: str, +): + descriptor = RL_LIBRARY_DESCRIPTORS[framework] + + assert descriptor.framework == framework + assert descriptor.tfevents_pattern == tfevents_pattern + assert descriptor.reward_tag == reward_tag + assert descriptor.ep_length_tag == ep_length_tag + + +def test_mean_std_peak_computes_peak(): + ms = mean_std_peak([1.0, 2.0, 3.0]) + assert isinstance(ms, MeanStd) + assert ms.mean == pytest.approx(2.0) + assert ms.std == pytest.approx(1.0) + assert ms.peak == pytest.approx(3.0) + + +def test_mean_std_omits_peak(): + ms = mean_std([10.0, 20.0]) + assert ms.peak is None + assert ms.mean == pytest.approx(15.0) + + +def test_mean_std_empty_is_zero(): + ms = mean_std_peak([]) + assert ms.mean == 0.0 and ms.std == 0.0 and ms.peak == 0.0 + + +def test_ema_matches_manual(): + series = [0.0, 10.0, 10.0] + a = 0.5 + e = series[0] + for x in series[1:]: + e = a * x + (1 - a) * e + assert ema(series, a) == pytest.approx(e) + + +def test_ema_empty_is_zero(): + assert ema([], 0.1) == 0.0 + + +def test_check_convergence_passes_on_stable_high_rewards(): + res = check_convergence([100.0] * 10, threshold=50.0) + assert res["passed"] is True + assert res["tail_mean"] == pytest.approx(100.0) + + +def test_check_convergence_fails_when_below_threshold(): + res = check_convergence([1.0] * 10, threshold=50.0) + assert res["passed"] is False + + +def test_get_success_rate_log_prefers_first_tag(): + data = {"Episode/Metrics/success_rate": [0.5], "Metrics/success_rate": [0.9]} + assert get_success_rate_log(data) == [0.9] + assert get_success_rate_log({}) is None + + +def test_success_rate_tracker_convergence(): + t = SuccessRateTracker(threshold=0.5, window=2, num_steps_per_env=1) + for v in (0.6, 0.7): + t.record_step({"log": {"Metrics/success_rate": v}}) + t.end_iteration() + assert t.converged is True + assert t.tail_mean == pytest.approx(0.65) + + +def test_check_convergence_high_cv_fails_despite_mean_above_threshold(): + # Series whose tail mean clears the threshold but CV is too high to pass. + # With window_pct=1.0, the whole series is the tail. + # [1, 1000] -> tail_mean ~500.5, threshold=2.0, cv >> 20 -> passed False. + rewards = [1.0, 1000.0] + res = check_convergence(rewards, threshold=2.0, window_pct=1.0) + assert res["passed"] is False + assert res["cv"] > 20.0 + assert res["tail_mean"] >= 2.0 + + +def test_check_convergence_empty_rewards(): + res = check_convergence([], threshold=1.0) + assert res == {"tail_mean": 0.0, "cv": 999.9, "passed": False} + + +def test_success_rate_tracker_multi_step_boundary(): + # num_steps_per_env=3: boundary fires after step 3, not after 1 or 2. + t = SuccessRateTracker(threshold=0.5, window=1, num_steps_per_env=3) + t.record_step({"log": {"Metrics/success_rate": 0.6}}) + assert t.at_iteration_boundary is False + t.record_step({"log": {"Metrics/success_rate": 0.6}}) + assert t.at_iteration_boundary is False + t.record_step({"log": {"Metrics/success_rate": 0.6}}) + assert t.at_iteration_boundary is True + t.end_iteration() + assert len(t.history) == 1 + + +def test_success_rate_tracker_item_tensor_path(): + class _T: + def item(self): + return 0.7 + + t = SuccessRateTracker(threshold=0.5, window=1, num_steps_per_env=1) + t.record_step({"log": {"Metrics/success_rate": _T()}}) + mean = t.end_iteration() + assert mean == pytest.approx(0.7) + assert t.history == [pytest.approx(0.7)] + + +def test_success_rate_tracker_no_data_end_iteration_returns_none(): + t = SuccessRateTracker(threshold=0.5, window=1, num_steps_per_env=1) + t.record_step({"log": {}}) + assert t._step_count == 1 + result = t.end_iteration() + assert result is None + assert t.history == [] + + +def test_parse_tf_logs_empty_dir_returns_empty(tmp_path, caplog): + import logging + + with caplog.at_level(logging.WARNING): + result = parse_tf_logs(str(tmp_path)) + assert result == {} + assert any("No TensorBoard event files" in r.getMessage() for r in caplog.records) + + +def test_check_convergence_zero_mean_returns_high_cv(): + result = check_convergence([0.0, 0.0, 0.0, 0.0, 0.0], threshold=0.3) + assert result["tail_mean"] == 0.0 + assert result["cv"] == 999.9 + assert result["passed"] is False + + +def test_success_rate_step_value_reads_scalar(): + import torch + + tag = SUCCESS_RATE_LOG_TAGS[0] + assert success_rate_step_value({tag: torch.tensor(0.75)}) == pytest.approx(0.75) + assert success_rate_step_value({tag: 0.5}) == pytest.approx(0.5) + assert success_rate_step_value({"other/metric": 1.0}) is None diff --git a/source/isaaclab/test/benchmark/test_profiling.py b/source/isaaclab/test/benchmark/test_profiling.py new file mode 100644 index 00000000000..f8396b11253 --- /dev/null +++ b/source/isaaclab/test/benchmark/test_profiling.py @@ -0,0 +1,67 @@ +# 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 benchmark cProfile parsing (Isaac-Sim-free).""" + +import cProfile +import os + +from isaaclab.test.benchmark.profiling import parse_cprofile_stats + + +def _profiled(): + def inner(n): + return sum(range(n)) + + p = cProfile.Profile() + p.enable() + for _ in range(3): + inner(1000) + p.disable() + return p + + +def test_parse_cprofile_returns_4_tuples(): + p = _profiled() + # Treat this test file's directory as an "isaaclab prefix" so functions + # defined here are captured by the filter. + prefixes = [os.path.dirname(os.path.abspath(__file__))] + rows = parse_cprofile_stats(p, prefixes, top_n=10) + assert rows, "expected at least one profiled function" + for row in rows: + assert len(row) == 4 + label, own_ms, cum_ms, calls = row + assert isinstance(label, str) + assert isinstance(own_ms, float) and isinstance(cum_ms, float) + assert isinstance(calls, int) and calls >= 1 + + +def test_whitelist_placeholder_is_4_tuple(): + p = _profiled() + prefixes = [os.path.dirname(os.path.abspath(__file__))] + rows = parse_cprofile_stats(p, prefixes, whitelist=["nonexistent.module:does_not_exist"]) + # unmatched pattern yields a placeholder row, still a 4-tuple + assert any(r[0] == "nonexistent.module:does_not_exist" and r[3] == 0 for r in rows) + + +def test_parse_cprofile_prefers_most_specific_source_root(): + namespace = {} + exec( + compile( + "def profile_target():\n return sum(range(10))", + "/tmp/repro/source/isaaclab_tasks/isaaclab_tasks/example.py", + "exec", + ), + namespace, + ) + profile = cProfile.Profile() + profile.runcall(namespace["profile_target"]) + + rows = parse_cprofile_stats( + profile, + ["/tmp/repro/source/isaaclab", "/tmp/repro/source/isaaclab_tasks"], + ) + + assert any(label == "isaaclab_tasks.example:profile_target" for label, *_ in rows) diff --git a/source/isaaclab/test/benchmark/test_recorders.py b/source/isaaclab/test/benchmark/test_recorders.py index b708035067b..003cfc66fbf 100644 --- a/source/isaaclab/test/benchmark/test_recorders.py +++ b/source/isaaclab/test/benchmark/test_recorders.py @@ -5,6 +5,10 @@ """Unit tests for benchmark recorder classes.""" +import builtins +import sys +import types + import pytest from isaaclab.test.benchmark.interfaces import MeasurementData @@ -596,12 +600,138 @@ def test_captures_core_versions(self, recorder): assert "numpy" in versions assert "isaaclab" in versions - def test_version_values_are_strings(self, recorder): - """Test that version values are strings.""" + def test_captures_renderer_runtime_versions(self, monkeypatch): + versions_by_distribution = {"ovrtx": "0.3.1", "isaaclab_ovphysx": "3.0.5"} + monkeypatch.setattr( + VersionInfoRecorder, + "_get_pkg_version", + lambda _self, distribution: versions_by_distribution.get(distribution), + ) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + assert versions["ovrtx"] == "0.3.1" + assert versions["ovphysx"] == "3.0.5" + + def test_captures_active_kit_versions(self, monkeypatch, tmp_path): + """Test that versions are captured from an active Kit runtime.""" + isaac_path = tmp_path / "isaacsim" + isaac_path.mkdir() + (isaac_path / "VERSION").write_text("6.0.0-test") + monkeypatch.setenv("ISAAC_PATH", str(isaac_path)) + + omni = types.ModuleType("omni") + kit = types.ModuleType("omni.kit") + app = types.ModuleType("omni.kit.app") + app.get_app = lambda: types.SimpleNamespace(get_build_version=lambda: "110.1.1-test") + omni.kit = kit + kit.app = app + monkeypatch.setitem(sys.modules, "omni", omni) + monkeypatch.setitem(sys.modules, "omni.kit", kit) + monkeypatch.setitem(sys.modules, "omni.kit.app", app) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + + assert versions["kit"] == "110.1.1-test" + assert versions["isaacsim"] == "6.0.0-test" + + def test_captures_isaacsim_version_without_isaac_path(self, monkeypatch): + """Test that an active Kit runtime provides the Isaac Sim application version.""" + monkeypatch.delenv("ISAAC_PATH", raising=False) + + omni = types.ModuleType("omni") + kit = types.ModuleType("omni.kit") + app = types.ModuleType("omni.kit.app") + app.get_app = lambda: types.SimpleNamespace(get_kit_version=lambda: "110.1.1-test") + omni.kit = kit + kit.app = app + isaacsim = types.ModuleType("isaacsim") + core = types.ModuleType("isaacsim.core") + version = types.ModuleType("isaacsim.core.version") + version.get_version = lambda: ("6.0.0", "rc.59", "6", "0", "0", "rc", "59", "main.0.test") + isaacsim.core = core + core.version = version + monkeypatch.setitem(sys.modules, "omni", omni) + monkeypatch.setitem(sys.modules, "omni.kit", kit) + monkeypatch.setitem(sys.modules, "omni.kit.app", app) + monkeypatch.setitem(sys.modules, "isaacsim", isaacsim) + monkeypatch.setitem(sys.modules, "isaacsim.core", core) + monkeypatch.setitem(sys.modules, "isaacsim.core.version", version) + monkeypatch.setitem(sys.modules, "carb", types.ModuleType("carb")) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + + assert versions["isaacsim"] == "6.0.0-rc.59+main.0.test" + + def test_records_null_kit_versions_without_active_kit(self, monkeypatch): + """Test that Kit versions are null when no Kit runtime is active.""" + monkeypatch.delenv("ISAAC_PATH", raising=False) + + omni = types.ModuleType("omni") + kit = types.ModuleType("omni.kit") + app = types.ModuleType("omni.kit.app") + app.get_app = lambda: None + omni.kit = kit + kit.app = app + isaacsim = types.ModuleType("isaacsim") + isaacsim.__version__ = "should-not-be-recorded" + carb = types.ModuleType("carb") + monkeypatch.setitem(sys.modules, "omni", omni) + monkeypatch.setitem(sys.modules, "isaacsim", isaacsim) + monkeypatch.setitem(sys.modules, "carb", carb) + monkeypatch.setitem(sys.modules, "omni.kit", kit) + monkeypatch.setitem(sys.modules, "omni.kit.app", app) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + + assert versions["kit"] is None + assert versions["isaacsim"] is None + + def test_records_null_kit_versions_without_importing_kit(self, monkeypatch): + """Test that Kitless runs do not import the Kit application module.""" + monkeypatch.delenv("ISAAC_PATH", raising=False) + for module_name in ("omni.kit.app", "omni.kit", "omni"): + monkeypatch.delitem(sys.modules, module_name, raising=False) + + kit_imports = [] + original_import = builtins.__import__ + + def import_without_kit(name, globals=None, locals=None, fromlist=(), level=0): + if name == "omni.kit.app": + kit_imports.append(name) + raise AssertionError("Kit must not be imported for Kitless runs") + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", import_without_kit) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + + assert kit_imports == [] + assert versions["kit"] is None + assert versions["isaacsim"] is None + + def test_records_null_renderer_runtime_versions_when_unavailable(self, monkeypatch): + """Test that unavailable renderer runtime versions are null.""" + monkeypatch.setattr(VersionInfoRecorder, "_get_pkg_version", lambda _self, _distribution: None) + + recorder = VersionInfoRecorder() + versions = recorder.get_initial_data()["version_metadata"] + metadata = {entry.name: entry.data for entry in recorder.get_data().metadata} + + assert versions["ovrtx"] is None + assert versions["ovphysx"] is None + assert metadata["ovrtx_version"] is None + assert metadata["ovphysx_version"] is None + + def test_version_values_are_strings_or_null(self, recorder): + """Test that version values are strings or null for runtime packages.""" data = recorder.get_initial_data() - for version in data["version_metadata"].values(): - assert isinstance(version, str) - assert len(version) > 0 + nullable_versions = {"kit", "isaacsim", "ovrtx", "ovphysx"} + for name, version in data["version_metadata"].items(): + if name in nullable_versions: + assert version is None or isinstance(version, str) + else: + assert isinstance(version, str) + assert len(version) > 0 def test_git_info_structure(self, recorder): """Test that git info has expected fields when available.""" diff --git a/source/isaaclab/test/benchmark/test_stepping.py b/source/isaaclab/test/benchmark/test_stepping.py new file mode 100644 index 00000000000..d2c53117230 --- /dev/null +++ b/source/isaaclab/test/benchmark/test_stepping.py @@ -0,0 +1,75 @@ +# 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 runtime stepping helpers.""" + +import numpy as np +import torch + +from isaaclab.test.benchmark.stepping import run_runtime_loop, sample_random_actions + + +class _Space: + def __init__(self, n): + self.shape = (n,) + + +class _Env: + class _U: + num_envs = 4 + device = "cpu" + single_action_space = _Space(3) + + def __init__(self): + self.unwrapped = _Env._U() + self.reset_called = False + self.steps = 0 + + def reset(self): + self.reset_called = True + + def step(self, actions): + self.steps += 1 + return (None, None, None, {}) + + +def test_sample_single_agent_shape_and_range(): + a = sample_random_actions(_Env()) + assert isinstance(a, torch.Tensor) + assert tuple(a.shape) == (4, 3) + assert float(a.min()) >= -1.0 - 1e-6 and float(a.max()) <= 1.0 + 1e-6 + + +def test_run_runtime_loop_steps_and_times(): + env = _Env() + times = run_runtime_loop(env, num_frames=5) + assert env.reset_called and env.steps == 5 + assert len(times) == 5 and all(t >= 0.0 for t in times) + + +class _MASpace: + def __init__(self, n): + self._n = n + + def sample(self): + return np.zeros(self._n, dtype=np.float32) + + +class _MAEnv: + class _U: + num_envs = 4 + device = "cpu" + action_spaces = {"a0": _MASpace(3), "a1": _MASpace(2)} + + def __init__(self): + self.unwrapped = _MAEnv._U() + + +def test_sample_multi_agent_returns_dict_per_agent(): + actions = sample_random_actions(_MAEnv()) + assert isinstance(actions, dict) + assert set(actions) == {"a0", "a1"} + assert tuple(actions["a0"].shape) == (4, 3) + assert tuple(actions["a1"].shape) == (4, 2) From 029fcb4f44e9bc8345d5e880eb9135ee7188f26c Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Mon, 6 Jul 2026 11:16:28 +0200 Subject: [PATCH 091/139] Fix OVPhysX runtime pin (#6333) ## Summary - pin the OVPhysX optional runtime to `0.5.2+head.f62c22207c` - align the daily compatibility job with the tested runtime artifact - add an OVPhysX changelog fragment ## Testing - `./isaaclab.sh -p -c "..."` (verified both explicit version constraints) - `./isaaclab.sh -p -m pytest tools/changelog/test/test_validate.py` - `./isaaclab.sh -p tools/changelog/cli.py check develop` - `./isaaclab.sh -f` --- .github/workflows/daily-compatibility.yml | 2 +- .github/workflows/license-check.yaml | 138 ++++++++++++++++++ .../antoiner-pin-ovphysx-0-5-2.rst | 5 + source/isaaclab_ovphysx/pyproject.toml | 2 +- 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst diff --git a/.github/workflows/daily-compatibility.yml b/.github/workflows/daily-compatibility.yml index 472b07300a5..a92a9e6ccf0 100644 --- a/.github/workflows/daily-compatibility.yml +++ b/.github/workflows/daily-compatibility.yml @@ -111,7 +111,7 @@ jobs: image-tag: ${{ env.DOCKER_IMAGE_TAG }} pytest-options: "" filter-pattern: "isaaclab_tasks" - extra-pip-packages: "ovrtx ovphysx==0.4.13" + extra-pip-packages: "ovrtx ovphysx==0.5.2+head.f62c22207c" - name: Copy All Test Results from IsaacLab Tasks Container run: | diff --git a/.github/workflows/license-check.yaml b/.github/workflows/license-check.yaml index fb82761f0b9..45c2befc2dc 100644 --- a/.github/workflows/license-check.yaml +++ b/.github/workflows/license-check.yaml @@ -15,7 +15,12 @@ concurrency: jobs: license-check: + # Fork PRs cannot access NGC_API_KEY, so they cannot resolve the immutable + # OVPhysX wheel required by the development extras. + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-24.04 + env: + NGC_API_KEY: ${{ secrets.NGC_API_KEY }} steps: - name: Checkout code @@ -23,6 +28,132 @@ jobs: with: filter: tree:0 + - name: Load OVPhysX wheelhouse configuration + id: config + shell: bash + run: | + set -euo pipefail + resource=$(yq -r .ovphysx_wheelhouse_resource .github/workflows/config.yaml) + echo "wheelhouse_resource=${resource}" >> "$GITHUB_OUTPUT" + + - name: Restore NGC CLI cache + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/ngc-cli-cache/ngccli_linux.zip + key: ngc-cli-${{ runner.os }}-${{ runner.arch }}-4.20.0-5cf084c88998c58ad8abf7849d2d1b41d578423886eb03018df10194e341d35b + + - name: Extract OVPhysX wheelhouse + id: extract-wheelhouse + shell: bash + env: + WHEELHOUSE_RESOURCE: ${{ steps.config.outputs.wheelhouse_resource }} + run: | + set -euo pipefail + + if [ -z "${NGC_API_KEY:-}" ]; then + echo "::error::NGC_API_KEY is unavailable; cannot download the configured OVPhysX wheelhouse" + exit 1 + fi + + NGC_CLI_VERSION="4.20.0" + NGC_CLI_SHA256="5cf084c88998c58ad8abf7849d2d1b41d578423886eb03018df10194e341d35b" + NGC_CLI_URL="https://api.ngc.nvidia.com/v2/resources/nvidia/ngc-apps/ngc_cli/versions/${NGC_CLI_VERSION}/files/ngccli_linux.zip" + + wheelhouse_root="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ovphysx-wheelhouse.XXXXXX")" + download_root="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ovphysx-wheelhouse-download.XXXXXX")" + ngc_home="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ovphysx-ngc-home.XXXXXX")" + ngc_unpack_dir="" + preserve_wheelhouse_root=false + cleanup() { + if [ "$preserve_wheelhouse_root" != "true" ]; then + rm -rf "$wheelhouse_root" + fi + rm -rf "$download_root" "$ngc_home" + if [ -n "$ngc_unpack_dir" ]; then + rm -rf "$ngc_unpack_dir" + fi + } + trap cleanup EXIT + + cache_dir="${RUNNER_TEMP:-/tmp}/ngc-cli-cache" + ngc_zip="${cache_dir}/ngccli_linux.zip" + mkdir -p "$cache_dir" + if [ ! -f "$ngc_zip" ]; then + echo "Downloading NGC CLI ${NGC_CLI_VERSION}" + curl -fsSL -o "$ngc_zip" "$NGC_CLI_URL" + fi + echo "${NGC_CLI_SHA256} ${ngc_zip}" | sha256sum -c - + ngc_unpack_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ngc-cli.XXXXXX")" + unzip -q "$ngc_zip" -d "$ngc_unpack_dir" + export PATH="${ngc_unpack_dir}/ngc-cli:${PATH}" + + export NGC_CLI_API_KEY="$NGC_API_KEY" + export NGC_CLI_ORG=nvidian + export NGC_CLI_TEAM=no-team + export NGC_CLI_HOME="$ngc_home" + + ngc --version + echo "Downloading wheelhouse resource: $WHEELHOUSE_RESOURCE" + ngc registry resource download-version "$WHEELHOUSE_RESOURCE" --dest "$download_root" + + mapfile -t manifests < <(find "$download_root" -type f -name manifest.json) + if [ "${#manifests[@]}" -ne 1 ]; then + echo "::error::expected exactly one manifest.json in downloaded wheelhouse resource, found ${#manifests[@]}" + printf '%s\n' "${manifests[@]}" + exit 1 + fi + + payload_dir="$(dirname "${manifests[0]}")" + if [ ! -d "${payload_dir}/wheelhouse" ]; then + echo "::error::downloaded wheelhouse resource is missing wheelhouse/ next to manifest.json" + exit 1 + fi + + mkdir -p "$wheelhouse_root/wheelhouse" + cp "${payload_dir}/manifest.json" "$wheelhouse_root/manifest.json" + cp "${payload_dir}/wheelhouse/"*.whl "$wheelhouse_root/wheelhouse/" + + python3 - <<'PY' "$wheelhouse_root/manifest.json" "$wheelhouse_root/wheelhouse" + import hashlib + import json + import pathlib + import sys + + manifest_path = pathlib.Path(sys.argv[1]) + wheelhouse_dir = pathlib.Path(sys.argv[2]) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + expected = { + "artifact": "ovphysx-wheelhouse", + "platform": "manylinux_2_35_x86_64", + } + for key, value in expected.items(): + actual = manifest.get(key) + if actual != value: + raise SystemExit(f"manifest {key!r} mismatch: expected {value!r}, got {actual!r}") + + wheels = manifest.get("wheels") + if not isinstance(wheels, list) or not wheels: + raise SystemExit("manifest has no wheels list") + + for wheel in wheels: + filename = wheel.get("file") + expected_sha = wheel.get("sha256") + if not filename or not expected_sha: + raise SystemExit(f"invalid wheel manifest entry: {wheel!r}") + wheel_path = wheelhouse_dir / filename + if not wheel_path.is_file(): + raise SystemExit(f"manifest wheel is missing from wheelhouse: {filename}") + actual_sha = hashlib.sha256(wheel_path.read_bytes()).hexdigest() + if actual_sha != expected_sha: + raise SystemExit(f"sha256 mismatch for {filename}: expected {expected_sha}, got {actual_sha}") + + print(f"Validated {len(wheels)} wheelhouse wheels from {manifest_path}") + PY + + echo "wheelhouse_root=$wheelhouse_root" >> "$GITHUB_OUTPUT" + echo "wheelhouse_dir=$wheelhouse_root/wheelhouse" >> "$GITHUB_OUTPUT" + # - name: Install jq # run: sudo apt-get update && sudo apt-get install -y jq @@ -60,6 +191,7 @@ jobs: OMNI_KIT_ACCEPT_EULA: yes ACCEPT_EULA: Y ISAACSIM_ACCEPT_EULA: YES + UV_FIND_LINKS: ${{ steps.extract-wheelhouse.outputs.wheelhouse_dir }} run: | uv sync --extra all # Isaac Sim isn't in the dev pyproject; sync first so it isn't pruned. @@ -153,3 +285,9 @@ jobs: else echo "All packages were checked." fi + + - name: Clean up OVPhysX wheelhouse + if: always() + shell: bash + run: | + rm -rf "${{ steps.extract-wheelhouse.outputs.wheelhouse_root }}" diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst new file mode 100644 index 00000000000..55367b600ea --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed the OVPhysX optional runtime dependency to install + ``ovphysx==0.5.2+head.f62c22207c``, matching the supported runtime wheel. diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index aa89c97b054..75755790e38 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -23,7 +23,7 @@ Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" [project.optional-dependencies] -ovphysx = ["ovphysx==0.4.13"] +ovphysx = ["ovphysx==0.5.2+head.f62c22207c"] [tool.setuptools] include-package-data = true From 447d8c5d02c5db39cfd5e27e871a2dd822cbc15f Mon Sep 17 00:00:00 2001 From: Agon Serifi <39410867+aserifi@users.noreply.github.com> Date: Mon, 6 Jul 2026 05:32:18 -0600 Subject: [PATCH 092/139] [Kamino] Small Cleanup (#6359) Revisited the solver parameters. Now, the environments consistently use num_substeps=1. Tuned also the settings for the DR Legs task, making training 3x faster. ## 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/aserifi-kamino_cleanup.rst | 12 ++++++++++++ .../contrib/dr_legs/hold_pose_env_cfg.py | 11 +++++------ .../contrib/velocity/config/a1/flat_env_cfg.py | 2 +- .../contrib/velocity/config/anymal_b/flat_env_cfg.py | 2 +- .../contrib/velocity/config/anymal_c/flat_env_cfg.py | 2 +- .../contrib/velocity/config/go1/flat_env_cfg.py | 2 +- .../isaaclab_tasks/core/cabinet/cabinet_env_cfg.py | 2 +- .../core/cartpole/cartpole_direct_env_cfg.py | 1 - .../core/cartpole/cartpole_manager_env_cfg.py | 1 - .../core/locomotion/ant/ant_direct_env_cfg.py | 1 - .../core/locomotion/ant/ant_manager_env_cfg.py | 1 - .../isaaclab_tasks/core/reach/reach_env_cfg.py | 1 - .../config/shadow_hand/shadow_hand_env_cfg.py | 2 +- .../core/velocity/config/cassie/flat_env_cfg.py | 2 +- .../core/velocity/config/g1/flat_env_cfg.py | 2 +- .../core/velocity/config/go2/flat_env_cfg.py | 2 +- .../core/velocity/config/h1/flat_env_cfg.py | 2 +- .../core/velocity/config/spot/flat_env_cfg.py | 2 +- 18 files changed, 28 insertions(+), 22 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst diff --git a/source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst b/source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst new file mode 100644 index 00000000000..d200dbd19e4 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst @@ -0,0 +1,12 @@ +Changed +^^^^^^^ + +* Changed the Disney DR Legs walk / hold-pose Kamino stepping to ``sim.dt = 1/150`` + with ``decimation = 3`` (previously ``0.004`` / ``5``). This keeps the 50 Hz control + rate while reducing the number of Kamino solver calls per control step from 5 to 3, + for roughly 1.6x faster simulation with equivalent task performance. +* Changed the ``newton_kamino`` presets of the velocity (A1, AnymalB, AnymalC, Go1, Go2, + Cassie, G1, H1, Spot), cabinet, and shadow-hand reorient tasks to use the default + single physics substep (removed the ``num_substeps=2`` override), and dropped the + redundant explicit ``num_substeps`` overrides from the cartpole, ant, and reach Kamino + presets. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py index e710e8c4a06..a2c15669aef 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py @@ -67,9 +67,8 @@ def _kamino_newton_cfg() -> NewtonCfg: padmm_warmstart_mode="containers", padmm_contact_warmstart_method="geom_pair_net_force", padmm_use_graph_conditionals=False, - max_contacts_per_world=50, + max_contacts_per_world=32, ), - num_substeps=2, use_cuda_graph=True, default_shape_cfg=NewtonShapeCfg(margin=0.0, gap=0.001), ) @@ -195,7 +194,7 @@ class EventCfg: "pose_range": { "x": (-0.05, 0.05), "y": (-0.05, 0.05), - "z": (-0.02, 0.02), + "z": (0.01, 0.015), "roll": (-0.1, 0.1), "pitch": (-0.1, 0.1), "yaw": (-3.14159, 3.14159), @@ -266,14 +265,14 @@ class DrLegsHoldPoseEnvCfg(ManagerBasedRLEnvCfg): rewards: RewardsCfg = RewardsCfg() terminations: TerminationsCfg = TerminationsCfg() sim: SimulationCfg = SimulationCfg( - dt=0.004, - render_interval=5, + dt=1 / 150, + render_interval=3, physics=DrLegsPhysicsCfg(), physics_material=_PHYSICS_MATERIAL, ) def __post_init__(self) -> None: - self.decimation = 5 + self.decimation = 3 self.episode_length_s = 10.0 self.viewer.eye = (1.5, 0.5, 0.5) self.viewer.lookat = (0.0, 0.0, 0.265) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py index 8436893db82..c794ef52fbe 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py index dd4765cbe73..49eb5234cac 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py index 09619985e32..dfe215c777a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py index e3e1daa8467..736b83784a9 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py @@ -28,7 +28,7 @@ class PhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py index 3df3825a440..870825b2db3 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py @@ -76,7 +76,7 @@ class CabinetSimCfg(PresetCfg): newton_kamino: SimulationCfg = SimulationCfg( dt=1 / 600, render_interval=1, - physics=NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=1), + physics=NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)), ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py index 7637e42ab7d..b4b418ab4e7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py @@ -57,7 +57,6 @@ class CartpolePhysicsCfg(PresetCfg): collision_detector_pipeline="unified", collision_detector_max_contacts_per_pair=8, ), - num_substeps=1, debug_mode=False, use_cuda_graph=True, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py index 73f59e6885b..2454f4890fa 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py @@ -67,7 +67,6 @@ class CartpolePhysicsCfg(PresetCfg): collision_detector_pipeline="unified", collision_detector_max_contacts_per_pair=8, ), - num_substeps=1, debug_mode=False, use_cuda_graph=True, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py index 510526a5998..3e47bbb896b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py @@ -56,7 +56,6 @@ class AntPhysicsCfg(PresetCfg): collision_detector_pipeline="unified", collision_detector_max_contacts_per_pair=8, ), - num_substeps=2, debug_mode=False, use_cuda_graph=True, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py index e89988dd7bc..84d09abeb54 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py @@ -63,7 +63,6 @@ class AntPhysicsCfg(PresetCfg): collision_detector_pipeline="unified", collision_detector_max_contacts_per_pair=8, ), - num_substeps=2, debug_mode=False, use_cuda_graph=True, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py index bed549290c0..26b40be6cc8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py @@ -45,7 +45,6 @@ class ReachPhysicsCfg(PresetCfg): ) newton_kamino: NewtonCfg = NewtonCfg( solver_cfg=KaminoSolverCfg(max_contacts_per_world=32), - num_substeps=2, ) default = physx diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py index a77d33b9b52..f6bdae2bd0d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py @@ -291,7 +291,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) default = physx - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=128), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=128)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py index 745c0852617..684594ca217 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py index 34ae1b08091..ed735096f3b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py index ab145d6a468..11c6aa509d5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py index 4e54676fa1a..ab636aac9c3 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py @@ -29,7 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py index b3ac1fb949f..671aa659cd2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py @@ -49,7 +49,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, default_shape_cfg=NewtonShapeCfg(margin=0.01), ) - newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64), num_substeps=2) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) ## From 6216fe71a6071ae73341ceffb1ca3c10b77054d1 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Mon, 6 Jul 2026 15:21:54 +0200 Subject: [PATCH 093/139] Fix pre-launch runtime imports (#6275) ## Summary - Deferred Kit and USD imports from modules loaded during task and configuration resolution until the runtime functionality requires them. - Prevented overlapping standalone USD package providers on x86 by restricting `usd-exchange` to ARM, where `usd-core` is not installed. - Added a metadata regression test that keeps the standalone USD providers platform-disjoint. ## Root Cause The failing installation-training environments loaded incompatible USD Python bindings in one process, with traces showing both `pxrInternal_v0_25_5` and `pxrInternal_v0_25_11`. On x86, `usd-core` and `usd-exchange` both supplied USD/`pxr` components. Their coexistence made native binding initialization depend on installation and import order, producing wrapper and converter registration failures. ## Test Plan - `./isaaclab.sh -p -m pytest source/isaaclab/test/cli/test_source_package_metadata.py` - `./isaaclab.sh -f` - Installation Tests: fresh successful run plus 10 successful reruns, each passing on x86 and ARM. ## CI Validation - Fresh run: x86 `84271139083`, ARM `84271139099` - Reruns 1-5: x86 `84278649544`, `84284392796`, `84297138420`, `84306527331`, `84317891132`; ARM `84278649480`, `84284392767`, `84297138393`, `84306527439`, `84317891100` - Reruns 6-10: x86 `84343375232`, `84356535895`, `84361942197`, `84373650598`, `84379046431`; ARM `84343375249`, `84356535827`, `84361942203`, `84373650527`, `84379046460` --------- Signed-off-by: Antoine RICHARD Co-authored-by: hujc --- .../fix-rl-train-prelaunch-imports.rst | 4 +++ .../isaaclab/scene/interactive_scene.py | 4 ++- .../scene_data/scene_data_provider.py | 4 +-- .../isaaclab/isaaclab/sensors/sensor_base.py | 6 ++-- source/isaaclab/isaaclab/sim/utils/queries.py | 3 +- source/isaaclab/isaaclab/sim/utils/stage.py | 29 ++++++++++++++----- source/isaaclab/pyproject.toml | 2 +- .../test/cli/test_source_package_metadata.py | 12 ++++++++ 8 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst diff --git a/source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst b/source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst new file mode 100644 index 00000000000..533762833f0 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed intermittent kitless Newton training startup failures by avoiding overlapping standalone USD package providers on x86. diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 11df307b235..05500207663 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -13,6 +13,7 @@ from isaaclab_physx.assets import SurfaceGripper from isaaclab.renderers.base_renderer import BaseRenderer + from isaaclab.terrains.terrain_importer import TerrainImporter import torch import warp as wp @@ -35,7 +36,6 @@ from isaaclab.sim import SimulationContext from isaaclab.sim.utils.stage import get_current_stage, get_current_stage_id from isaaclab.sim.views import FrameView -from isaaclab.terrains import TerrainImporter, TerrainImporterCfg # Note: This is a temporary import for the VisuoTactileSensorCfg class. # It will be removed once the VisuoTactileSensor class is added to the core Isaac Lab framework. @@ -741,6 +741,8 @@ def _add_entities_from_cfg(self): # noqa: C901 """Add scene entities from the config.""" from isaaclab_physx.assets import SurfaceGripperCfg # noqa: PLC0415 + from isaaclab.terrains.terrain_importer_cfg import TerrainImporterCfg # noqa: PLC0415 + # store paths that are in global collision filter self._global_prim_paths = list() # Resolve the env-namespace convention from the cloner cfg once for this pass. diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index ba4c051920e..8d770a3da57 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -13,8 +13,6 @@ import numpy as np import warp as wp -from pxr import UsdGeom - import isaaclab.sim as sim_utils from .scene_data_backend import SceneDataBackend, SceneDataFormat @@ -441,6 +439,8 @@ def _walk_camera_prims(stage: Usd.Stage | None) -> dict[str, Any] | None: if stage is None: return None + from pxr import UsdGeom # noqa: PLC0415 + shared_paths: list[str] = [] instances: dict[str, list[tuple[int, str]]] = {} num_envs = -1 diff --git a/source/isaaclab/isaaclab/sensors/sensor_base.py b/source/isaaclab/isaaclab/sensors/sensor_base.py index 8b885a91d14..ee4e5885d78 100644 --- a/source/isaaclab/isaaclab/sensors/sensor_base.py +++ b/source/isaaclab/isaaclab/sensors/sensor_base.py @@ -20,10 +20,7 @@ import warp as wp -from pxr import UsdPhysics - import isaaclab.sim as sim_utils -from isaaclab.cloner.cloner_utils import iter_clone_plan_matches from isaaclab.physics import PhysicsEvent, PhysicsManager from isaaclab.sim.utils.queries import get_first_matching_ancestor_prim from isaaclab.sim.utils.transforms import resolve_prim_pose @@ -231,6 +228,8 @@ def _initialize_impl(self): clone_plan = self._clone_plan clone_plan_matches = () if clone_plan is not None: + from isaaclab.cloner.cloner_utils import iter_clone_plan_matches # noqa: PLC0415 + clone_plan_matches = tuple(iter_clone_plan_matches(clone_plan, self.cfg.prim_path)) if clone_plan_matches: self._parent_prims = [] @@ -457,6 +456,7 @@ def _resolve_rigid_body_ancestor_expr( mounted directly at the body origin. """ prim, target_expr = sim_utils.resolve_matching_prims_from_source(self.cfg.prim_path)[0] + from pxr import UsdPhysics # noqa: PLC0415 ancestor_prim = get_first_matching_ancestor_prim( prim.GetPath(), predicate=lambda _prim: _prim.HasAPI(UsdPhysics.RigidBodyAPI) diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index 4d5dcb3ac02..1b38f0aae47 100644 --- a/source/isaaclab/isaaclab/sim/utils/queries.py +++ b/source/isaaclab/isaaclab/sim/utils/queries.py @@ -12,7 +12,6 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from isaaclab.cloner.cloner_utils import resolve_clone_plan_source from isaaclab.sim.simulation_context import SimulationContext from .stage import get_current_stage @@ -418,6 +417,8 @@ def resolve_matching_prims_from_source( RuntimeError: If no prim matches ``path_expr`` and ``raise_if_no_matches`` is True. """ plan = SimulationContext.instance().get_clone_plan() + from isaaclab.cloner.cloner_utils import resolve_clone_plan_source # noqa: PLC0415 + resolved = resolve_clone_plan_source(path_expr, plan) if plan is not None else None if resolved is not None: source_path, dest_glob, asset_suffix = resolved diff --git a/source/isaaclab/isaaclab/sim/utils/stage.py b/source/isaaclab/isaaclab/sim/utils/stage.py index d4c87f4c2e7..b7eaff26937 100644 --- a/source/isaaclab/isaaclab/sim/utils/stage.py +++ b/source/isaaclab/isaaclab/sim/utils/stage.py @@ -136,15 +136,24 @@ def _modify_path(asset_path: str) -> str: # ############################################################################## -try: - # _context is a singleton design in isaacsim and for that reason - # until we fully replace all modules that references the singleton(such as XformPrim, Prim ....), we have to point - # that singleton to this _context - from isaacsim.core.experimental.utils import stage as sim_stage +_isaacsim_stage_context_synced = False + +def _sync_isaacsim_stage_context() -> None: + """Point Isaac Sim's stage helper at Isaac Lab's thread-local stage context.""" + global _isaacsim_stage_context_synced + + if _isaacsim_stage_context_synced or not has_kit(): + return + + try: + from isaacsim.core.experimental.utils import stage as sim_stage # noqa: PLC0415 + except ImportError: + return + + # Isaac Sim stage helpers read this singleton context. sim_stage._context = _context # type: ignore -except ImportError: - pass + _isaacsim_stage_context_synced = True def create_new_stage() -> Usd.Stage: @@ -167,6 +176,8 @@ def create_new_stage() -> Usd.Stage: sessionLayer=Sdf.Find('anon:0x7fba6c01c5c0:World7-session.usda'), pathResolverContext=) """ + _sync_isaacsim_stage_context() + from pxr import Usd, UsdUtils # noqa: PLC0415 stage: Usd.Stage = Usd.Stage.CreateInMemory() @@ -226,6 +237,8 @@ def open_stage(usd_path: str) -> Usd.Stage: ValueError: When input path is not a supported file type by USD. RuntimeError: When failed to open the stage. """ + _sync_isaacsim_stage_context() + from pxr import Usd # noqa: PLC0415 if not Usd.Stage.IsSupportedFile(usd_path): @@ -523,6 +536,8 @@ def get_current_stage(fabric: bool = False) -> Usd.Stage: sessionLayer=Sdf.Find('anon:0x7fba6c01c5c0:World7-session.usda'), pathResolverContext=) """ + _sync_isaacsim_stage_context() + # First check thread-local context for an in-memory stage stage = getattr(_context, "stage", None) if stage is not None: diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 66e349f1c51..e3c8cbd2c74 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -55,7 +55,7 @@ dependencies = [ "daqp==0.8.5 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", # OpenUSD (kit-less mode) "usd-core>=25.11,<26.0 ; platform_machine in 'x86_64 AMD64'", - "usd-exchange>=2.2 ; platform_machine in 'x86_64 AMD64 aarch64 arm64'", + "usd-exchange>=2.2 ; platform_machine in 'aarch64 arm64'", # avoid broken hf-xet pre-release cached on NVIDIA Artifactory "hf-xet>=1.4.1,<2.0.0 ; platform_machine in 'x86_64 AMD64 aarch64 arm64'", ] diff --git a/source/isaaclab/test/cli/test_source_package_metadata.py b/source/isaaclab/test/cli/test_source_package_metadata.py index ec2b1c35461..ceeec3e6d2b 100644 --- a/source/isaaclab/test/cli/test_source_package_metadata.py +++ b/source/isaaclab/test/cli/test_source_package_metadata.py @@ -30,3 +30,15 @@ def test_isaaclab_usd_core_pin_stays_on_isaacsim_compatible_usd25_abi(): ] assert usd_core_dependencies == ["usd-core>=25.11,<26.0 ; platform_machine in 'x86_64 AMD64'"] + + +def test_isaaclab_standalone_usd_providers_are_platform_disjoint(): + """Standalone USD packages must not overlap on platforms where both ship ``pxr``.""" + with (_repo_root() / "source/isaaclab/pyproject.toml").open("rb") as f: + pyproject = tomllib.load(f) + + usd_exchange_dependencies = [ + dependency for dependency in pyproject["project"]["dependencies"] if dependency.startswith("usd-exchange") + ] + + assert usd_exchange_dependencies == ["usd-exchange>=2.2 ; platform_machine in 'aarch64 arm64'"] From 5f2db9902baa724427b7aa654835aa559fce64e8 Mon Sep 17 00:00:00 2001 From: Ruben Grandia Date: Mon, 6 Jul 2026 16:48:22 +0200 Subject: [PATCH 094/139] [Kamino] fourbar-pole swingup task (#6327) # Description Fourbar-pole swingup RL example that uses Kamino. Builds on the core Kamino changes in https://github.com/isaac-sim/IsaacLab/pull/5962 The USD model is added to `newton-assets` in https://github.com/newton-physics/newton-assets/pull/44 See video for a trained policy. 5s episode -> reset -> 5s episode. https://github.com/user-attachments/assets/9deb0597-6653-4614-b05f-5f3a03f0ff74 ## 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: Agon Serifi Co-authored-by: Cursor Co-authored-by: Antoine RICHARD --- .../changelog.d/rgrandia-fourbar-pole.rst | 5 + .../isaaclab_assets/__init__.pyi | 2 + .../isaaclab_assets/robots/__init__.pyi | 2 + .../isaaclab_assets/robots/fourbar_pole.py | 64 +++++ .../changelog.d/fourbar-pole-swingup.rst | 5 + .../core/fourbar_pole/__init__.py | 24 ++ .../core/fourbar_pole/agents/__init__.py | 4 + .../agents/rsl_rl_manager_ppo_cfg.py | 46 ++++ .../fourbar_pole_manager_env_cfg.py | 256 ++++++++++++++++++ .../core/fourbar_pole/mdp/__init__.py | 10 + .../core/fourbar_pole/mdp/__init__.pyi | 21 ++ .../core/fourbar_pole/mdp/rewards.py | 91 +++++++ 12 files changed, 530 insertions(+) create mode 100644 source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst create mode 100644 source/isaaclab_assets/isaaclab_assets/robots/fourbar_pole.py create mode 100644 source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/rsl_rl_manager_ppo_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/fourbar_pole_manager_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.pyi create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py diff --git a/source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst b/source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst new file mode 100644 index 00000000000..30f654eedda --- /dev/null +++ b/source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added :data:`~isaaclab_assets.robots.fourbar_pole.FOURBAR_POLE_CFG` for a parallel + four-bar linkage with an inverted pendulum pole on the coupler. diff --git a/source/isaaclab_assets/isaaclab_assets/__init__.pyi b/source/isaaclab_assets/isaaclab_assets/__init__.pyi index dc69e0b8c71..84f2c12f68e 100644 --- a/source/isaaclab_assets/isaaclab_assets/__init__.pyi +++ b/source/isaaclab_assets/isaaclab_assets/__init__.pyi @@ -24,6 +24,7 @@ __all__ = [ "FRANKA_PANDA_CFG", "FRANKA_PANDA_HIGH_PD_CFG", "FRANKA_ROBOTIQ_GRIPPER_CFG", + "FOURBAR_POLE_CFG", "GALBOT_ONE_CHARLIE_CFG", "HUMANOID_CFG", "HUMANOID_28_CFG", @@ -87,6 +88,7 @@ from .robots import ( FRANKA_PANDA_CFG, FRANKA_PANDA_HIGH_PD_CFG, FRANKA_ROBOTIQ_GRIPPER_CFG, + FOURBAR_POLE_CFG, GALBOT_ONE_CHARLIE_CFG, HUMANOID_CFG, HUMANOID_28_CFG, diff --git a/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi b/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi index 2440b5d8a79..246bfc05fb0 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi +++ b/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi @@ -24,6 +24,7 @@ __all__ = [ "FRANKA_PANDA_CFG", "FRANKA_PANDA_HIGH_PD_CFG", "FRANKA_ROBOTIQ_GRIPPER_CFG", + "FOURBAR_POLE_CFG", "GALBOT_ONE_CHARLIE_CFG", "HUMANOID_CFG", "HUMANOID_28_CFG", @@ -77,6 +78,7 @@ from .cartpole import CARTPOLE_CFG from .cassie import CASSIE_CFG from .fourier import GR1T2_CFG, GR1T2_HIGH_PD_CFG from .franka import FRANKA_PANDA_CFG, FRANKA_PANDA_HIGH_PD_CFG, FRANKA_ROBOTIQ_GRIPPER_CFG +from .fourbar_pole import FOURBAR_POLE_CFG from .galbot import GALBOT_ONE_CHARLIE_CFG from .humanoid import HUMANOID_CFG from .humanoid_28 import HUMANOID_28_CFG diff --git a/source/isaaclab_assets/isaaclab_assets/robots/fourbar_pole.py b/source/isaaclab_assets/isaaclab_assets/robots/fourbar_pole.py new file mode 100644 index 00000000000..a6b3b266f24 --- /dev/null +++ b/source/isaaclab_assets/isaaclab_assets/robots/fourbar_pole.py @@ -0,0 +1,64 @@ +# 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 a parallel four-bar linkage with an inverted pendulum pole on the coupler.""" + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets import ArticulationCfg +from isaaclab.utils.assets import NEWTON_ASSET_DIR, retrieve_git_asset_path + +_FOURBAR_POLE_USD = retrieve_git_asset_path(NEWTON_ASSET_DIR, "fourbar_pole/usd/fourbar_pole.usda") + +## +# Configuration +## + +FOURBAR_POLE_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=_FOURBAR_POLE_USD, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + rigid_body_enabled=True, + max_linear_velocity=1000.0, + max_angular_velocity=1000.0, + max_depenetration_velocity=100.0, + enable_gyroscopic_forces=True, + ), + articulation_props=sim_utils.ArticulationRootPropertiesCfg( + enabled_self_collisions=False, + solver_position_iteration_count=4, + solver_velocity_iteration_count=0, + sleep_threshold=0.005, + stabilization_threshold=0.001, + ), + ), + init_state=ArticulationCfg.InitialStateCfg( + pos=(0.0, 0.0, 1.5), + joint_pos={ + "ground_to_crank": 0.0, + "crank_to_coupler": 0.0, + "coupler_to_rocker": 0.0, + "coupler_to_pole": 0.0, + }, + ), + actuators={ + "fourbar_actuator": ImplicitActuatorCfg( + joint_names_expr=["ground_to_crank"], + effort_limit_sim=400.0, + stiffness=0.0, + damping=10.0, + ), + # Add an actuator to the pole even though it is not actuated by the user. + # This makes the Kamino forward kinematics solver treat the pole as + # an actuated joint and preserve its user-specified joint position on resets. + "pole_actuator": ImplicitActuatorCfg( + joint_names_expr=["coupler_to_pole"], + effort_limit_sim=400.0, + stiffness=0.0, + damping=0.0, + ), + }, +) +"""Configuration for a parallel four-bar linkage with a pole on the coupler midpoint.""" diff --git a/source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst b/source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst new file mode 100644 index 00000000000..0bd1928b8f8 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_tasks.core.fourbar_pole.fourbar_pole_manager_env_cfg.FourbarPoleSwingupEnvCfg` + as the ``Isaac-Fourbar-Pole-Swingup`` Gym environment for four-bar pole swing-up with the Kamino solver. diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/__init__.py new file mode 100644 index 00000000000..b30913f7e05 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/__init__.py @@ -0,0 +1,24 @@ +# 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 + +"""Fourbar-pole swing-up environment.""" + +import gymnasium as gym + +from . import agents + +## +# Register Gym environments -- manager-based workflow. +## + +gym.register( + id="Isaac-Fourbar-Pole-Swingup", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.fourbar_pole_manager_env_cfg:FourbarPoleSwingupEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_manager_ppo_cfg:FourbarPolePPORunnerCfg", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/__init__.py new file mode 100644 index 00000000000..460a3056908 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/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/core/fourbar_pole/agents/rsl_rl_manager_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/rsl_rl_manager_ppo_cfg.py new file mode 100644 index 00000000000..c37510ea4b0 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/rsl_rl_manager_ppo_cfg.py @@ -0,0 +1,46 @@ +# 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 ( + RslRlMLPModelCfg, + RslRlOnPolicyRunnerCfg, + RslRlPpoAlgorithmCfg, +) + + +@configclass +class FourbarPolePPORunnerCfg(RslRlOnPolicyRunnerCfg): + num_steps_per_env = 16 + max_iterations = 300 + save_interval = 50 + experiment_name = "fourbar_pole" + obs_groups = {"actor": ["policy"], "critic": ["policy"]} + 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=False, + ) + algorithm = RslRlPpoAlgorithmCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.001, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/fourbar_pole_manager_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/fourbar_pole_manager_env_cfg.py new file mode 100644 index 00000000000..4708f24746f --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/fourbar_pole_manager_env_cfg.py @@ -0,0 +1,256 @@ +# 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 copy +import math +from dataclasses import MISSING + +from isaaclab_newton.physics import KaminoSolverCfg, NewtonCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import ArticulationCfg, AssetBaseCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +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.utils.configclass import configclass +from isaaclab.visualizers import VisualizerCfg + +import isaaclab_tasks.core.fourbar_pole.mdp as mdp +from isaaclab_tasks.core.fourbar_pole.mdp.rewards import UprightSuccessRateCommandCfg +from isaaclab_tasks.utils import PresetCfg + +## +# Pre-defined configs +## +from isaaclab_assets.robots.fourbar_pole import FOURBAR_POLE_CFG # isort:skip + + +## +# Physics backend presets +## + + +@configclass +class FourbarPolePhysicsCfg(PresetCfg): + """Physics backends for the fourbar-pole task. + + Only the kamino (Newton constraint) solver is wired up for now; the closed + four-bar kinematic loop is resolved as an equality constraint by kamino. The + ``default`` field mirrors ``newton_kamino`` so the task uses it without any + ``physics=`` selector. Additional backends will be added later. + """ + + default: NewtonCfg = MISSING + newton_kamino: NewtonCfg = NewtonCfg( + solver_cfg=KaminoSolverCfg( + integrator="euler", + use_fk_solver=True, + sparse_jacobian=True, + constraints_alpha=0.1, + padmm_max_iterations=100, + padmm_rho_0=0.1, + padmm_warmstart_mode="containers", + ), + num_substeps=1, + debug_mode=False, + use_cuda_graph=True, + ) + + def __post_init__(self): + if not isinstance(self.default, NewtonCfg): + self.default = copy.deepcopy(self.newton_kamino) + + +## +# Scene definition +## + + +@configclass +class FourbarPoleSceneCfg(InteractiveSceneCfg): + """Configuration for a fourbar-pole scene.""" + + # ground plane + ground = AssetBaseCfg( + prim_path="/World/ground", + spawn=sim_utils.GroundPlaneCfg(size=(100.0, 100.0)), + ) + + # fourbar-pole + robot: ArticulationCfg = FOURBAR_POLE_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + # lights + dome_light = AssetBaseCfg( + prim_path="/World/DomeLight", + spawn=sim_utils.DomeLightCfg(color=(0.9, 0.9, 0.9), intensity=500.0), + ) + + +## +# MDP settings +## + + +@configclass +class CommandsCfg: + """Command specifications for the MDP.""" + + # Command term to track pole upright success rate + upright = UprightSuccessRateCommandCfg( + asset_cfg=SceneEntityCfg("robot", joint_names=["coupler_to_pole"]), + threshold=0.95, + ) + + +@configclass +class ActionsCfg: + """Action specifications for the MDP.""" + + joint_effort = mdp.JointEffortActionCfg(asset_name="robot", joint_names=["ground_to_crank"], scale=50.0) + + +@configclass +class ObservationsCfg: + """Observation specifications for the MDP.""" + + @configclass + class PolicyCfg(ObsGroup): + """Observations for policy group.""" + + # pole encoded as (cos, sin, vel) to avoid the +-pi wrap discontinuity during swing-up + pole_cos = ObsTerm( + func=mdp.joint_pos_cos, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + pole_sin = ObsTerm( + func=mdp.joint_pos_sin, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + pole_vel = ObsTerm( + func=mdp.joint_vel_rel, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + + # crank encoded with (pos, vel). No wrapping possible due to joint limits. + crank_pos = ObsTerm( + func=mdp.joint_pos_rel, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["ground_to_crank"])}, + ) + crank_vel = ObsTerm( + func=mdp.joint_vel_rel, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["ground_to_crank"])}, + ) + + def __post_init__(self) -> None: + self.enable_corruption = False + self.concatenate_terms = True + + # observation groups + policy: PolicyCfg = PolicyCfg() + + +@configclass +class EventCfg: + """Configuration for events.""" + + # reset crank around its default (0) so the cart starts at varied positions + reset_crank_position = EventTerm( + func=mdp.reset_joints_by_offset, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=["ground_to_crank"]), + "position_range": (-0.25 * math.pi, 0.25 * math.pi), + "velocity_range": (-0.5, 0.5), + }, + ) + + # reset pole around its default (0) with a small perturbation + reset_pole_position = EventTerm( + func=mdp.reset_joints_by_offset, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"]), + "position_range": (-math.pi, math.pi), + "velocity_range": (-0.5, 0.5), + }, + ) + + +@configclass +class RewardsCfg: + """Reward terms for the MDP.""" + + # (1) Primary task: swing the pole up and keep it upright (cos is maximal upright) + pole_upright = RewTerm( + func=mdp.pole_upright, + weight=1.0, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + # (2) Shaping: damp the pole angular velocity to settle at the top + pole_vel = RewTerm( + func=mdp.joint_vel_l1, + weight=-0.01, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + # (3) Shaping: discourage excessive crank motion + crank_vel = RewTerm( + func=mdp.joint_vel_l1, + weight=-0.05, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["ground_to_crank"])}, + ) + # (4) Shaping: penalize control effort + effort = RewTerm( + func=mdp.action_l2, + weight=-0.01, + ) + + +@configclass +class TerminationsCfg: + """Termination terms for the MDP.""" + + # Time out only -- the pole must be free to hang and the parallelogram is geometrically bounded. + time_out = DoneTerm(func=mdp.time_out, time_out=True) + + +## +# Environment configuration +## + + +@configclass +class FourbarPoleSwingupEnvCfg(ManagerBasedRLEnvCfg): + """Configuration for the fourbar-pole swing-up environment.""" + + # Scene settings + scene: FourbarPoleSceneCfg = FourbarPoleSceneCfg(num_envs=4096, env_spacing=4.0, clone_in_fabric=True) + # Basic settings + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + commands: CommandsCfg = CommandsCfg() + events: EventCfg = EventCfg() + # MDP settings + rewards: RewardsCfg = RewardsCfg() + terminations: TerminationsCfg = TerminationsCfg() + + # Post initialization + def __post_init__(self) -> None: + """Post initialization.""" + # general settings + self.decimation = 2 + self.episode_length_s = 5 + # viewer settings + self.viewer.eye = (12.0, 0.0, 4.0) + # Match Newton GL / --video camera to the task viewport when --viz newton creates the visualizer. + self.sim.default_visualizer_cfg = VisualizerCfg(eye=self.viewer.eye, lookat=self.viewer.lookat) + # simulation settings + self.sim.dt = 1 / 120 + self.sim.render_interval = self.decimation + self.sim.physics = FourbarPolePhysicsCfg() diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.py new file mode 100644 index 00000000000..20219001d70 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/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 fourbar-pole environments.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.pyi new file mode 100644 index 00000000000..1b852382866 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.pyi @@ -0,0 +1,21 @@ +# 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__ = [ + "UprightSuccessRateCommand", + "UprightSuccessRateCommandCfg", + "joint_pos_cos", + "joint_pos_sin", + "pole_upright", +] + +from .rewards import ( + UprightSuccessRateCommand, + UprightSuccessRateCommandCfg, + joint_pos_cos, + joint_pos_sin, + pole_upright, +) +from isaaclab.envs.mdp import * diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py new file mode 100644 index 00000000000..f03243c6ae4 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.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 + +"""Functions specific to the fourbar-pole swing-up environments. + +This module holds both observation helpers (``joint_pos_cos`` / ``joint_pos_sin``) +that encode an angle without the ``+-pi`` wrap discontinuity, and the swing-up +reward / success-metric terms. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import CommandTerm, CommandTermCfg, SceneEntityCfg +from isaaclab.utils.configclass import configclass + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedRLEnv + + +def joint_pos_cos(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg) -> torch.Tensor: + """Cosine of the selected joint positions. + + Encodes the angle without the wrap-around discontinuity at ``+-pi`` so the + policy sees a smooth signal as the pole swings through the bottom. + """ + asset: Articulation = env.scene[asset_cfg.name] + return torch.cos(asset.data.joint_pos.torch[:, asset_cfg.joint_ids]) + + +def joint_pos_sin(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg) -> torch.Tensor: + """Sine of the selected joint positions (companion to :func:`joint_pos_cos`).""" + asset: Articulation = env.scene[asset_cfg.name] + return torch.sin(asset.data.joint_pos.torch[:, asset_cfg.joint_ids]) + + +def pole_upright(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg) -> torch.Tensor: + """Uprightness reward for the pole, maximal (``+1``) when fully upright. + + The pole joint is zero when upright and ``+-pi`` when hanging, so ``cos`` gives + a dense shaping signal in ``[-1, 1]`` that drives the swing-up. + """ + asset: Articulation = env.scene[asset_cfg.name] + return torch.sum(torch.cos(asset.data.joint_pos.torch[:, asset_cfg.joint_ids]), dim=1) + + +class UprightSuccessRateCommand(CommandTerm): + """Command term that tracks pole-upright terminal success as a metric.""" + + cfg: UprightSuccessRateCommandCfg + + def __init__(self, cfg: UprightSuccessRateCommandCfg, env: ManagerBasedRLEnv): + super().__init__(cfg, env) + self._asset_cfg = cfg.asset_cfg + self._asset_cfg.resolve(env.scene) + self._asset: Articulation = env.scene[self._asset_cfg.name] + + self._command = torch.zeros((self.num_envs, 1), device=self.device) + self.metrics["success_rate"] = torch.zeros(self.num_envs, device=self.device) + + @property + def command(self) -> torch.Tensor: + return self._command + + def _update_metrics(self): + pole_pos = self._asset.data.joint_pos.torch[:, self._asset_cfg.joint_ids] + self.metrics["success_rate"] = (torch.cos(pole_pos) > self.cfg.threshold).all(dim=1).float() + + def _resample_command(self, env_ids: Sequence[int]): + pass + + def _update_command(self): + pass + + +@configclass +class UprightSuccessRateCommandCfg(CommandTermCfg): + """Configuration for :class:`UprightSuccessRateCommand`.""" + + class_type: type[UprightSuccessRateCommand] | str = "{DIR}.rewards:UprightSuccessRateCommand" + resampling_time_range: tuple[float, float] = (1e6, 1e6) + + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot", joint_names=["coupler_to_pole"]) + threshold: float = 0.95 From f6fd754ea74a4d8541ae41af120b7e07134de79e Mon Sep 17 00:00:00 2001 From: Vidur Vij Date: Mon, 6 Jul 2026 08:51:20 -0700 Subject: [PATCH 095/139] Defer pxr imports in the base physics manager The manager classes are imported while environment configs load (manager_base pulls PhysicsManager at module import), and config loading must stay free of USD/omni modules before the simulation app starts -- the kitless/Warp config-loading tests enforce this. A module-level "from pxr import ..." here therefore fails config loading for every registered environment. Import pxr inside the two methods that use it instead, mirroring the existing local import that avoids the SimulationContext import cycle. The module-level get_current_stage import is safe to keep: the stage utilities now defer their Isaac Sim integration until Kit is available. --- source/isaaclab/isaaclab/physics/physics_manager.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 840eef44e69..fafab0e2701 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -14,8 +14,6 @@ from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar -from pxr import Gf, Usd, UsdGeom, UsdPhysics - from isaaclab.sim.utils.stage import get_current_stage if TYPE_CHECKING: @@ -122,6 +120,11 @@ def fix_articulation_root(cls, articulation_prim: Any, stage: Any = None) -> Any """ # Keep this import local to avoid the SimulationContext -> PhysicsManager -> # sim.utils.queries -> SimulationContext import cycle. + # Keep pxr local as well: this module is imported while environment configs load (via the + # manager classes), and config loading must not pull USD/omni modules before the simulation + # app starts. + from pxr import Gf, UsdGeom, UsdPhysics # noqa: PLC0415 + from isaaclab.sim.utils import find_global_fixed_joint_prim # noqa: PLC0415 if stage is None: @@ -154,6 +157,11 @@ def _relocate_articulation_root( companion_namespace: str, ) -> Any: """Move root-bearing schemas and authored properties to the root link's parent.""" + # Keep pxr local: this module is imported while environment configs load (via the manager + # classes), and config loading must not pull USD/omni modules before the simulation app + # starts. + from pxr import Usd, UsdPhysics # noqa: PLC0415 + new_root = articulation_prim.GetParent() if new_root.HasAPI(UsdPhysics.ArticulationRootAPI): raise RuntimeError( From c67c6811e25dc83290a86c41aa56619563e0b392 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:25:08 +0000 Subject: [PATCH 096/139] [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: 8.1.2 → 9.0.0 - isaaclab_assets: 0.4.1 → 0.4.2 - isaaclab_ovphysx: 6.1.0 → 6.1.1 - isaaclab_tasks: 8.1.5 → 8.1.6 --- .../antoiner-benchmark-core.major.rst | 44 ---------------- .../fix-rl-train-prelaunch-imports.rst | 4 -- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 50 +++++++++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../changelog.d/rgrandia-fourbar-pole.rst | 5 -- source/isaaclab_assets/config/extension.toml | 2 +- source/isaaclab_assets/docs/CHANGELOG.rst | 10 ++++ source/isaaclab_assets/pyproject.toml | 2 +- .../antoiner-pin-ovphysx-0-5-2.rst | 5 -- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 10 ++++ source/isaaclab_ovphysx/pyproject.toml | 2 +- .../changelog.d/aserifi-kamino_cleanup.rst | 12 ----- .../changelog.d/fourbar-pole-swingup.rst | 5 -- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 23 +++++++++ source/isaaclab_tasks/pyproject.toml | 2 +- 18 files changed, 101 insertions(+), 83 deletions(-) delete mode 100644 source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst delete mode 100644 source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst delete mode 100644 source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst delete mode 100644 source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst delete mode 100644 source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst diff --git a/source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst b/source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst deleted file mode 100644 index b33d2628bfb..00000000000 --- a/source/isaaclab/changelog.d/antoiner-benchmark-core.major.rst +++ /dev/null @@ -1,44 +0,0 @@ -Added -^^^^^ - -* Added a backend-agnostic benchmark core under :mod:`isaaclab.test.benchmark`, - including the ``capture``, ``metrics``, ``builders``, ``stepping``, - and ``profiling`` submodules, for assembling and emitting schema-v1 benchmark - bundles (``RuntimeBundle`` / ``TrainingBundle`` / - ``StartupBundle``). -* Added a ``schema`` output formatter that serializes a benchmark bundle through - :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark`, and taught - ``BaseIsaacLabBenchmark`` to emit several formatters in one run from a - comma-separated formatter selection and a new ``attach_bundle`` hook. -* Added runtime and package version metadata to schema benchmark bundles, - including IsaacLab extensions, OVRTX, OVPhysX, MuJoCo, CUDA bindings, and - USD Core. - -Changed -^^^^^^^ - -* **Breaking:** Renamed the benchmark metrics-formatter module - ``isaaclab.test.benchmark.backends`` to ``isaaclab.test.benchmark.formatters``, and the - ``MetricsBackend`` / ``MetricsBackendInterface`` classes to ``MetricsFormatter`` / - ``MetricsFormatterInterface``. The output formatter classes (``JSONFileMetrics``, - ``SummaryMetrics``, ``OsmoKPIFile``, ``OmniPerfKPIFile``) are unchanged but now live in the - ``formatters`` module — update imports from ``isaaclab.test.benchmark.backends`` to - ``isaaclab.test.benchmark.formatters``. The - :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark` constructor keeps ``backend_type`` as - an alias for the new ``formatter_type`` argument, so callers that pass ``backend_type=`` - continue to work unchanged. - -Fixed -^^^^^ - -* Fixed multi-phase :class:`~isaaclab.test.benchmark.OsmoKPIFile` output - overwriting earlier phases by writing one phase-suffixed JSON file per phase. -* Fixed benchmark run metadata to use resolved task defaults for physics and - rendering backends. -* Fixed simulation launch failures being reported with a zero process exit - status during Kit fast shutdown. -* Fixed benchmark metadata so Kit-full runs now report Kit and Isaac Sim - versions while Kitless runs report null. -* Fixed benchmark metadata to report the installed OVPhysX runtime version. -* Fixed benchmark metadata to preserve null values for unavailable OVRTX and - OVPhysX runtimes. diff --git a/source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst b/source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst deleted file mode 100644 index 533762833f0..00000000000 --- a/source/isaaclab/changelog.d/fix-rl-train-prelaunch-imports.rst +++ /dev/null @@ -1,4 +0,0 @@ -Fixed -^^^^^ - -* Fixed intermittent kitless Newton training startup failures by avoiding overlapping standalone USD package providers on x86. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 3207fd89b94..ec207c8f819 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 = "8.1.2" +version = "9.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 3883905e87b..818078ed1c5 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,56 @@ Changelog --------- +9.0.0 (2026-07-07) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added a backend-agnostic benchmark core under :mod:`isaaclab.test.benchmark`, + including the ``capture``, ``metrics``, ``builders``, ``stepping``, + and ``profiling`` submodules, for assembling and emitting schema-v1 benchmark + bundles (``RuntimeBundle`` / ``TrainingBundle`` / + ``StartupBundle``). +* Added a ``schema`` output formatter that serializes a benchmark bundle through + :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark`, and taught + ``BaseIsaacLabBenchmark`` to emit several formatters in one run from a + comma-separated formatter selection and a new ``attach_bundle`` hook. +* Added runtime and package version metadata to schema benchmark bundles, + including IsaacLab extensions, OVRTX, OVPhysX, MuJoCo, CUDA bindings, and + USD Core. + +Changed +^^^^^^^ + +* **Breaking:** Renamed the benchmark metrics-formatter module + ``isaaclab.test.benchmark.backends`` to ``isaaclab.test.benchmark.formatters``, and the + ``MetricsBackend`` / ``MetricsBackendInterface`` classes to ``MetricsFormatter`` / + ``MetricsFormatterInterface``. The output formatter classes (``JSONFileMetrics``, + ``SummaryMetrics``, ``OsmoKPIFile``, ``OmniPerfKPIFile``) are unchanged but now live in the + ``formatters`` module — update imports from ``isaaclab.test.benchmark.backends`` to + ``isaaclab.test.benchmark.formatters``. The + :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark` constructor keeps ``backend_type`` as + an alias for the new ``formatter_type`` argument, so callers that pass ``backend_type=`` + continue to work unchanged. + +Fixed +^^^^^ + +* Fixed multi-phase :class:`~isaaclab.test.benchmark.OsmoKPIFile` output + overwriting earlier phases by writing one phase-suffixed JSON file per phase. +* Fixed benchmark run metadata to use resolved task defaults for physics and + rendering backends. +* Fixed simulation launch failures being reported with a zero process exit + status during Kit fast shutdown. +* Fixed benchmark metadata so Kit-full runs now report Kit and Isaac Sim + versions while Kitless runs report null. +* Fixed benchmark metadata to report the installed OVPhysX runtime version. +* Fixed benchmark metadata to preserve null values for unavailable OVRTX and + OVPhysX runtimes. +* Fixed intermittent kitless Newton training startup failures by avoiding overlapping standalone USD package providers on x86. + + 8.1.2 (2026-07-06) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index e3c8cbd2c74..ab47be147f5 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.1.2" +version = "9.0.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst b/source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst deleted file mode 100644 index 30f654eedda..00000000000 --- a/source/isaaclab_assets/changelog.d/rgrandia-fourbar-pole.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added :data:`~isaaclab_assets.robots.fourbar_pole.FOURBAR_POLE_CFG` for a parallel - four-bar linkage with an inverted pendulum pole on the coupler. diff --git a/source/isaaclab_assets/config/extension.toml b/source/isaaclab_assets/config/extension.toml index 0a560242c28..ea533ef3419 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.4.1" +version = "0.4.2" # Description title = "Isaac Lab Assets" diff --git a/source/isaaclab_assets/docs/CHANGELOG.rst b/source/isaaclab_assets/docs/CHANGELOG.rst index 8a2a4dd58bf..04cc3850c7b 100644 --- a/source/isaaclab_assets/docs/CHANGELOG.rst +++ b/source/isaaclab_assets/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.4.2 (2026-07-07) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :data:`~isaaclab_assets.robots.fourbar_pole.FOURBAR_POLE_CFG` for a parallel + four-bar linkage with an inverted pendulum pole on the coupler. + + 0.4.1 (2026-07-04) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_assets/pyproject.toml b/source/isaaclab_assets/pyproject.toml index b75ffb3e97a..c212565d63a 100644 --- a/source/isaaclab_assets/pyproject.toml +++ b/source/isaaclab_assets/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_assets" -version = "0.4.1" +version = "0.4.2" description = "Extension containing configuration instances of different assets and sensors." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst deleted file mode 100644 index 55367b600ea..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-pin-ovphysx-0-5-2.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed the OVPhysX optional runtime dependency to install - ``ovphysx==0.5.2+head.f62c22207c``, matching the supported runtime wheel. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index c25e8b2f01a..17482a81446 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 = "6.1.0" +version = "6.1.1" # Description title = "OvPhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_ovphysx/docs/CHANGELOG.rst b/source/isaaclab_ovphysx/docs/CHANGELOG.rst index 96425f9de74..1f62c943719 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +6.1.1 (2026-07-07) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed the OVPhysX optional runtime dependency to install + ``ovphysx==0.5.2+head.f62c22207c``, matching the supported runtime wheel. + + 6.1.0 (2026-07-06) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index 75755790e38..8d85e515440 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ovphysx" -version = "6.1.0" +version = "6.1.1" description = "Extension providing IsaacLab with ovphysx/TensorBindingsAPI specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst b/source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst deleted file mode 100644 index d200dbd19e4..00000000000 --- a/source/isaaclab_tasks/changelog.d/aserifi-kamino_cleanup.rst +++ /dev/null @@ -1,12 +0,0 @@ -Changed -^^^^^^^ - -* Changed the Disney DR Legs walk / hold-pose Kamino stepping to ``sim.dt = 1/150`` - with ``decimation = 3`` (previously ``0.004`` / ``5``). This keeps the 50 Hz control - rate while reducing the number of Kamino solver calls per control step from 5 to 3, - for roughly 1.6x faster simulation with equivalent task performance. -* Changed the ``newton_kamino`` presets of the velocity (A1, AnymalB, AnymalC, Go1, Go2, - Cassie, G1, H1, Spot), cabinet, and shadow-hand reorient tasks to use the default - single physics substep (removed the ``num_substeps=2`` override), and dropped the - redundant explicit ``num_substeps`` overrides from the cartpole, ant, and reach Kamino - presets. diff --git a/source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst b/source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst deleted file mode 100644 index 0bd1928b8f8..00000000000 --- a/source/isaaclab_tasks/changelog.d/fourbar-pole-swingup.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab_tasks.core.fourbar_pole.fourbar_pole_manager_env_cfg.FourbarPoleSwingupEnvCfg` - as the ``Isaac-Fourbar-Pole-Swingup`` Gym environment for four-bar pole swing-up with the Kamino solver. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 026ae3a7c21..2fdf98600c1 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 = "8.1.5" +version = "8.1.6" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 8750611e4ab..7ad8be7ffe9 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,29 @@ Changelog --------- +8.1.6 (2026-07-07) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_tasks.core.fourbar_pole.fourbar_pole_manager_env_cfg.FourbarPoleSwingupEnvCfg` + as the ``Isaac-Fourbar-Pole-Swingup`` Gym environment for four-bar pole swing-up with the Kamino solver. + +Changed +^^^^^^^ + +* Changed the Disney DR Legs walk / hold-pose Kamino stepping to ``sim.dt = 1/150`` + with ``decimation = 3`` (previously ``0.004`` / ``5``). This keeps the 50 Hz control + rate while reducing the number of Kamino solver calls per control step from 5 to 3, + for roughly 1.6x faster simulation with equivalent task performance. +* Changed the ``newton_kamino`` presets of the velocity (A1, AnymalB, AnymalC, Go1, Go2, + Cassie, G1, H1, Spot), cabinet, and shadow-hand reorient tasks to use the default + single physics substep (removed the ``num_substeps=2`` override), and dropped the + redundant explicit ``num_substeps`` overrides from the cartpole, ant, and reach Kamino + presets. + + 8.1.5 (2026-07-06) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index 96051ad002e..47db04587cf 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.1.5" +version = "8.1.6" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 569fcaae47aaaa967d415f3f37274853a9547e07 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Tue, 7 Jul 2026 09:54:58 +0200 Subject: [PATCH 097/139] Articulation Reordering Part 1/8: Extract rigid object interface test utilities (#6334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Moves the rigid object and rigid object collection interface test factories into shared underscore-prefixed utility modules so later parts can reuse them. Pure code movement with no behavior change and no ordering content. Part 1/8 of the series splitting #6248 into independently reviewable units. #6248 remains open, untouched, as the reference implementation; this stack was cut from its final tree after a `develop` sync (Kamino Core, Newton bump, OVPhysX 0.5.1). ## Stack - **Part 1/8 (this PR)**: #6334 — Extract rigid object interface test utilities - Part 2/8: #6335 — Core ordering foundation - Part 3/8: #6336 — Newton backend - Part 4/8: #6337 — PhysX backend - Part 5/8: #6338 — Extract articulation interface test utilities - Part 6/8: #6339 — OVPhysX backend - Part 7/8: #6340 — Cross-backend ordering interface tests - Part 8/8: #6341 — Events, consumers, and documentation ## Type of change - Test/documentation/refactor 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 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 - [x] My name exists in `CONTRIBUTORS.md` ## Test Plan - [x] `./isaaclab.sh -p -m pytest source/isaaclab/test/assets/test_rigid_object_iface.py` -> 1478 passed, 12 xfailed - [x] `./isaaclab.sh -p -m pytest source/isaaclab/test/assets/test_rigid_object_collection_iface.py` -> 1902 passed; the 7 failures + 636 errors are pre-existing on `develop` in the local environment (OVPhysX 0.4.13 wheel vs the 0.5.x binding API from #6314) — fail-set identical to the `develop` baseline - [x] `./isaaclab.sh -f` -> all hooks pass --------- Signed-off-by: Antoine RICHARD Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../articulation-reordering-p1.skip | 0 ...igid_object_collection_iface_test_utils.py | 321 ++++++++++++++++++ .../assets/_rigid_object_iface_test_utils.py | 313 +++++++++++++++++ .../test_rigid_object_collection_iface.py | 321 +----------------- .../test/assets/test_rigid_object_iface.py | 309 +---------------- 5 files changed, 636 insertions(+), 628 deletions(-) create mode 100644 source/isaaclab/changelog.d/articulation-reordering-p1.skip create mode 100644 source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py create mode 100644 source/isaaclab/test/assets/_rigid_object_iface_test_utils.py diff --git a/source/isaaclab/changelog.d/articulation-reordering-p1.skip b/source/isaaclab/changelog.d/articulation-reordering-p1.skip new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py b/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py new file mode 100644 index 00000000000..426c43f0f7f --- /dev/null +++ b/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py @@ -0,0 +1,321 @@ +# 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 + +"""Shared mocked rigid-object-collection backend factories for interface tests.""" + +import os +import sys +import importlib.util +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: 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 + + 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 warp as wp + +from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg +from isaaclab.assets.rigid_object_collection.rigid_object_collection_cfg import RigidObjectCollectionCfg +from isaaclab.test.mock_interfaces.utils import MockWrenchComposer + +# Mock SimulationManager.get_physics_sim_view() to return a mock object with gravity +_mock_physics_sim_view = MagicMock() +_mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) + +from isaaclab_physx.physics import PhysxManager as SimulationManager + +SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) + +BACKENDS = ["Mock"] # Mock backend is always available. + +if importlib.util.find_spec("isaaclab_physx") is not None: + from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection import ( + RigidObjectCollection as PhysXRigidObjectCollection, + ) + from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection_data import ( + RigidObjectCollectionData as PhysXRigidObjectCollectionData, + ) + from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp + + BACKENDS.append("physx") + +if importlib.util.find_spec("isaaclab_newton") is not None: + from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection import ( + RigidObjectCollection as NewtonRigidObjectCollection, + ) + from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data import ( + RigidObjectCollectionData as NewtonRigidObjectCollectionData, + ) + from isaaclab_newton.test.mock_interfaces.mock_newton import MockWrenchComposer as NewtonMockWrenchComposer + from isaaclab_newton.test.mock_interfaces.views import MockNewtonCollectionView as NewtonMockCollectionView + + BACKENDS.append("newton") + +if ( + importlib.util.find_spec("isaaclab_ovphysx") is not None + and importlib.util.find_spec("ovphysx") is not None +): + 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 + + if hasattr(OvPhysxRigidObjectCollection, "_create_buffers"): + BACKENDS.append("ovphysx") + + +def create_physx_rigid_object_collection( + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + """Create a test RigidObjectCollection instance with mocked dependencies.""" + collection = object.__new__(PhysXRigidObjectCollection) + + 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) + + # View count = num_instances * num_bodies (body-major view order) + mock_view = PhysXMockRigidBodyViewWarp( + count=num_instances * num_bodies, + device=device, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + object.__setattr__(collection, "_root_view", mock_view) + object.__setattr__(collection, "_device", device) + object.__setattr__(collection, "_num_bodies", num_bodies) + object.__setattr__(collection, "_num_instances", num_instances) + object.__setattr__(collection, "_body_names_list", [f"object_{i}" for i in range(num_bodies)]) + + # Create RigidObjectCollectionData instance + data = PhysXRigidObjectCollectionData(mock_view, num_bodies, device) + object.__setattr__(collection, "_data", data) + data.body_names = [f"object_{i}" for i in range(num_bodies)] + + # Create mock wrench composers + 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 AttributeError + 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) + + # Set up index arrays + object.__setattr__( + collection, "_ALL_ENV_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device) + ) + object.__setattr__(collection, "_ALL_BODY_INDICES", wp.array(np.arange(num_bodies, dtype=np.int32), device=device)) + + return collection, mock_view + + +def create_newton_rigid_object_collection( + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + """Create a test Newton RigidObjectCollection instance with mocked dependencies.""" + import isaaclab_newton.assets.rigid_object_collection.rigid_object_collection as newton_coll_module + import isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data as newton_data_module + + body_names = [f"object_{i}" for i in range(num_bodies)] + + # Create collection-specific mock view with (N, B) root shapes + mock_view = NewtonMockCollectionView( + num_envs=num_instances, + num_bodies=num_bodies, + device=device, + body_names=body_names, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + # 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) + mock_state = MagicMock() + mock_control = MagicMock() + + mock_manager = MagicMock() + mock_manager.get_model.return_value = mock_model + mock_manager.get_state_0.return_value = mock_state + mock_manager.get_state_1.return_value = mock_state + mock_manager.get_control.return_value = mock_control + + # Patch SimulationManager in both data and collection modules + original_data_manager = newton_data_module.SimulationManager + original_coll_manager = newton_coll_module.SimulationManager + newton_data_module.SimulationManager = mock_manager + newton_coll_module.SimulationManager = mock_manager + + try: + data = NewtonRigidObjectCollectionData(mock_view, num_bodies, device) + finally: + newton_data_module.SimulationManager = original_data_manager + newton_coll_module.SimulationManager = original_coll_manager + + # Create collection shell (bypass __init__) + collection = object.__new__(NewtonRigidObjectCollection) + + 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) + + object.__setattr__(collection, "_root_view", mock_view) + object.__setattr__(collection, "_device", device) + object.__setattr__(collection, "_num_bodies", num_bodies) + object.__setattr__(collection, "_num_instances", num_instances) + object.__setattr__(collection, "_body_names_list", body_names) + object.__setattr__(collection, "_data", data) + data.body_names = body_names + + # Mock wrench composers (Newton-specific) + mock_inst_wrench = NewtonMockWrenchComposer(collection) + mock_perm_wrench = NewtonMockWrenchComposer(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 AttributeError + 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) + + # Index arrays (warp) + object.__setattr__( + collection, "_ALL_ENV_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device) + ) + object.__setattr__(collection, "_ALL_BODY_INDICES", wp.array(np.arange(num_bodies, dtype=np.int32), device=device)) + object.__setattr__(collection, "_ALL_ENV_MASK", wp.ones((num_instances,), dtype=wp.bool, device=device)) + object.__setattr__(collection, "_ALL_BODY_MASK", wp.ones((num_bodies,), dtype=wp.bool, device=device)) + + 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, + device: str = "cuda:0", +): + from isaaclab.test.mock_interfaces.assets.mock_rigid_object_collection import MockRigidObjectCollection + + obj = MockRigidObjectCollection( + num_instances=num_instances, + num_bodies=num_bodies, + device=device, + ) + return obj, None + + +def get_rigid_object_collection( + backend: str, + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + 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": + return create_mock_rigid_object_collection(num_instances, num_bodies, device) + else: + raise ValueError(f"Invalid backend: {backend}") diff --git a/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py b/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py new file mode 100644 index 00000000000..1004d3db048 --- /dev/null +++ b/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py @@ -0,0 +1,313 @@ +# 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 + +"""Shared mocked rigid-object backend factories for interface tests.""" + +import os +import sys +import importlib.util +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: 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 + + 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 warp as wp + +from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg +from isaaclab.test.mock_interfaces.utils import MockWrenchComposer + +# Mock SimulationManager.get_physics_sim_view() to return a mock object with gravity +# This is needed because the Data classes call SimulationManager.get_physics_sim_view().get_gravity() +# but there's no actual physics scene when running unit tests +_mock_physics_sim_view = MagicMock() +_mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) + +from isaaclab_physx.physics import PhysxManager as SimulationManager + +SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) + +BACKENDS = ["Mock"] # Mock backend is always available. + +if importlib.util.find_spec("isaaclab_physx") is not None: + from isaaclab_physx.assets.rigid_object.rigid_object import RigidObject as PhysXRigidObject + from isaaclab_physx.assets.rigid_object.rigid_object_data import RigidObjectData as PhysXRigidObjectData + from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp + + BACKENDS.append("physx") + +if importlib.util.find_spec("isaaclab_newton") is not None: + from isaaclab_newton.assets.rigid_object.rigid_object import RigidObject as NewtonRigidObject + from isaaclab_newton.assets.rigid_object.rigid_object_data import RigidObjectData as NewtonRigidObjectData + from isaaclab_newton.test.mock_interfaces.views import MockNewtonArticulationView as NewtonMockArticulationView + + BACKENDS.append("newton") + +if ( + importlib.util.find_spec("isaaclab_ovphysx") is not None + and importlib.util.find_spec("ovphysx") is not None +): + 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") + + +def create_physx_rigid_object( + num_instances: int = 2, + device: str = "cuda:0", +): + """Create a test RigidObject instance with mocked dependencies.""" + body_names = ["body_0"] + + rigid_object = object.__new__(PhysXRigidObject) + + rigid_object.cfg = RigidObjectCfg(prim_path="/World/Object") + + # Create PhysX mock view + mock_view = PhysXMockRigidBodyViewWarp( + count=num_instances, + device=device, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + object.__setattr__(rigid_object, "_root_view", mock_view) + object.__setattr__(rigid_object, "_device", device) + + # Create RigidObjectData instance (SimulationManager already mocked at module level) + data = PhysXRigidObjectData(mock_view, device) + object.__setattr__(rigid_object, "_data", data) + + # Set body names on data + data.body_names = body_names + + # Create mock wrench composers + mock_inst_wrench = MockWrenchComposer(rigid_object) + mock_perm_wrench = MockWrenchComposer(rigid_object) + object.__setattr__(rigid_object, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(rigid_object, "_permanent_wrench_composer", mock_perm_wrench) + + # Prevent __del__ / _clear_callbacks from raising AttributeError + object.__setattr__(rigid_object, "_initialize_handle", None) + object.__setattr__(rigid_object, "_invalidate_initialize_handle", None) + object.__setattr__(rigid_object, "_prim_deletion_handle", None) + object.__setattr__(rigid_object, "_debug_vis_handle", None) + + # Set up index arrays (warp arrays for rigid object) + object.__setattr__(rigid_object, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) + object.__setattr__(rigid_object, "_ALL_BODY_INDICES", wp.array(np.array([0], dtype=np.int32), device=device)) + + # Cached .view(wp.float32) wrappers + object.__setattr__(rigid_object, "_root_link_pose_w_f32", None) + object.__setattr__(rigid_object, "_root_com_vel_w_f32", None) + object.__setattr__(rigid_object, "_inst_wrench_force_f32", None) + object.__setattr__(rigid_object, "_inst_wrench_torque_f32", None) + object.__setattr__(rigid_object, "_perm_wrench_force_f32", None) + object.__setattr__(rigid_object, "_perm_wrench_torque_f32", None) + + # Pre-allocated pinned CPU buffers for PhysX TensorAPI writes + N, B = num_instances, 1 # rigid object has 1 body + cpu_env_ids = wp.array(np.arange(N, dtype=np.int32), device="cpu") + object.__setattr__(rigid_object, "_cpu_env_ids_all", cpu_env_ids) + object.__setattr__(rigid_object, "_cpu_body_mass", wp.zeros((N, B), dtype=wp.float32, device="cpu")) + object.__setattr__(rigid_object, "_cpu_body_coms", wp.zeros((N, B, 7), dtype=wp.float32, device="cpu")) + object.__setattr__(rigid_object, "_cpu_body_inertia", wp.zeros((N, B, 9), dtype=wp.float32, device="cpu")) + + return rigid_object, mock_view + + +def create_newton_rigid_object( + num_instances: int = 2, + device: str = "cuda:0", +): + """Create a test Newton RigidObject instance with mocked dependencies.""" + import isaaclab_newton.assets.rigid_object.rigid_object_data as newton_data_module + + body_names = ["body_0"] + + # Create Newton mock view (uses ArticulationView with num_bodies=1 for rigid objects) + mock_view = NewtonMockArticulationView( + num_instances=num_instances, + num_bodies=1, + num_joints=0, + device=device, + is_fixed_base=False, + joint_names=[], + body_names=body_names, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + # 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) + mock_state = MagicMock() + mock_control = MagicMock() + + mock_manager = MagicMock() + mock_manager.get_model.return_value = mock_model + mock_manager.get_state_0.return_value = mock_state + mock_manager.get_state_1.return_value = mock_state + mock_manager.get_control.return_value = mock_control + + # Patch SimulationManager in the Newton data module + original_sim_manager = newton_data_module.SimulationManager + newton_data_module.SimulationManager = mock_manager + + try: + data = NewtonRigidObjectData(mock_view, device) + finally: + newton_data_module.SimulationManager = original_sim_manager + + # Create RigidObject shell (bypass __init__) + rigid_object = object.__new__(NewtonRigidObject) + + rigid_object.cfg = RigidObjectCfg(prim_path="/World/Object") + + object.__setattr__(rigid_object, "_root_view", mock_view) + object.__setattr__(rigid_object, "_device", device) + object.__setattr__(rigid_object, "_data", data) + + # Mock wrench composers + mock_inst_wrench = MockWrenchComposer(rigid_object) + mock_perm_wrench = MockWrenchComposer(rigid_object) + object.__setattr__(rigid_object, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(rigid_object, "_permanent_wrench_composer", mock_perm_wrench) + + # Prevent __del__ / _clear_callbacks from raising AttributeError + object.__setattr__(rigid_object, "_initialize_handle", None) + object.__setattr__(rigid_object, "_invalidate_initialize_handle", None) + object.__setattr__(rigid_object, "_prim_deletion_handle", None) + object.__setattr__(rigid_object, "_debug_vis_handle", None) + + # Newton uses wp.array for indices + object.__setattr__(rigid_object, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) + object.__setattr__(rigid_object, "_ALL_BODY_INDICES", wp.array(np.array([0], dtype=np.int32), device=device)) + + # Newton uses wp.bool masks + object.__setattr__(rigid_object, "_ALL_ENV_MASK", wp.ones((num_instances,), dtype=wp.bool, device=device)) + object.__setattr__(rigid_object, "_ALL_BODY_MASK", wp.ones((1,), dtype=wp.bool, device=device)) + + 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, "_root_view", mock_bindings.view) + 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.view, 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", +): + from isaaclab.test.mock_interfaces.assets.mock_rigid_object import MockRigidObject + + obj = MockRigidObject( + num_instances=num_instances, + device=device, + ) + return obj, None # No view for mock backend + + +def get_rigid_object( + backend: str, + num_instances: int = 2, + device: str = "cuda:0", +): + 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": + return create_mock_rigid_object(num_instances, device) + else: + raise ValueError(f"Invalid backend: {backend}") 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 e5753943bcb..ee5f24df8d2 100644 --- a/source/isaaclab/test/assets/test_rigid_object_collection_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_collection_iface.py @@ -14,330 +14,11 @@ 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 (when available).""" - -import os -import sys -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: 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 - - 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 import torch import warp as wp - -from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg -from isaaclab.assets.rigid_object_collection.rigid_object_collection_cfg import RigidObjectCollectionCfg -from isaaclab.test.mock_interfaces.utils import MockWrenchComposer - -# Mock SimulationManager.get_physics_sim_view() to return a mock object with gravity -_mock_physics_sim_view = MagicMock() -_mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) - -from isaaclab_physx.physics import PhysxManager as SimulationManager - -SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) - -""" -Check which backends are available. -""" - -BACKENDS = ["Mock"] # Mock backend is always available. - -try: - from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection import ( - RigidObjectCollection as PhysXRigidObjectCollection, - ) - from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection_data import ( - RigidObjectCollectionData as PhysXRigidObjectCollectionData, - ) - from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp - - BACKENDS.append("physx") -except ImportError: - pass - -try: - from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection import ( - RigidObjectCollection as NewtonRigidObjectCollection, - ) - from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data import ( - RigidObjectCollectionData as NewtonRigidObjectCollectionData, - ) - from isaaclab_newton.test.mock_interfaces.mock_newton import MockWrenchComposer as NewtonMockWrenchComposer - from isaaclab_newton.test.mock_interfaces.views import MockNewtonCollectionView as NewtonMockCollectionView - - BACKENDS.append("newton") -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, - num_bodies: int = 3, - device: str = "cuda:0", -): - """Create a test RigidObjectCollection instance with mocked dependencies.""" - collection = object.__new__(PhysXRigidObjectCollection) - - 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) - - # View count = num_instances * num_bodies (body-major view order) - mock_view = PhysXMockRigidBodyViewWarp( - count=num_instances * num_bodies, - device=device, - ) - mock_view.set_random_mock_data() - mock_view._noop_setters = True - - object.__setattr__(collection, "_root_view", mock_view) - object.__setattr__(collection, "_device", device) - object.__setattr__(collection, "_num_bodies", num_bodies) - object.__setattr__(collection, "_num_instances", num_instances) - object.__setattr__(collection, "_body_names_list", [f"object_{i}" for i in range(num_bodies)]) - - # Create RigidObjectCollectionData instance - data = PhysXRigidObjectCollectionData(mock_view, num_bodies, device) - object.__setattr__(collection, "_data", data) - data.body_names = [f"object_{i}" for i in range(num_bodies)] - - # Create mock wrench composers - 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 AttributeError - 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) - - # Set up index arrays - object.__setattr__( - collection, "_ALL_ENV_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device) - ) - object.__setattr__(collection, "_ALL_BODY_INDICES", wp.array(np.arange(num_bodies, dtype=np.int32), device=device)) - - return collection, mock_view - - -def create_newton_rigid_object_collection( - num_instances: int = 2, - num_bodies: int = 3, - device: str = "cuda:0", -): - """Create a test Newton RigidObjectCollection instance with mocked dependencies.""" - import isaaclab_newton.assets.rigid_object_collection.rigid_object_collection as newton_coll_module - import isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data as newton_data_module - - body_names = [f"object_{i}" for i in range(num_bodies)] - - # Create collection-specific mock view with (N, B) root shapes - mock_view = NewtonMockCollectionView( - num_envs=num_instances, - num_bodies=num_bodies, - device=device, - body_names=body_names, - ) - mock_view.set_random_mock_data() - mock_view._noop_setters = True - - # 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) - mock_state = MagicMock() - mock_control = MagicMock() - - mock_manager = MagicMock() - mock_manager.get_model.return_value = mock_model - mock_manager.get_state_0.return_value = mock_state - mock_manager.get_state_1.return_value = mock_state - mock_manager.get_control.return_value = mock_control - - # Patch SimulationManager in both data and collection modules - original_data_manager = newton_data_module.SimulationManager - original_coll_manager = newton_coll_module.SimulationManager - newton_data_module.SimulationManager = mock_manager - newton_coll_module.SimulationManager = mock_manager - - try: - data = NewtonRigidObjectCollectionData(mock_view, num_bodies, device) - finally: - newton_data_module.SimulationManager = original_data_manager - newton_coll_module.SimulationManager = original_coll_manager - - # Create collection shell (bypass __init__) - collection = object.__new__(NewtonRigidObjectCollection) - - 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) - - object.__setattr__(collection, "_root_view", mock_view) - object.__setattr__(collection, "_device", device) - object.__setattr__(collection, "_num_bodies", num_bodies) - object.__setattr__(collection, "_num_instances", num_instances) - object.__setattr__(collection, "_body_names_list", body_names) - object.__setattr__(collection, "_data", data) - data.body_names = body_names - - # Mock wrench composers (Newton-specific) - mock_inst_wrench = NewtonMockWrenchComposer(collection) - mock_perm_wrench = NewtonMockWrenchComposer(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 AttributeError - 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) - - # Index arrays (warp) - object.__setattr__( - collection, "_ALL_ENV_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device) - ) - object.__setattr__(collection, "_ALL_BODY_INDICES", wp.array(np.arange(num_bodies, dtype=np.int32), device=device)) - object.__setattr__(collection, "_ALL_ENV_MASK", wp.ones((num_instances,), dtype=wp.bool, device=device)) - object.__setattr__(collection, "_ALL_BODY_MASK", wp.ones((num_bodies,), dtype=wp.bool, device=device)) - - 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, - device: str = "cuda:0", -): - from isaaclab.test.mock_interfaces.assets.mock_rigid_object_collection import MockRigidObjectCollection - - obj = MockRigidObjectCollection( - num_instances=num_instances, - num_bodies=num_bodies, - device=device, - ) - return obj, None - - -def get_rigid_object_collection( - backend: str, - num_instances: int = 2, - num_bodies: int = 3, - device: str = "cuda:0", -): - 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": - return create_mock_rigid_object_collection(num_instances, num_bodies, device) - else: - raise ValueError(f"Invalid backend: {backend}") +from _rigid_object_collection_iface_test_utils import BACKENDS, get_rigid_object_collection @pytest.fixture diff --git a/source/isaaclab/test/assets/test_rigid_object_iface.py b/source/isaaclab/test/assets/test_rigid_object_iface.py index 772130149ee..c934def4bf3 100644 --- a/source/isaaclab/test/assets/test_rigid_object_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_iface.py @@ -13,318 +13,11 @@ 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 (when available).""" - -import os -import sys -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: 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 - - 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 import torch import warp as wp - -from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg -from isaaclab.test.mock_interfaces.utils import MockWrenchComposer - -# Mock SimulationManager.get_physics_sim_view() to return a mock object with gravity -# This is needed because the Data classes call SimulationManager.get_physics_sim_view().get_gravity() -# but there's no actual physics scene when running unit tests -_mock_physics_sim_view = MagicMock() -_mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) - -from isaaclab_physx.physics import PhysxManager as SimulationManager - -SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) - -""" -Check which backends are available. -""" - -BACKENDS = ["Mock"] # Mock backend is always available. - -try: - from isaaclab_physx.assets.rigid_object.rigid_object import RigidObject as PhysXRigidObject - from isaaclab_physx.assets.rigid_object.rigid_object_data import RigidObjectData as PhysXRigidObjectData - from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp - - BACKENDS.append("physx") -except ImportError: - pass - -try: - from isaaclab_newton.assets.rigid_object.rigid_object import RigidObject as NewtonRigidObject - from isaaclab_newton.assets.rigid_object.rigid_object_data import RigidObjectData as NewtonRigidObjectData - from isaaclab_newton.test.mock_interfaces.views import MockNewtonArticulationView as NewtonMockArticulationView - - BACKENDS.append("newton") -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, - device: str = "cuda:0", -): - """Create a test RigidObject instance with mocked dependencies.""" - body_names = ["body_0"] - - rigid_object = object.__new__(PhysXRigidObject) - - rigid_object.cfg = RigidObjectCfg(prim_path="/World/Object") - - # Create PhysX mock view - mock_view = PhysXMockRigidBodyViewWarp( - count=num_instances, - device=device, - ) - mock_view.set_random_mock_data() - mock_view._noop_setters = True - - object.__setattr__(rigid_object, "_root_view", mock_view) - object.__setattr__(rigid_object, "_device", device) - - # Create RigidObjectData instance (SimulationManager already mocked at module level) - data = PhysXRigidObjectData(mock_view, device) - object.__setattr__(rigid_object, "_data", data) - - # Set body names on data - data.body_names = body_names - - # Create mock wrench composers - mock_inst_wrench = MockWrenchComposer(rigid_object) - mock_perm_wrench = MockWrenchComposer(rigid_object) - object.__setattr__(rigid_object, "_instantaneous_wrench_composer", mock_inst_wrench) - object.__setattr__(rigid_object, "_permanent_wrench_composer", mock_perm_wrench) - - # Prevent __del__ / _clear_callbacks from raising AttributeError - object.__setattr__(rigid_object, "_initialize_handle", None) - object.__setattr__(rigid_object, "_invalidate_initialize_handle", None) - object.__setattr__(rigid_object, "_prim_deletion_handle", None) - object.__setattr__(rigid_object, "_debug_vis_handle", None) - - # Set up index arrays (warp arrays for rigid object) - object.__setattr__(rigid_object, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) - object.__setattr__(rigid_object, "_ALL_BODY_INDICES", wp.array(np.array([0], dtype=np.int32), device=device)) - - # Cached .view(wp.float32) wrappers - object.__setattr__(rigid_object, "_root_link_pose_w_f32", None) - object.__setattr__(rigid_object, "_root_com_vel_w_f32", None) - object.__setattr__(rigid_object, "_inst_wrench_force_f32", None) - object.__setattr__(rigid_object, "_inst_wrench_torque_f32", None) - object.__setattr__(rigid_object, "_perm_wrench_force_f32", None) - object.__setattr__(rigid_object, "_perm_wrench_torque_f32", None) - - # Pre-allocated pinned CPU buffers for PhysX TensorAPI writes - N, B = num_instances, 1 # rigid object has 1 body - cpu_env_ids = wp.array(np.arange(N, dtype=np.int32), device="cpu") - object.__setattr__(rigid_object, "_cpu_env_ids_all", cpu_env_ids) - object.__setattr__(rigid_object, "_cpu_body_mass", wp.zeros((N, B), dtype=wp.float32, device="cpu")) - object.__setattr__(rigid_object, "_cpu_body_coms", wp.zeros((N, B, 7), dtype=wp.float32, device="cpu")) - object.__setattr__(rigid_object, "_cpu_body_inertia", wp.zeros((N, B, 9), dtype=wp.float32, device="cpu")) - - return rigid_object, mock_view - - -def create_newton_rigid_object( - num_instances: int = 2, - device: str = "cuda:0", -): - """Create a test Newton RigidObject instance with mocked dependencies.""" - import isaaclab_newton.assets.rigid_object.rigid_object_data as newton_data_module - - body_names = ["body_0"] - - # Create Newton mock view (uses ArticulationView with num_bodies=1 for rigid objects) - mock_view = NewtonMockArticulationView( - num_instances=num_instances, - num_bodies=1, - num_joints=0, - device=device, - is_fixed_base=False, - joint_names=[], - body_names=body_names, - ) - mock_view.set_random_mock_data() - mock_view._noop_setters = True - - # 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) - mock_state = MagicMock() - mock_control = MagicMock() - - mock_manager = MagicMock() - mock_manager.get_model.return_value = mock_model - mock_manager.get_state_0.return_value = mock_state - mock_manager.get_state_1.return_value = mock_state - mock_manager.get_control.return_value = mock_control - - # Patch SimulationManager in the Newton data module - original_sim_manager = newton_data_module.SimulationManager - newton_data_module.SimulationManager = mock_manager - - try: - data = NewtonRigidObjectData(mock_view, device) - finally: - newton_data_module.SimulationManager = original_sim_manager - - # Create RigidObject shell (bypass __init__) - rigid_object = object.__new__(NewtonRigidObject) - - rigid_object.cfg = RigidObjectCfg(prim_path="/World/Object") - - object.__setattr__(rigid_object, "_root_view", mock_view) - object.__setattr__(rigid_object, "_device", device) - object.__setattr__(rigid_object, "_data", data) - - # Mock wrench composers - mock_inst_wrench = MockWrenchComposer(rigid_object) - mock_perm_wrench = MockWrenchComposer(rigid_object) - object.__setattr__(rigid_object, "_instantaneous_wrench_composer", mock_inst_wrench) - object.__setattr__(rigid_object, "_permanent_wrench_composer", mock_perm_wrench) - - # Prevent __del__ / _clear_callbacks from raising AttributeError - object.__setattr__(rigid_object, "_initialize_handle", None) - object.__setattr__(rigid_object, "_invalidate_initialize_handle", None) - object.__setattr__(rigid_object, "_prim_deletion_handle", None) - object.__setattr__(rigid_object, "_debug_vis_handle", None) - - # Newton uses wp.array for indices - object.__setattr__(rigid_object, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) - object.__setattr__(rigid_object, "_ALL_BODY_INDICES", wp.array(np.array([0], dtype=np.int32), device=device)) - - # Newton uses wp.bool masks - object.__setattr__(rigid_object, "_ALL_ENV_MASK", wp.ones((num_instances,), dtype=wp.bool, device=device)) - object.__setattr__(rigid_object, "_ALL_BODY_MASK", wp.ones((1,), dtype=wp.bool, device=device)) - - 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", -): - from isaaclab.test.mock_interfaces.assets.mock_rigid_object import MockRigidObject - - obj = MockRigidObject( - num_instances=num_instances, - device=device, - ) - return obj, None # No view for mock backend - - -def get_rigid_object( - backend: str, - num_instances: int = 2, - device: str = "cuda:0", -): - 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": - return create_mock_rigid_object(num_instances, device) - else: - raise ValueError(f"Invalid backend: {backend}") +from _rigid_object_iface_test_utils import BACKENDS, get_rigid_object @pytest.fixture From e5f99d3203387a9f03780e73210fac7ce28eb110 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Tue, 7 Jul 2026 11:39:55 +0200 Subject: [PATCH 098/139] Avoid redundant sensor backend updates (#6370) # Description Repeated sensor data reads currently re-enter the backend update implementation even when the cached data is already fresh. For PhysX contact sensors, this repeats getters such as get_net_contact_forces() before the device mask can suppress masked kernel work. This change adds a synchronization-free host freshness generation to SensorBase. Updates and resets invalidate the generation, ordinary repeated reads reuse the cached buffers, and force_recompute=True bypasses the guard. Regression coverage includes repeated reads, full and partial resets, forced recomputation, and CUDA graph capture. No new dependencies are required. Fixes #6315 ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots Not applicable; this change has no visual output. ## Testing - ./isaaclab.sh -f - SensorBase and selected core/PhysX sensor suites: 113 passed - Newton sensor suites: 150 passed, 6 XPASS - Verified the repeated-read regression fails before the fix on CPU and CUDA ## 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 -f - [x] I have made corresponding changes to the documentation (not applicable; no public API or user workflow changed) - [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 - [x] I have added my name to CONTRIBUTORS.md or my name already exists there --- .../fix-sensor-freshness-cache.rst | 5 + .../isaaclab/isaaclab/sensors/sensor_base.py | 11 +- .../isaaclab/test/sensors/test_sensor_base.py | 105 +++++++++++++++++- 3 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 source/isaaclab/changelog.d/fix-sensor-freshness-cache.rst diff --git a/source/isaaclab/changelog.d/fix-sensor-freshness-cache.rst b/source/isaaclab/changelog.d/fix-sensor-freshness-cache.rst new file mode 100644 index 00000000000..d9dfd915aa6 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-sensor-freshness-cache.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed repeated sensor data reads re-running backend updates when the cached + data was already fresh. diff --git a/source/isaaclab/isaaclab/sensors/sensor_base.py b/source/isaaclab/isaaclab/sensors/sensor_base.py index ee4e5885d78..4d011f647d1 100644 --- a/source/isaaclab/isaaclab/sensors/sensor_base.py +++ b/source/isaaclab/isaaclab/sensors/sensor_base.py @@ -185,11 +185,13 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None inputs=[env_mask, self._is_outdated, self._timestamp, self._timestamp_last_update], device=self._device, ) + self._data_generation += 1 def update(self, dt: float, force_recompute: bool = False): # Skip update if sensor is not initialized if not self._is_initialized: return + self._data_generation += 1 # Update the timestamp for the sensors wp.launch( update_timestamp_kernel, @@ -205,7 +207,7 @@ def update(self, dt: float, force_recompute: bool = False): ) # Update the buffers if force_recompute or self._is_visualizing: - self._update_outdated_buffers() + self._update_outdated_buffers(force_recompute=force_recompute) """ Implementation specific. @@ -254,6 +256,8 @@ def _initialize_impl(self): 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) + self._data_generation = 0 + self._data_generation_last_update = -1 # Initialize debug visualization handle if self._debug_vis_handle is None: @@ -389,8 +393,10 @@ def _clear_callbacks(self) -> None: Helper functions. """ - def _update_outdated_buffers(self): + def _update_outdated_buffers(self, force_recompute: bool = False) -> None: """Fills the sensor data for the outdated sensors.""" + if not force_recompute and self._data_generation == self._data_generation_last_update: + return self._update_buffers_impl(self._is_outdated) # update timestamps and clear outdated flags wp.launch( @@ -399,6 +405,7 @@ def _update_outdated_buffers(self): inputs=[self._is_outdated, self._timestamp, self._timestamp_last_update], device=self._device, ) + self._data_generation_last_update = self._data_generation def _resolve_indices_and_mask( self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None diff --git a/source/isaaclab/test/sensors/test_sensor_base.py b/source/isaaclab/test/sensors/test_sensor_base.py index ddf3d1f9d9f..0a7d694cd98 100644 --- a/source/isaaclab/test/sensors/test_sensor_base.py +++ b/source/isaaclab/test/sensors/test_sensor_base.py @@ -33,10 +33,19 @@ class DummyData: count: torch.Tensor = None +@wp.kernel +def increment_count_kernel(env_mask: wp.array(dtype=wp.bool), count: wp.array(dtype=wp.int32)): + """Increment the count for the selected environments.""" + env_id = wp.tid() + if env_mask[env_id]: + count[env_id] += 1 + + class DummySensor(SensorBase): def __init__(self, cfg): super().__init__(cfg) self._data = DummyData() + self.backend_update_count = 0 def _initialize_impl(self): super()._initialize_impl() @@ -50,10 +59,13 @@ def data(self): return self._data def _update_buffers_impl(self, env_mask: wp.array | None = None): - env_ids = wp.to_torch(env_mask).nonzero(as_tuple=False).squeeze(-1) - if len(env_ids) == 0: - return - self._data.count[env_ids] += 1 + self.backend_update_count += 1 + wp.launch( + increment_count_kernel, + dim=self._num_envs, + inputs=[env_mask, wp.from_torch(self._data.count)], + device=self.device, + ) def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None): super().reset(env_ids=env_ids, env_mask=env_mask) @@ -232,6 +244,91 @@ def test_sensor_reset(create_dummy_sensor, device): ) +@pytest.mark.parametrize("device", ("cpu", "cuda")) +def test_repeated_data_reads_update_backend_once(create_dummy_sensor, device): + """Test that repeated data reads update the backend once per sensor update.""" + sensor_cfg, sim, dt = create_dummy_sensor + sensor = DummySensor(cfg=sensor_cfg) + sim.step() + sim.reset() + + sensor.update(dt=dt) + _ = sensor.data + backend_update_count = sensor.backend_update_count + _ = sensor.data + + assert sensor.backend_update_count == backend_update_count + + +@pytest.mark.parametrize("device", ("cpu", "cuda")) +def test_reset_invalidates_cached_sensor_data(create_dummy_sensor, device): + """Test that full and partial resets each invalidate cached sensor data once.""" + sensor_cfg, sim, _ = create_dummy_sensor + sensor = DummySensor(cfg=sensor_cfg) + sim.step() + sim.reset() + _ = sensor.data + + sensor.reset() + backend_update_count = sensor.backend_update_count + _ = sensor.data + _ = sensor.data + assert sensor.backend_update_count == backend_update_count + 1 + + reset_ids = [2, 4] + continued_ids = [0, 1, 3] + sensor.reset(env_ids=reset_ids) + backend_update_count = sensor.backend_update_count + _ = sensor.data + _ = sensor.data + + assert sensor.backend_update_count == backend_update_count + 1 + torch.testing.assert_close( + sensor.data.count[reset_ids], torch.ones(len(reset_ids), dtype=torch.int32, device=device) + ) + torch.testing.assert_close( + sensor.data.count[continued_ids], torch.ones(len(continued_ids), dtype=torch.int32, device=device) + ) + + +@pytest.mark.parametrize("device", ("cpu", "cuda")) +def test_force_recompute_bypasses_sensor_data_cache(create_dummy_sensor, device): + """Test that forced recomputation bypasses a consumed freshness generation.""" + sensor_cfg, sim, _ = create_dummy_sensor + sensor = DummySensor(cfg=sensor_cfg) + sim.step() + sim.reset() + _ = sensor.data + backend_update_count = sensor.backend_update_count + + sensor._update_outdated_buffers(force_recompute=True) + _ = sensor.data + + assert sensor.backend_update_count == backend_update_count + 1 + + +@pytest.mark.parametrize("device", ("cuda",)) +def test_repeated_data_reads_are_graph_safe(create_dummy_sensor, device): + """Test that CUDA graph capture records one backend refresh for repeated reads.""" + sensor_cfg, sim, dt = create_dummy_sensor + sensor = DummySensor(cfg=sensor_cfg) + sim.step() + sim.reset() + + # Warm up the kernels before capture. + sensor.update(dt=dt) + _ = sensor.data + backend_update_count = sensor.backend_update_count + + with wp.ScopedCapture(device=device) as capture: + sensor.update(dt=dt) + _ = sensor.data + _ = sensor.data + + assert sensor.backend_update_count == backend_update_count + 1 + wp.capture_launch(capture.graph) + + @pytest.mark.parametrize("device", ("cpu",)) def test_rigid_body_ancestor_expr_trims_only_terminal_suffix(create_dummy_sensor, device): """Test that ancestor expression trimming keeps repeated path segments above the sensor.""" From 69888c34e471fe84d5ce5c1b9be5199fc54a6ad8 Mon Sep 17 00:00:00 2001 From: Peter Verswyvelen Date: Tue, 7 Jul 2026 14:01:02 +0200 Subject: [PATCH 099/139] feat: GPU-accelerated FrameView local poses + a new xform_space_writer API (#5677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Fast GPU pose/scale ops on Fabric + a new writer-scope API ## What this does Two things: 1. **Local-space pose and scale ops are now GPU-fast on the Fabric backend.** They go through the same Warp-kernel path that world-space ops already used. The old USD fallback for local-space is ~100× slower (see the table below). 2. **All transform writes go through a small context manager.** You open a scope, do your writes inside it, and the scope handles the cleanup: ```python with view.xform_world_space_writer() as writer: writer.set_poses(positions=p, orientations=o) writer.set_scales(scales=s) ``` Use `view.xform_local_space_writer()` for local-space writes. Builds on Piotr's prototype at [`bareya/pbarejko/camera-update`](https://github.com/bareya/IsaacLab/tree/pbarejko/camera-update). ## Why a scope? A FrameView keeps two copies of each prim's transform in Fabric storage: the `omni:fabric:worldMatrix` and `omni:fabric:localMatrix` attributes. When you write one, the other has to be recomputed so they stay consistent. Without a scope, that recompute has to run after every single write. With a scope, it runs **once**, when the scope closes. You can call `set_poses` and `set_scales` as many times as you want inside the scope; on exit, one Warp kernel derives the other space and one `wp.synchronize()` runs. Empty scopes cost nothing. The scope also pauses **Fabric Hierarchy**'s transform-change tracking while you're writing, then restores it on exit. (Fabric itself is just the data store; Fabric Hierarchy, exposed through USDRT's `IFabricHierarchy`, is the plugin that watches writes to `omni:fabric:localMatrix` / `omni:fabric:worldMatrix` and keeps them mutually consistent across the hierarchy.) Its tracking is pull-based: a per-attribute listener records writes into a changelog, and the plugin drains and processes that changelog on the next call to `IFabricHierarchy::update_world_xforms()`. Pausing the listener does not block the writes themselves — they still land in Fabric storage — it just keeps them out of the changelog. With the changelog empty, the next tick has nothing to "catch up on" for these prims, and can't decide one of the two spaces is canonical and derive the other from it. See the next section for why that matters. Rules: - Only one writer can be open on a view at a time. Opening a second one raises `RuntimeError`. - While a writer is open, the view's own getters (`get_world_poses`, etc.) raise. Read through the writer (`writer.get_poses()`) inside the scope, or close the scope first. - **Do not advance the simulation or render from inside the scope.** No `sim.step()`, no `world.render()`, no `SimulationApp.update()`. The matrices are mid-write until exit, and rendering against that state would read torn data. Keep scopes short and close them before stepping. - If something raises inside the scope (a user-code bug, a notebook cell interrupt, etc.), the scope still runs the opposite-space recompute on exit so the world and local matrices stay consistent prim-by-prim. The partial write itself is not rolled back — if you need all-or-nothing, snapshot the matrices yourself before entering. ## Why we have to coordinate with Fabric Hierarchy (renderer correctness) A writer scope is synchronous Python code, so the renderer can't see anything mid-scope — no tick runs until we exit. The risk is on the **next** render/sim tick after the scope closes. On that next tick, Fabric Hierarchy runs its `IFabricHierarchy::update_world_xforms()` step, which can recompute `omni:fabric:worldMatrix` from `omni:fabric:localMatrix` (or vice versa). If it thinks one of the two was the user's most recent edit, it derives the other from it; the derived value — not what we wrote — is then what the **Fabric Scene Delegate (FSD)** hands to RTX on the render path. Three things in this PR keep that next-tick recompute from clobbering our work: 1. **Opposite-space derive at scope exit.** By the time the scope closes, `omni:fabric:worldMatrix` and `omni:fabric:localMatrix` are mutually consistent prim-by-prim, so any recompute Fabric Hierarchy does is a no-op. 2. **Fabric-Hierarchy change tracking is paused for the whole scope.** The writer calls `track_local_xform_changes(False)` and `track_world_xform_changes(False)` on enter (saving the prior state) and restores them on exit. While tracking is paused, the listeners don't record our writes in their changelog, so when the next tick runs there's nothing for `update_world_xforms()` to "catch up on". 3. **Two persistent selections with explicit read-only / read-write flags.** Outside the scope, the view's selection is fully read-only — both `omni:fabric:worldMatrix` and `omni:fabric:localMatrix` are flagged RO. Inside the scope, the writer flips to a fully read-write selection. ``` _sel_ro : worldMatrix=RO, localMatrix=RO (steady state, between writes) _sel_rw : worldMatrix=RW, localMatrix=RW (active during a writer scope) ``` Both selections are built once when the view is initialized and kept alive for its lifetime. The writer scope flips a single flag (`_is_rw`) on enter/exit; nothing is rebuilt. The RO steady state tells Fabric Hierarchy's next-tick `update_world_xforms()` that no attribute is user-authored, so it leaves both alone. ## Backend support - **Fabric (PhysX):** the fast GPU path described above. - **USD, Newton, OVPhysX:** the writer is a thin wrapper. Writes go straight to the backend's existing storage. No batching, no extra recompute (there is no second matrix to keep in sync). Same API across all four backends. Newton has two different "scale" ideas, and the API keeps them apart: - `NewtonSiteFrameView.set_scales(...)` (deprecated) writes the **Newton collision-shape geometry** size. - The writer's `set_scales(...)` writes per-site **transform scale**, matching what USD FrameView does. They operate on different state and are not merged. ## API changes **New (recommended):** - `view.xform_world_space_writer()` / `view.xform_local_space_writer()` — a context manager with `set_poses`, `set_scales`, `get_poses`, `get_scales`. **Deprecated (still works, warns once per class on first use):** - `set_world_poses` / `set_local_poses` — use the writer scope. - `get_scales` / `set_scales` — use `get_local_scales` / `get_world_scales`, or the writer's `set_scales`. ## Benchmark (1024 prims, 50 iterations, Blackwell RTX PRO 5000) Per-iteration timings (lower is better): | Operation | USD (ms) | Fabric (ms) | Newton Site (ms) | |----------------------------|---------:|------------:|-----------------:| | Get World Poses | 8.6148 | 0.0375 | 0.0307 | | Set World Poses | 19.1357 | 0.1090 | 0.0368 | | Get Local Poses | 5.9556 | 0.0374 | 0.0497 | | Set Local Poses | 7.9794 | 0.0979 | 0.0790 | | Get World Scales | 13.1193 | 0.0371 | 0.0015 | | Set World Scales | 21.4571 | 0.1014 | 0.0612 | | Get Local Scales | 3.2544 | 0.0375 | 0.0022 | | Set Local Scales | 3.8350 | 0.0937 | 0.0587 | | Get Both (World+Local) | 14.7506 | 0.0738 | 0.1302 | | Interleaved World Set→Get | 28.1046 | 0.1432 | 0.1588 | | **Per-iter total** | **126.2** | **0.77** | **0.61** | Speedup vs USD (per-iter ops; one-time view construction excluded): | Operation | Fabric × | Newton Site × | |----------------------------|---------:|--------------:| | Get World Poses | 229.6× | 280.2× | | Set World Poses | 175.6× | 519.8× | | Get Local Poses | 159.0× | 119.9× | | Set Local Poses | 81.5× | 101.0× | | Get World Scales | 353.9× | 8911.5× | | Set World Scales | 211.6× | 350.4× | | Get Local Scales | 86.8× | 1489.9× | | Set Local Scales | 40.9× | 65.4× | | Get Both (World+Local) | 200.0× | 113.3× | | Interleaved World Set→Get | 196.2× | 177.0× | | **Overall (per-iter)** | **164×** | **207×** | One-time view construction (reported separately, not part of the per-iter total): USD 4.6 ms, Fabric 4.4 ms, Newton Site 1013 ms — the Newton number is dominated by stage population on the first call. Steady-state per-iteration cost is what the speedup row reflects. ## Notes for reviewers - Getters call `wp.synchronize()` before returning, so the returned `ProxyArray` is always immediately readable from both GPU and host code — no caller-side sync needed. (Both the cached and the per-indices paths sync; this used to be asymmetric.) --------- Co-authored-by: pv-nvidia Co-authored-by: pv-nvidia <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/users/emails#list-email-addresses-for-the-authenticated-user","status":"404"}> --- docs/source/api/lab/isaaclab.utils.rst | 13 + .../benchmarks/benchmark_view_comparison.py | 3 +- .../benchmarks/benchmark_xform_prim_view.py | 160 ++- .../changelog.d/fabric-local-poses.rst | 31 + .../changelog.d/xform-space-writer.rst | 35 + .../isaaclab/sensors/camera/camera.py | 14 +- .../isaaclab/isaaclab/sim/views/__init__.pyi | 4 + .../isaaclab/sim/views/base_frame_view.py | 269 ++++- .../isaaclab/sim/views/usd_frame_view.py | 247 +++-- .../isaaclab/sim/views/xform_space_writer.py | 145 +++ source/isaaclab/isaaclab/utils/warp/fabric.py | 176 ++++ .../test/sim/frame_view_contract_utils.py | 179 +++- .../test/sim/test_views_xform_prim.py | 70 +- .../test/terrains/check_terrain_importer.py | 3 +- .../test/terrains/test_terrain_importer.py | 3 +- .../changelog.d/xform-space-writer.skip | 1 + .../locomanipulation_sdg/scene_utils.py | 3 +- .../changelog.d/fabric-local-poses.rst | 19 + .../changelog.d/xform-space-writer.rst | 13 + .../sim/views/newton_site_frame_view.py | 232 ++++- .../test/sim/test_views_xform_prim_newton.py | 3 +- .../changelog.d/fabric-local-poses.rst | 19 + .../changelog.d/xform-space-writer.rst | 13 + .../sim/views/ovphysx_frame_view.py | 154 +-- .../changelog.d/fabric-local-poses.rst | 36 + .../changelog.d/xform-space-writer.rst | 36 + .../sim/views/fabric_frame_view.py | 966 ++++++++++++++---- .../test/sim/test_views_xform_prim_fabric.py | 827 +++++++++++++-- 28 files changed, 3099 insertions(+), 575 deletions(-) create mode 100644 source/isaaclab/changelog.d/fabric-local-poses.rst create mode 100644 source/isaaclab/changelog.d/xform-space-writer.rst create mode 100644 source/isaaclab/isaaclab/sim/views/xform_space_writer.py create mode 100644 source/isaaclab_mimic/changelog.d/xform-space-writer.skip create mode 100644 source/isaaclab_newton/changelog.d/fabric-local-poses.rst create mode 100644 source/isaaclab_newton/changelog.d/xform-space-writer.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/fabric-local-poses.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/xform-space-writer.rst create mode 100644 source/isaaclab_physx/changelog.d/fabric-local-poses.rst create mode 100644 source/isaaclab_physx/changelog.d/xform-space-writer.rst diff --git a/docs/source/api/lab/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index 5b352152e0b..59c78b3aef4 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -188,3 +188,16 @@ Warp operations :members: :imported-members: :show-inheritance: + +Warp Fabric kernels +^^^^^^^^^^^^^^^^^^^ + +Warp kernels for reading and writing Fabric ``Matrix4d`` attributes +(``omni:fabric:worldMatrix`` / ``omni:fabric:localMatrix``) via +:class:`wp.fabricarray` and :class:`wp.indexedfabricarray`. Used by +:class:`~isaaclab_physx.sim.views.FabricFrameView` to keep child world and +local matrices consistent without round-tripping through USD. + +.. automodule:: isaaclab.utils.warp.fabric + :members: + :show-inheritance: diff --git a/scripts/benchmarks/benchmark_view_comparison.py b/scripts/benchmarks/benchmark_view_comparison.py index aa5927e10b6..2f19554d5ff 100644 --- a/scripts/benchmarks/benchmark_view_comparison.py +++ b/scripts/benchmarks/benchmark_view_comparison.py @@ -284,7 +284,8 @@ def _run_pose_benchmarks( start_time = time.perf_counter() for _ in range(num_iterations): - view.set_world_poses(new_positions, orientations) + with view.xform_world_space_writer() as w: + w.set_poses(new_positions, orientations) timing_results["set_world_poses"] = (time.perf_counter() - start_time) / num_iterations ret_pos, ret_quat = view.get_world_poses() diff --git a/scripts/benchmarks/benchmark_xform_prim_view.py b/scripts/benchmarks/benchmark_xform_prim_view.py index fee3b9642c7..f5c13a0242c 100644 --- a/scripts/benchmarks/benchmark_xform_prim_view.py +++ b/scripts/benchmarks/benchmark_xform_prim_view.py @@ -138,21 +138,35 @@ def benchmark_frame_view( # noqa: C901 is_newton = api == "isaaclab-newton-site" + # Synchronize around timed regions using Warp directly (rather than torch), + # since all backend kernels here are Warp launches and ``wp.synchronize()`` + # covers CPU/CUDA Warp devices consistently. We only need it for GPU runs, + # where kernel launches are asynchronous; guard on device to avoid paying + # it needlessly on CPU. + _needs_sync = str(device).startswith("cuda") + + def _sync() -> None: + if _needs_sync: + wp.synchronize() + def to_torch(a): - return wp.to_torch(a) if isinstance(a, wp.array) else a + if isinstance(a, wp.array): + return wp.to_torch(a) + if hasattr(a, "torch"): + return a.torch + return a try: # -- Warmup -------------------------------------------------------- xform_view.get_world_poses() + xform_view.get_world_scales() # -- get_world_poses ----------------------------------------------- - if is_newton: - torch.cuda.synchronize() + _sync() start_time = time.perf_counter() for _ in range(num_iterations): positions, orientations = xform_view.get_world_poses() - if is_newton: - torch.cuda.synchronize() + _sync() timing_results["get_world_poses"] = (time.perf_counter() - start_time) / num_iterations positions_t = to_torch(positions) @@ -161,20 +175,24 @@ def to_torch(a): computed_results["initial_world_orientations"] = orientations_t.clone() # -- set_world_poses ----------------------------------------------- + # ``.warp`` unwraps the ProxyArray returned by ``get_*_poses`` / + # ``get_*_scales`` to the underlying ``wp.array`` that ``wp.clone`` + # requires. ProxyArray was introduced in PR #5304 ("ProxyArray and + # Asset/Sensor level property caching") which changed the FrameView + # getter return type. Applies to every ``wp.clone`` call below. if is_newton: - new_positions = wp.clone(positions) + new_positions = wp.clone(positions.warp) wp.to_torch(new_positions)[:, 2] += 0.1 else: new_positions = positions_t.clone() new_positions[:, 2] += 0.1 - if is_newton: - torch.cuda.synchronize() + _sync() start_time = time.perf_counter() for _ in range(num_iterations): - xform_view.set_world_poses(new_positions, orientations) - if is_newton: - torch.cuda.synchronize() + with xform_view.xform_world_space_writer() as w: + w.set_poses(new_positions, orientations) + _sync() timing_results["set_world_poses"] = (time.perf_counter() - start_time) / num_iterations pa, oa = xform_view.get_world_poses() @@ -182,13 +200,11 @@ def to_torch(a): computed_results["world_orientations_after_set"] = to_torch(oa).clone() # -- get_local_poses ----------------------------------------------- - if is_newton: - torch.cuda.synchronize() + _sync() start_time = time.perf_counter() for _ in range(num_iterations): translations, orientations_local = xform_view.get_local_poses() - if is_newton: - torch.cuda.synchronize() + _sync() timing_results["get_local_poses"] = (time.perf_counter() - start_time) / num_iterations translations_t = to_torch(translations) @@ -198,45 +214,99 @@ def to_torch(a): # -- set_local_poses ----------------------------------------------- if is_newton: - new_translations = wp.clone(translations) + new_translations = wp.clone(translations.warp) wp.to_torch(new_translations)[:, 2] += 0.1 else: new_translations = translations_t.clone() new_translations[:, 2] += 0.1 - if is_newton: - torch.cuda.synchronize() + _sync() start_time = time.perf_counter() for _ in range(num_iterations): - xform_view.set_local_poses(new_translations, orientations_local) - if is_newton: - torch.cuda.synchronize() + with xform_view.xform_local_space_writer() as w: + w.set_poses(new_translations, orientations_local) + _sync() timing_results["set_local_poses"] = (time.perf_counter() - start_time) / num_iterations ta, ola = xform_view.get_local_poses() computed_results["local_translations_after_set"] = to_torch(ta).clone() computed_results["local_orientations_after_set"] = to_torch(ola).clone() - # -- get_both (world + local) -------------------------------------- + # -- get_world_scales ---------------------------------------------- + _sync() + start_time = time.perf_counter() + for _ in range(num_iterations): + world_scales = xform_view.get_world_scales() + _sync() + timing_results["get_world_scales"] = (time.perf_counter() - start_time) / num_iterations + + world_scales_t = to_torch(world_scales) + computed_results["initial_world_scales"] = world_scales_t.clone() + + # -- set_world_scales ---------------------------------------------- + if is_newton: + new_world_scales = wp.clone(world_scales.warp) + wp.to_torch(new_world_scales)[:] = 1.1 + else: + new_world_scales = world_scales_t.clone() + new_world_scales[:] = 1.1 + + _sync() + start_time = time.perf_counter() + for _ in range(num_iterations): + with xform_view.xform_world_space_writer() as w: + w.set_scales(new_world_scales) + _sync() + timing_results["set_world_scales"] = (time.perf_counter() - start_time) / num_iterations + + computed_results["world_scales_after_set"] = to_torch(xform_view.get_world_scales()).clone() + + # -- get_local_scales ---------------------------------------------- + _sync() + start_time = time.perf_counter() + for _ in range(num_iterations): + local_scales = xform_view.get_local_scales() + _sync() + timing_results["get_local_scales"] = (time.perf_counter() - start_time) / num_iterations + + local_scales_t = to_torch(local_scales) + computed_results["initial_local_scales"] = local_scales_t.clone() + + # -- set_local_scales ---------------------------------------------- if is_newton: - torch.cuda.synchronize() + new_local_scales = wp.clone(local_scales.warp) + wp.to_torch(new_local_scales)[:] = 0.9 + else: + new_local_scales = local_scales_t.clone() + new_local_scales[:] = 0.9 + + _sync() + start_time = time.perf_counter() + for _ in range(num_iterations): + with xform_view.xform_local_space_writer() as w: + w.set_scales(new_local_scales) + _sync() + timing_results["set_local_scales"] = (time.perf_counter() - start_time) / num_iterations + + computed_results["local_scales_after_set"] = to_torch(xform_view.get_local_scales()).clone() + + # -- get_both (world + local) -------------------------------------- + _sync() start_time = time.perf_counter() for _ in range(num_iterations): xform_view.get_world_poses() xform_view.get_local_poses() - if is_newton: - torch.cuda.synchronize() + _sync() timing_results["get_both"] = (time.perf_counter() - start_time) / num_iterations # -- interleaved set -> get ---------------------------------------- - if is_newton: - torch.cuda.synchronize() + _sync() start_time = time.perf_counter() for _ in range(num_iterations): - xform_view.set_world_poses(new_positions, orientations) + with xform_view.xform_world_space_writer() as w: + w.set_poses(new_positions, orientations) xform_view.get_world_poses() - if is_newton: - torch.cuda.synchronize() + _sync() timing_results["interleaved_world_set_get"] = (time.perf_counter() - start_time) / num_iterations finally: @@ -267,15 +337,26 @@ def print_results(results_dict: dict[str, dict[str, float]], num_prims: int, num print(header) print("-" * 120) - operations = [ - ("Initialization", "init"), + # ``init`` is the one-time view-construction cost. We display it in the + # per-operation table but EXCLUDE it from the steady-state totals and the + # overall speedup -- otherwise a backend whose construction is dominated + # by stage population (e.g. Newton, where the first call materializes the + # site cache) shows a misleading "0.00x" overall and crushes the rest of + # the table. The overall row is intended to compare per-iteration cost. + init_op = ("Initialization (one-time)", "init") + per_iter_operations = [ ("Get World Poses", "get_world_poses"), ("Set World Poses", "set_world_poses"), ("Get Local Poses", "get_local_poses"), ("Set Local Poses", "set_local_poses"), + ("Get World Scales", "get_world_scales"), + ("Set World Scales", "set_world_scales"), + ("Get Local Scales", "get_local_scales"), + ("Set Local Scales", "set_local_scales"), ("Get Both (World+Local)", "get_both"), ("Interleaved World Set->Get", "interleaved_world_set_get"), ] + operations = [init_op, *per_iter_operations] for op_name, op_key in operations: row = f"{op_name:<28}" @@ -286,15 +367,16 @@ def print_results(results_dict: dict[str, dict[str, float]], num_prims: int, num print("=" * 120) - total_row = f"{'Total':<28}" + total_row = f"{'Total (per-iter ops)':<28}" for name in api_names: - total_row += f" {sum(results_dict[name].values()) * 1000:>{col_width}.4f}" + per_iter_total = sum(results_dict[name].get(k, 0) for _, k in per_iter_operations) + total_row += f" {per_iter_total * 1000:>{col_width}.4f}" print(f"\n{total_row}") baseline = "isaaclab-usd" if baseline in results_dict and len(api_names) > 1: print("\n" + "=" * 120) - print(f"SPEEDUP vs {baseline.replace('-', ' ').title()}") + print(f"SPEEDUP vs {baseline.replace('-', ' ').title()} (per-iter ops; one-time init excluded)") print("=" * 120) header = f"{'Operation':<28}" for name in api_names: @@ -304,7 +386,7 @@ def print_results(results_dict: dict[str, dict[str, float]], num_prims: int, num print("-" * 120) base = results_dict[baseline] - for op_name, op_key in operations: + for op_name, op_key in per_iter_operations: row = f"{op_name:<28}" base_t = base.get(op_key, 0) for name in api_names: @@ -317,11 +399,11 @@ def print_results(results_dict: dict[str, dict[str, float]], num_prims: int, num print(row) print("=" * 120) - print(f"{'Overall':>28}", end="") - total_base = sum(base.values()) + print(f"{'Overall (per-iter ops)':>28}", end="") + total_base = sum(base.get(k, 0) for _, k in per_iter_operations) for name in api_names: if name != baseline: - total_impl = sum(results_dict[name].values()) + total_impl = sum(results_dict[name].get(k, 0) for _, k in per_iter_operations) if total_base > 0 and total_impl > 0: print(f" {total_base / total_impl:>{col_width}.2f}x", end="") else: diff --git a/source/isaaclab/changelog.d/fabric-local-poses.rst b/source/isaaclab/changelog.d/fabric-local-poses.rst new file mode 100644 index 00000000000..37a4f4ea639 --- /dev/null +++ b/source/isaaclab/changelog.d/fabric-local-poses.rst @@ -0,0 +1,31 @@ +Added +^^^^^ + +* Added explicit local/world scale getters + :meth:`~isaaclab.sim.views.BaseFrameView.get_local_scales` and + :meth:`~isaaclab.sim.views.BaseFrameView.get_world_scales` to the FrameView + API, implemented for :class:`~isaaclab.sim.views.UsdFrameView`. Scale + writes go through the writer scope (see the ``xform-space-writer`` + fragment). + +* Added :func:`~isaaclab.utils.warp.fabric.decompose_indexed_fabric_transforms`, + :func:`~isaaclab.utils.warp.fabric.compose_indexed_fabric_transforms`, + :func:`~isaaclab.utils.warp.fabric.update_indexed_local_matrix_from_world`, and + :func:`~isaaclab.utils.warp.fabric.update_indexed_world_matrix_from_local` + Warp kernels operating on :class:`wp.indexedfabricarray` for reading and + writing Fabric ``Matrix4d`` attributes (``omni:fabric:worldMatrix`` / + ``omni:fabric:localMatrix``). + +Notes +^^^^^ + +* :meth:`~isaaclab.sim.views.BaseFrameView.get_scales` and + :meth:`~isaaclab.sim.views.BaseFrameView.set_scales` remain supported as + convenience helpers (not deprecated). For reads where the space matters, + prefer the explicit ``get_local_scales`` (operates on ``xformOp:scale``) or + ``get_world_scales`` (composed world-space scale). For writes that also + update poses, prefer batching inside one scope: + ``with view.xform_world_space_writer() as w: w.set_poses(...); w.set_scales(...)`` + (or ``xform_local_space_writer``). + :class:`~isaaclab.sim.views.UsdFrameView` preserves prior behavior by + defaulting :meth:`get_scales` / :meth:`set_scales` to local scales. diff --git a/source/isaaclab/changelog.d/xform-space-writer.rst b/source/isaaclab/changelog.d/xform-space-writer.rst new file mode 100644 index 00000000000..f3b2986beb6 --- /dev/null +++ b/source/isaaclab/changelog.d/xform-space-writer.rst @@ -0,0 +1,35 @@ +Added +^^^^^ + +* Added :class:`~isaaclab.sim.views.FrameViewSpaceWriterBase`, the new context-managed + write API for ``FrameView``-managed prim transforms. Open with + ``view.xform_world_space_writer()`` or ``view.xform_local_space_writer()`` and call + :meth:`~isaaclab.sim.views.FrameViewSpaceWriterBase.set_poses` / + :meth:`~isaaclab.sim.views.FrameViewSpaceWriterBase.set_scales` inside the scope; + the writer's ``__exit__`` derives the opposite-space matrices once and + synchronizes once. Only one writer scope may be active per view at a + time. View-level getters + (:meth:`~isaaclab.sim.views.BaseFrameView.get_world_poses` etc.) raise + :class:`RuntimeError` while a writer scope is active. + +* Added the two concrete tag classes + :class:`~isaaclab.sim.views.FrameViewWorldSpaceWriter` and + :class:`~isaaclab.sim.views.FrameViewLocalSpaceWriter` returned by + :meth:`~isaaclab.sim.views.BaseFrameView.xform_world_space_writer` / + :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer`. + +Notes +^^^^^ + +* :meth:`~isaaclab.sim.views.BaseFrameView.set_world_poses`, + :meth:`~isaaclab.sim.views.BaseFrameView.set_local_poses`, and + :meth:`~isaaclab.sim.views.BaseFrameView.set_scales` remain supported as + convenience helpers (not deprecated). Each opens a single-statement writer + scope internally, so updating poses and scales through separate calls derives + the opposite-space matrices and synchronizes twice. For best performance, + update both inside one scope: + ``with view.xform_world_space_writer() as w: w.set_poses(...); w.set_scales(...)`` + (or :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer`). + The bundled examples use the writer scope to get this benefit; callers may + keep the convenience helpers when code simplicity matters more than shaving + a redundant derive/sync. diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index 5ed97a3825f..58cf4423eaf 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -376,7 +376,8 @@ def set_world_poses( orientations = convert_camera_frame_orientation_convention(orientations, origin=convention, target="opengl") ori_wp = wp.from_torch(orientations.contiguous(), dtype=wp.vec4f) idx_wp = self._resolve_env_ids_wp(env_ids) - self._view.set_world_poses(pos_wp, ori_wp, idx_wp) + with self._view.xform_world_space_writer() as writer: + writer.set_poses(pos_wp, ori_wp, idx_wp) def set_world_poses_from_view( self, eyes: torch.Tensor, targets: torch.Tensor, env_ids: Sequence[int] | None = None @@ -434,11 +435,12 @@ def set_world_poses_from_view( env_ids_torch = env_ids_torch.index_select(0, valid_indices) orientations = quat_from_matrix(rotation_matrix) idx_wp = wp.from_torch(env_ids_torch.contiguous(), dtype=wp.int32) - self._view.set_world_poses( - wp.from_torch(eyes.contiguous(), dtype=wp.vec3f), - wp.from_torch(orientations.contiguous(), dtype=wp.vec4f), - idx_wp, - ) + with self._view.xform_world_space_writer() as writer: + writer.set_poses( + wp.from_torch(eyes.contiguous(), dtype=wp.vec3f), + wp.from_torch(orientations.contiguous(), dtype=wp.vec4f), + idx_wp, + ) """ Operations diff --git a/source/isaaclab/isaaclab/sim/views/__init__.pyi b/source/isaaclab/isaaclab/sim/views/__init__.pyi index d578f85d6ad..734e925c19b 100644 --- a/source/isaaclab/isaaclab/sim/views/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/views/__init__.pyi @@ -7,6 +7,9 @@ __all__ = [ "BaseFrameView", "UsdFrameView", "FrameView", + "FrameViewSpaceWriterBase", + "FrameViewWorldSpaceWriter", + "FrameViewLocalSpaceWriter", # Deprecated alias "XformPrimView", ] @@ -14,5 +17,6 @@ __all__ = [ from .base_frame_view import BaseFrameView from .usd_frame_view import UsdFrameView from .frame_view import FrameView +from .xform_space_writer import FrameViewSpaceWriterBase, FrameViewWorldSpaceWriter, FrameViewLocalSpaceWriter # Deprecated alias from .xform_prim_view import XformPrimView diff --git a/source/isaaclab/isaaclab/sim/views/base_frame_view.py b/source/isaaclab/isaaclab/sim/views/base_frame_view.py index 656108f24d2..79c672b7cfd 100644 --- a/source/isaaclab/isaaclab/sim/views/base_frame_view.py +++ b/source/isaaclab/isaaclab/sim/views/base_frame_view.py @@ -8,23 +8,48 @@ from __future__ import annotations import abc +from typing import TYPE_CHECKING import warp as wp from isaaclab.utils.warp import ProxyArray +if TYPE_CHECKING: + from .xform_space_writer import FrameViewLocalSpaceWriter, FrameViewSpaceWriterBase, FrameViewWorldSpaceWriter + class BaseFrameView(abc.ABC): - """Abstract interface for reading and writing world-space transforms of multiple prims. + """Abstract interface for reading and writing transforms of multiple prims. Backend-specific implementations (USD/Fabric, Newton GPU state, etc.) subclass this to provide efficient batched pose queries. The factory :class:`~isaaclab.sim.views.FrameView` selects the correct implementation at runtime based on the active physics backend. - All getters return :class:`~isaaclab.utils.warp.ProxyArray`. Setters accept ``wp.array``. + All getters return :class:`~isaaclab.utils.warp.ProxyArray`. All writes go + through the writer-scope API -- :meth:`xform_world_space_writer` or + :meth:`xform_local_space_writer`: + + .. code-block:: python + + with view.xform_world_space_writer() as writer: + writer.set_poses(positions=p, orientations=o) + writer.set_scales(scales=s) + # Derived-space matrices are recomputed and the writer scope is closed. + + Only one writer scope may be active per view at a time. While a writer + scope is active, the view-level getters + (:meth:`get_world_poses`, :meth:`get_local_poses`, + :meth:`get_world_scales`, :meth:`get_local_scales`) raise + :class:`RuntimeError` -- use the writer's :meth:`~FrameViewSpaceWriterBase.get_poses` + or :meth:`~FrameViewSpaceWriterBase.get_scales` inside the scope, or exit the + scope first. """ + # Class-level default; instance-level value is set by the writer's + # __enter__ / __exit__ to track the active scope on this view. + _active_writer: FrameViewSpaceWriterBase | None = None + @property @abc.abstractmethod def count(self) -> int: @@ -37,7 +62,77 @@ def device(self) -> str: """Device where arrays are allocated (``"cpu"`` or ``"cuda:0"``).""" ... + # ------------------------------------------------------------------ + # Write scope -- recommended API for all transform writes. + # ------------------------------------------------------------------ + + def xform_world_space_writer(self) -> FrameViewWorldSpaceWriter: + """Open a world-space write scope on this view (recommended write API). + + Inside the scope, :meth:`~FrameViewSpaceWriterBase.set_poses` / + :meth:`~FrameViewSpaceWriterBase.set_scales` write world-space values. + + Returns: + A :class:`~isaaclab.sim.views.FrameViewWorldSpaceWriter` context manager. + + Raises: + RuntimeError: On ``__enter__``, if another writer is already active + on this view. + + Example: + .. code-block:: python + + with view.xform_world_space_writer() as w: + w.set_poses(positions=p, orientations=o) + w.set_scales(scales=s) + """ + return self._make_world_space_writer() + + def xform_local_space_writer(self) -> FrameViewLocalSpaceWriter: + """Open a local-space write scope on this view (recommended write API). + + Inside the scope, :meth:`~FrameViewSpaceWriterBase.set_poses` / + :meth:`~FrameViewSpaceWriterBase.set_scales` write local-space values. + + Returns: + A :class:`~isaaclab.sim.views.FrameViewLocalSpaceWriter` context manager. + + Raises: + RuntimeError: On ``__enter__``, if another writer is already active + on this view. + + Example: + .. code-block:: python + + with view.xform_local_space_writer() as w: + w.set_poses(translations=t, orientations=o) + w.set_scales(scales=s) + """ + return self._make_local_space_writer() + + @abc.abstractmethod + def _make_world_space_writer(self) -> FrameViewWorldSpaceWriter: + """Backend hook: return a fresh :class:`FrameViewWorldSpaceWriter` for this view.""" + ... + @abc.abstractmethod + def _make_local_space_writer(self) -> FrameViewLocalSpaceWriter: + """Backend hook: return a fresh :class:`FrameViewLocalSpaceWriter` for this view.""" + ... + + def _assert_no_active_writer(self, method_name: str) -> None: + """Raise :class:`RuntimeError` if a writer scope is currently active on this view.""" + if self._active_writer is not None: + raise RuntimeError( + f"{type(self).__name__}.{method_name}() is not allowed while a writer " + f"scope is active ({type(self._active_writer).__name__}). Use the writer's " + f"get_poses / get_scales inside the scope, or exit the scope first." + ) + + # ------------------------------------------------------------------ + # Public getters -- guarded; delegate to backend ``_*_impl`` hooks. + # ------------------------------------------------------------------ + def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: """Get world-space positions and orientations for prims in the view. @@ -46,12 +141,110 @@ def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, 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. + wrappers. + + Raises: + RuntimeError: If a writer scope is active on this view. """ + self._assert_no_active_writer("get_world_poses") + return self._get_world_poses_impl(indices) + + def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + """Get local-space translations and orientations for prims in the view. + + Args: + indices: Subset of prims to query. ``None`` means all prims. + + Returns: + A tuple ``(translations, orientations)`` of :class:`~isaaclab.utils.warp.ProxyArray` + wrappers. + + Raises: + RuntimeError: If a writer scope is active on this view. + """ + self._assert_no_active_writer("get_local_poses") + return self._get_local_poses_impl(indices) + + def get_local_scales(self, indices: wp.array | None = None) -> ProxyArray: + """Get local-space scales for prims in the view. + + Args: + indices: Subset of prims to query. ``None`` means all prims. + + Returns: + A :class:`~isaaclab.utils.warp.ProxyArray` of shape ``(M, 3)``. + + Raises: + RuntimeError: If a writer scope is active on this view. + """ + self._assert_no_active_writer("get_local_scales") + return self._get_local_scales_impl(indices) + + def get_world_scales(self, indices: wp.array | None = None) -> ProxyArray: + """Get world-space (composed) scales for prims in the view. + + Returns the effective scale in world space (``parent_scale * local_scale``). + + .. note:: + Scale extraction uses TRS (Translation-Rotation-Scale) decomposition, + which assumes no shear/skew in the transform matrix. If a prim's + world transform contains shear, the extracted scale values will be + approximate. + + Args: + indices: Subset of prims to query. ``None`` means all prims. + + Returns: + A :class:`~isaaclab.utils.warp.ProxyArray` of shape ``(M, 3)``. + + Raises: + RuntimeError: If a writer scope is active on this view. + """ + self._assert_no_active_writer("get_world_scales") + return self._get_world_scales_impl(indices) + + # ------------------------------------------------------------------ + # Backend hooks for the public getters above. + # ------------------------------------------------------------------ + + @abc.abstractmethod + def _get_world_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + """Backend implementation of :meth:`get_world_poses`.""" ... @abc.abstractmethod + def _get_local_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + """Backend implementation of :meth:`get_local_poses`.""" + ... + + @abc.abstractmethod + def _get_local_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Backend implementation of :meth:`get_local_scales`.""" + ... + + @abc.abstractmethod + def _get_world_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Backend implementation of :meth:`get_world_scales`.""" + ... + + # ------------------------------------------------------------------ + # Convenience pose/scale setters -- route through the writer scope. + # + # These are kept as first-class convenience APIs (not deprecated). Each + # call opens its own single-statement writer scope internally, so writing + # poses and scales through two separate calls performs the opposite-space + # recompute + synchronize twice. For the best performance when updating + # both poses and scales together, open one writer scope and issue both + # writes inside it:: + # + # with view.xform_world_space_writer() as w: + # w.set_poses(...) + # w.set_scales(...) + # + # Prefer the writer scope in hot loops; prefer these helpers when code + # simplicity matters more than shaving a redundant derive/sync. + # ------------------------------------------------------------------ + def set_world_poses( self, positions: wp.array | None = None, @@ -60,28 +253,19 @@ def set_world_poses( ) -> None: """Set world-space positions and/or orientations for prims in the view. + This convenience method opens a single-statement writer scope + internally. To update poses and scales together without paying the + opposite-space derive/sync twice, prefer + ``with view.xform_world_space_writer() as w: w.set_poses(...); w.set_scales(...)``. + Args: positions: World-space positions ``(M, 3)``. ``None`` leaves positions unchanged. orientations: World-space quaternions ``(M, 4)``. ``None`` leaves orientations unchanged. indices: Subset of prims to update. ``None`` means all prims. """ - ... - - @abc.abstractmethod - def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: - """Get local-space positions and orientations for prims in the view. - - Args: - indices: Subset of prims to query. ``None`` means all prims. - - Returns: - A tuple ``(translations, 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. - """ - ... + with self.xform_world_space_writer() as writer: + writer.set_poses(positions, orientations, indices) - @abc.abstractmethod def set_local_poses( self, translations: wp.array | None = None, @@ -90,31 +274,66 @@ def set_local_poses( ) -> None: """Set local-space translations and/or orientations for prims in the view. + This convenience method opens a single-statement writer scope + internally. To update poses and scales together without paying the + opposite-space derive/sync twice, prefer + ``with view.xform_local_space_writer() as w: w.set_poses(...); w.set_scales(...)``. + Args: translations: Local-space translations ``(M, 3)``. ``None`` leaves translations unchanged. orientations: Local-space quaternions ``(M, 4)``. ``None`` leaves orientations unchanged. indices: Subset of prims to update. ``None`` means all prims. """ - ... + with self.xform_local_space_writer() as writer: + writer.set_poses(translations, orientations, indices) + + # ------------------------------------------------------------------ + # Scale getter/setter convenience helpers. + # ------------------------------------------------------------------ - @abc.abstractmethod def get_scales(self, indices: wp.array | None = None) -> ProxyArray: """Get scales for prims in the view. + .. note:: + Prefer the explicit :meth:`get_local_scales` or + :meth:`get_world_scales` when the space matters. This method + delegates to :meth:`_get_scales_impl`, which preserves each + backend's legacy space (world for Fabric, local for USD). + Args: indices: Subset of prims to query. ``None`` means all prims. Returns: - A :class:`~isaaclab.utils.warp.ProxyArray` of shape ``(M, 3)``. + A ``ProxyArray`` of shape ``(M, 3)``. + + Raises: + RuntimeError: If a writer scope is active on this view. """ - ... + self._assert_no_active_writer("get_scales") + return self._get_scales_impl(indices) - @abc.abstractmethod def set_scales(self, scales: wp.array, indices: wp.array | None = None) -> None: """Set scales for prims in the view. + This convenience method delegates to :meth:`_set_scales_impl`, which + opens the backend's legacy space (world for Fabric, local for USD) and + calls ``writer.set_scales``. To update poses and scales together + without paying the opposite-space derive/sync twice, prefer + ``with view.xform_world_space_writer() as w: w.set_poses(...); w.set_scales(...)`` + (or :meth:`xform_local_space_writer`). + Args: scales: Scales ``(M, 3)`` as ``wp.array``. indices: Subset of prims to update. ``None`` means all prims. """ + self._set_scales_impl(scales, indices) + + @abc.abstractmethod + def _get_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Backend-specific implementation for :meth:`get_scales`.""" + ... + + @abc.abstractmethod + def _set_scales_impl(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Backend-specific implementation for :meth:`set_scales`.""" ... diff --git a/source/isaaclab/isaaclab/sim/views/usd_frame_view.py b/source/isaaclab/isaaclab/sim/views/usd_frame_view.py index 31331221e46..22707ceb8a7 100644 --- a/source/isaaclab/isaaclab/sim/views/usd_frame_view.py +++ b/source/isaaclab/isaaclab/sim/views/usd_frame_view.py @@ -17,6 +17,7 @@ from isaaclab.utils.warp import ProxyArray from .base_frame_view import BaseFrameView +from .xform_space_writer import FrameViewLocalSpaceWriter, FrameViewWorldSpaceWriter logger = logging.getLogger(__name__) @@ -35,7 +36,14 @@ class UsdFrameView(BaseFrameView): For GPU-accelerated Fabric operations, use the PhysX backend variant obtained via :class:`~isaaclab.sim.views.FrameView`. - Getters return :class:`~isaaclab.utils.warp.ProxyArray`. Setters accept ``wp.array``. + All writes go through the writer-scope API (:meth:`xform_world_space_writer` + / :meth:`xform_local_space_writer`). The + USD backend's writers are pass-throughs: each :meth:`set_poses` / + :meth:`set_scales` call directly modifies the prim's USD ``xformOp:*`` + attributes (no batching, no derivation step on exit) -- USD has no + separate world-matrix storage to keep in sync. + + Getters return :class:`~isaaclab.utils.warp.ProxyArray`. .. note:: **Transform Requirements:** @@ -126,24 +134,70 @@ def prim_paths(self) -> list[str]: return self._prim_paths # ------------------------------------------------------------------ - # Setters + # Writer factory hooks (pass-through writers; USD has no derived state) + # ------------------------------------------------------------------ + + def _make_world_space_writer(self) -> FrameViewWorldSpaceWriter: + return _UsdWorldSpaceWriter(self) + + def _make_local_space_writer(self) -> FrameViewLocalSpaceWriter: + return _UsdLocalSpaceWriter(self) + + # ------------------------------------------------------------------ + # Visibility (USD-only, no writer scope) # ------------------------------------------------------------------ - def set_world_poses( + def set_visibility(self, visibility: torch.Tensor, indices: wp.array | None = None): + """Set visibility for prims in the view. + + Args: + visibility: Visibility as a boolean tensor of shape ``(M,)``. + indices: Indices of prims to set visibility for. Defaults to None (all prims). + """ + indices_list = self._resolve_indices(indices) + + if visibility.shape != (len(indices_list),): + raise ValueError(f"Expected visibility shape ({len(indices_list)},), got {visibility.shape}.") + + with Sdf.ChangeBlock(): + for idx, prim_idx in enumerate(indices_list): + imageable = UsdGeom.Imageable(self._prims[prim_idx]) + if visibility[idx]: + imageable.MakeVisible() + else: + imageable.MakeInvisible() + + def get_visibility(self, indices: wp.array | None = None) -> torch.Tensor: + """Get visibility for prims in the view. + + Args: + indices: Indices of prims to get visibility for. Defaults to None (all prims). + + Returns: + A tensor of shape ``(M,)`` containing the visibility of each prim (bool). + """ + indices_list = self._resolve_indices(indices) + + visibility = torch.zeros(len(indices_list), dtype=torch.bool, device=self._device) + for idx, prim_idx in enumerate(indices_list): + imageable = UsdGeom.Imageable(self._prims[prim_idx]) + visibility[idx] = imageable.ComputeVisibility() != UsdGeom.Tokens.invisible + return visibility + + # ------------------------------------------------------------------ + # Backend hooks: pose / scale writes (called by writers). + # ------------------------------------------------------------------ + + def _apply_world_pose_write( self, positions: wp.array | None = None, orientations: wp.array | None = None, indices: wp.array | None = None, - ): - """Set world-space poses for prims in the view. + ) -> None: + """Apply a world-space pose write directly to USD xform ops. Converts the desired world pose to local-space relative to each prim's - parent before writing to USD xform ops. - - Args: - positions: World-space positions of shape ``(M, 3)``. - orientations: World-space quaternions ``(w, x, y, z)`` of shape ``(M, 4)``. - indices: Indices of prims to set poses for. Defaults to None (all prims). + parent before writing. """ indices_list = self._resolve_indices(indices) @@ -187,19 +241,13 @@ def set_world_poses( if local_quat is not None: prim.GetAttribute("xformOp:orient").Set(local_quat) - def set_local_poses( + def _apply_local_pose_write( self, translations: wp.array | None = None, orientations: wp.array | None = None, indices: wp.array | None = None, - ): - """Set local-space poses for prims in the view. - - Args: - translations: Local-space translations of shape ``(M, 3)``. - orientations: Local-space quaternions ``(w, x, y, z)`` of shape ``(M, 4)``. - indices: Indices of prims to set poses for. Defaults to None (all prims). - """ + ) -> None: + """Apply a local-space pose write directly to USD xform ops.""" indices_list = self._resolve_indices(indices) translations_array = Vt.Vec3dArray.FromNumpy(self._to_numpy(translations)) if translations is not None else None @@ -213,13 +261,8 @@ def set_local_poses( if orientations_array is not None: prim.GetAttribute("xformOp:orient").Set(orientations_array[idx]) - def set_scales(self, scales: wp.array, indices: wp.array | None = None): - """Set scales for prims in the view. - - Args: - scales: Scales of shape ``(M, 3)``. - indices: Indices of prims to set scales for. Defaults to None (all prims). - """ + def _apply_local_scale_write(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Apply a local-space scale write (``xformOp:scale``).""" indices_list = self._resolve_indices(indices) scales_array = Vt.Vec3dArray.FromNumpy(self._to_numpy(scales)) @@ -228,41 +271,41 @@ def set_scales(self, scales: wp.array, indices: wp.array | None = None): prim = self._prims[prim_idx] prim.GetAttribute("xformOp:scale").Set(scales_array[idx]) - def set_visibility(self, visibility: torch.Tensor, indices: wp.array | None = None): - """Set visibility for prims in the view. + def _apply_world_scale_write(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Apply a world-space scale write. - Args: - visibility: Visibility as a boolean tensor of shape ``(M,)``. - indices: Indices of prims to set visibility for. Defaults to None (all prims). + Computes ``local_scale = world_scale / parent_world_scale`` and writes + to ``xformOp:scale``. """ indices_list = self._resolve_indices(indices) - - if visibility.shape != (len(indices_list),): - raise ValueError(f"Expected visibility shape ({len(indices_list)},), got {visibility.shape}.") + scales_np = self._to_numpy(scales) + xf_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) with Sdf.ChangeBlock(): for idx, prim_idx in enumerate(indices_list): - imageable = UsdGeom.Imageable(self._prims[prim_idx]) - if visibility[idx]: - imageable.MakeVisible() + prim = self._prims[prim_idx] + parent = prim.GetParent() + if parent and parent.IsValid() and parent.GetPath() != Sdf.Path.absoluteRootPath: + parent_world = xf_cache.GetLocalToWorldTransform(parent) + parent_scale = Gf.Vec3d( + Gf.Vec3d(parent_world[0][0], parent_world[0][1], parent_world[0][2]).GetLength(), + Gf.Vec3d(parent_world[1][0], parent_world[1][1], parent_world[1][2]).GetLength(), + Gf.Vec3d(parent_world[2][0], parent_world[2][1], parent_world[2][2]).GetLength(), + ) else: - imageable.MakeInvisible() + parent_scale = Gf.Vec3d(1.0, 1.0, 1.0) + local_scale = Gf.Vec3d( + float(scales_np[idx][0] / parent_scale[0]), + float(scales_np[idx][1] / parent_scale[1]), + float(scales_np[idx][2] / parent_scale[2]), + ) + prim.GetAttribute("xformOp:scale").Set(local_scale) # ------------------------------------------------------------------ - # Getters + # Backend hooks: pose / scale reads. # ------------------------------------------------------------------ - def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: - """Get world-space poses for prims in the view. - - Args: - indices: Indices of prims to get poses for. Defaults to None (all prims). - - Returns: - A tuple ``(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. - """ + def _get_world_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: indices_list = self._resolve_indices(indices) positions = Vt.Vec3dArray(len(indices_list)) @@ -280,17 +323,7 @@ def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, quat_wp = wp.array(np.array(orientations, dtype=np.float32), dtype=wp.float32, device=self._device) return ProxyArray(pos_wp), ProxyArray(quat_wp) - def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: - """Get local-space poses for prims in the view. - - Args: - indices: Indices of prims to get poses for. Defaults to None (all prims). - - Returns: - A tuple ``(translations, 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. - """ + def _get_local_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: indices_list = self._resolve_indices(indices) translations = Vt.Vec3dArray(len(indices_list)) @@ -308,15 +341,7 @@ def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, quat_wp = wp.array(np.array(orientations, dtype=np.float32), dtype=wp.float32, device=self._device) return ProxyArray(pos_wp), ProxyArray(quat_wp) - def get_scales(self, indices: wp.array | None = None) -> ProxyArray: - """Get scales for prims in the view. - - Args: - indices: Indices of prims to get scales for. Defaults to None (all prims). - - Returns: - A :class:`~isaaclab.utils.warp.ProxyArray` of shape ``(M, 3)``. - """ + def _get_local_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: indices_list = self._resolve_indices(indices) scales = Vt.Vec3dArray(len(indices_list)) @@ -324,25 +349,34 @@ def get_scales(self, indices: wp.array | None = None) -> ProxyArray: prim = self._prims[prim_idx] scales[idx] = prim.GetAttribute("xformOp:scale").Get() - scales_wp = wp.array(np.array(scales, dtype=np.float32), dtype=wp.float32, device=self._device) - return ProxyArray(scales_wp) - - def get_visibility(self, indices: wp.array | None = None) -> torch.Tensor: - """Get visibility for prims in the view. - - Args: - indices: Indices of prims to get visibility for. Defaults to None (all prims). + return ProxyArray(wp.array(np.array(scales, dtype=np.float32), dtype=wp.float32, device=self._device)) - Returns: - A tensor of shape ``(M,)`` containing the visibility of each prim (bool). - """ + def _get_world_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: indices_list = self._resolve_indices(indices) + xf_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) - visibility = torch.zeros(len(indices_list), dtype=torch.bool, device=self._device) + scales = np.empty((len(indices_list), 3), dtype=np.float32) for idx, prim_idx in enumerate(indices_list): - imageable = UsdGeom.Imageable(self._prims[prim_idx]) - visibility[idx] = imageable.ComputeVisibility() != UsdGeom.Tokens.invisible - return visibility + prim = self._prims[prim_idx] + world_mtx = xf_cache.GetLocalToWorldTransform(prim) + scales[idx, 0] = Gf.Vec3d(world_mtx[0][0], world_mtx[0][1], world_mtx[0][2]).GetLength() + scales[idx, 1] = Gf.Vec3d(world_mtx[1][0], world_mtx[1][1], world_mtx[1][2]).GetLength() + scales[idx, 2] = Gf.Vec3d(world_mtx[2][0], world_mtx[2][1], world_mtx[2][2]).GetLength() + + return ProxyArray(wp.array(scales, dtype=wp.float32, device=self._device)) + + # ------------------------------------------------------------------ + # Deprecated get_scales / set_scales hooks + # ------------------------------------------------------------------ + + def _get_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """USD legacy: get_scales returns local scales.""" + return self._get_local_scales_impl(indices) + + def _set_scales_impl(self, scales: wp.array, indices: wp.array | None = None) -> None: + """USD legacy: set_scales writes local scales via a one-shot writer scope.""" + with self.xform_local_space_writer() as writer: + writer.set_scales(scales, indices) # ------------------------------------------------------------------ # Helpers @@ -360,3 +394,44 @@ def _to_numpy(data: wp.array | torch.Tensor) -> np.ndarray: if isinstance(data, wp.array): return data.numpy() return data.cpu().numpy() + + +# ---------------------------------------------------------------------- +# Pass-through writer classes +# ---------------------------------------------------------------------- + + +class _UsdWorldSpaceWriter(FrameViewWorldSpaceWriter): + """USD world-space writer: pass-through to backend ``_apply_*`` hooks. + + USD has no separate world-matrix storage to keep in sync; ``__exit__`` + is a no-op beyond releasing the single-writer lock. + """ + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + self._view._apply_world_pose_write(positions, orientations, indices) # type: ignore[attr-defined] + + def set_scales(self, scales, indices=None) -> None: + self._view._apply_world_scale_write(scales, indices) # type: ignore[attr-defined] + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._get_world_poses_impl(indices) # type: ignore[attr-defined] + + def get_scales(self, indices=None) -> ProxyArray: + return self._view._get_world_scales_impl(indices) # type: ignore[attr-defined] + + +class _UsdLocalSpaceWriter(FrameViewLocalSpaceWriter): + """USD local-space writer: pass-through to backend ``_apply_*`` hooks.""" + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + self._view._apply_local_pose_write(positions, orientations, indices) # type: ignore[attr-defined] + + def set_scales(self, scales, indices=None) -> None: + self._view._apply_local_scale_write(scales, indices) # type: ignore[attr-defined] + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._get_local_poses_impl(indices) # type: ignore[attr-defined] + + def get_scales(self, indices=None) -> ProxyArray: + return self._view._get_local_scales_impl(indices) # type: ignore[attr-defined] diff --git a/source/isaaclab/isaaclab/sim/views/xform_space_writer.py b/source/isaaclab/isaaclab/sim/views/xform_space_writer.py new file mode 100644 index 00000000000..2d657b03199 --- /dev/null +++ b/source/isaaclab/isaaclab/sim/views/xform_space_writer.py @@ -0,0 +1,145 @@ +# 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 + +"""Context-managed transform writers for :class:`~isaaclab.sim.views.BaseFrameView`. + +This module defines the recommended write API for FrameView poses and scales: + +.. code-block:: python + + with view.xform_world_space_writer() as writer: + writer.set_poses(positions=p, orientations=o) + writer.set_scales(scales=s) + # ... any number of writes ... + # On exit the writer derives the opposite-space matrices once, + # synchronizes once, and restores any saved Fabric tracking state. + +Only one writer may be active per view at a time. While a writer scope is +active on a view, view-level getters (``view.get_world_poses``, +``view.get_local_poses``, ``view.get_world_scales``, +``view.get_local_scales``) raise :class:`RuntimeError` -- use the writer's own +:meth:`~FrameViewSpaceWriterBase.get_poses` / :meth:`~FrameViewSpaceWriterBase.get_scales` +inside the scope, or exit the scope first. + +**Do not advance the simulation or render from inside a scope.** The scope +runs as synchronous Python code, so no ``sim.step()`` / ``world.render()`` / +``SimulationApp.update()`` is allowed inside the ``with`` block. Until the +scope exits, the backend's matrices may be mid-write (some prims updated, +others not; the opposite-space derive has not yet run) and rendering against +that state would read torn data. Keep scopes short and step the +simulation outside them. +""" + +from __future__ import annotations + +import abc +from typing import TYPE_CHECKING + +import warp as wp + +from isaaclab.utils.warp import ProxyArray + +if TYPE_CHECKING: + from .base_frame_view import BaseFrameView + + +class FrameViewSpaceWriterBase(abc.ABC): + """Abstract context-managed writer for a single transform space. + + Subclasses are returned by :meth:`BaseFrameView.xform_world_space_writer` / + :meth:`BaseFrameView.xform_local_space_writer`; they + are not constructed directly. The class is intentionally minimal -- the + pose/scale semantics depend on the writer's space (world or local), which + is conveyed by the concrete tag class :class:`FrameViewWorldSpaceWriter` or + :class:`FrameViewLocalSpaceWriter`. + + The scope runs as synchronous Python code: no simulation step and no + render tick can run while it is open, and the caller must not advance + either from inside the ``with`` block. See the module docstring for the + full contract. + """ + + def __init__(self, view: BaseFrameView): + self._view = view + + @abc.abstractmethod + def set_poses( + self, + positions: wp.array | None = None, + orientations: wp.array | None = None, + indices: wp.array | None = None, + ) -> None: + """Set positions and/or orientations in this writer's space. + + Args: + positions: Positions ``(M, 3)``. ``None`` leaves positions unchanged. + orientations: Quaternions ``(M, 4)`` in ``(x, y, z, w)``. + ``None`` leaves orientations unchanged. + indices: Subset of prims to update. ``None`` means all prims. + """ + ... + + @abc.abstractmethod + def set_scales(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set scales in this writer's space. + + Args: + scales: Scales ``(M, 3)`` as ``wp.array``. + indices: Subset of prims to update. ``None`` means all prims. + """ + ... + + @abc.abstractmethod + def get_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + """Return ``(positions, orientations)`` in this writer's space. + + Reflects any in-scope writes that have already been queued on the + underlying device stream. + """ + ... + + @abc.abstractmethod + def get_scales(self, indices: wp.array | None = None) -> ProxyArray: + """Return scales in this writer's space.""" + ... + + def __enter__(self) -> FrameViewSpaceWriterBase: + if self._view._active_writer is not None: + raise RuntimeError( + f"{type(self._view).__name__} already has an active writer scope " + f"({type(self._view._active_writer).__name__}). Exit the existing scope before " + "opening a new one." + ) + self._view._active_writer = self + self._enter_impl() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + try: + self._exit_impl(exc_type, exc_val, exc_tb) + finally: + self._view._active_writer = None + + def _enter_impl(self) -> None: + """Backend hook called after the single-active-writer lock is claimed.""" + + def _exit_impl(self, exc_type, exc_val, exc_tb) -> None: + """Backend hook called before the single-active-writer lock is released.""" + + +class FrameViewWorldSpaceWriter(FrameViewSpaceWriterBase): + """Writer whose :meth:`set_poses` / :meth:`set_scales` write world-space values. + + On context exit the opposite-space (``local``) matrices are derived from + the just-written world matrices in a single Warp kernel launch. + """ + + +class FrameViewLocalSpaceWriter(FrameViewSpaceWriterBase): + """Writer whose :meth:`set_poses` / :meth:`set_scales` write local-space values. + + On context exit the opposite-space (``world``) matrices are derived from + the just-written local matrices in a single Warp kernel launch. + """ diff --git a/source/isaaclab/isaaclab/utils/warp/fabric.py b/source/isaaclab/isaaclab/utils/warp/fabric.py index a48f773f499..e0519d98c33 100644 --- a/source/isaaclab/isaaclab/utils/warp/fabric.py +++ b/source/isaaclab/isaaclab/utils/warp/fabric.py @@ -18,12 +18,14 @@ if TYPE_CHECKING: FabricArrayUInt32 = Any FabricArrayMat44d = Any + IndexedFabricArrayMat44d = Any ArrayUInt32 = Any ArrayUInt32_1d = Any ArrayFloat32_2d = Any else: FabricArrayUInt32 = wp.fabricarray(dtype=wp.uint32) FabricArrayMat44d = wp.fabricarray(dtype=wp.mat44d) + IndexedFabricArrayMat44d = wp.indexedfabricarray(dtype=wp.mat44d) ArrayUInt32 = wp.array(ndim=1, dtype=wp.uint32) ArrayUInt32_1d = wp.array(dtype=wp.uint32) ArrayFloat32_2d = wp.array(ndim=2, dtype=wp.float32) @@ -163,6 +165,180 @@ def compose_fabric_transformation_matrix_from_warp_arrays( ) +@wp.kernel(enable_backward=False) +def decompose_indexed_fabric_transforms( + fabric_matrices: IndexedFabricArrayMat44d, + array_positions: ArrayFloat32_2d, + array_orientations: ArrayFloat32_2d, + array_scales: ArrayFloat32_2d, + indices: ArrayUInt32, +): + """Decompose indexed Fabric transformation matrices into position, orientation, and scale. + + Like :func:`decompose_fabric_transformation_matrix_to_warp_arrays` but operates on a + :class:`wp.indexedfabricarray` that already encodes the view-to-fabric mapping, removing + the need for a separate ``mapping`` array. + + Args: + fabric_matrices: Indexed fabric array containing 4x4 transformation matrices. + array_positions: Output array for positions [m], shape (N, 3). + array_orientations: Output array for quaternions in xyzw format, shape (N, 4). + array_scales: Output array for scales, shape (N, 3). + indices: View indices to process (subset selection). + """ + output_index = wp.tid() + view_index = indices[output_index] + + position, rotation, scale = _decompose_transformation_matrix(wp.mat44f(fabric_matrices[view_index])) + + if array_positions.shape[0] > 0: + array_positions[output_index, 0] = position[0] + array_positions[output_index, 1] = position[1] + array_positions[output_index, 2] = position[2] + if array_orientations.shape[0] > 0: + array_orientations[output_index, 0] = rotation[0] + array_orientations[output_index, 1] = rotation[1] + array_orientations[output_index, 2] = rotation[2] + array_orientations[output_index, 3] = rotation[3] + if array_scales.shape[0] > 0: + array_scales[output_index, 0] = scale[0] + array_scales[output_index, 1] = scale[1] + array_scales[output_index, 2] = scale[2] + + +@wp.kernel(enable_backward=False) +def compose_indexed_fabric_transforms( + fabric_matrices: IndexedFabricArrayMat44d, + array_positions: ArrayFloat32_2d, + array_orientations: ArrayFloat32_2d, + array_scales: ArrayFloat32_2d, + broadcast_positions: bool, + broadcast_orientations: bool, + broadcast_scales: bool, + indices: ArrayUInt32, +): + """Compose indexed Fabric transformation matrices from position, orientation, and scale. + + Like :func:`compose_fabric_transformation_matrix_from_warp_arrays` but operates on a + :class:`wp.indexedfabricarray` that already encodes the view-to-fabric mapping, removing + the need for a separate ``mapping`` array. + + Args: + fabric_matrices: Indexed fabric array containing 4x4 transformation matrices to update. + array_positions: Input array for positions [m], shape (N, 3). + array_orientations: Input array for quaternions in xyzw format, shape (N, 4). + array_scales: Input array for scales, shape (N, 3). + broadcast_positions: If True, use first position for all prims. + broadcast_orientations: If True, use first orientation for all prims. + broadcast_scales: If True, use first scale for all prims. + indices: View indices to process (subset selection). + """ + i = wp.tid() + view_index = indices[i] + position, rotation, scale = _decompose_transformation_matrix(wp.mat44f(fabric_matrices[view_index])) + + if array_positions.shape[0] > 0: + if broadcast_positions: + index = 0 + else: + index = i + position[0] = array_positions[index, 0] + position[1] = array_positions[index, 1] + position[2] = array_positions[index, 2] + if array_orientations.shape[0] > 0: + if broadcast_orientations: + index = 0 + else: + index = i + rotation[0] = array_orientations[index, 0] + rotation[1] = array_orientations[index, 1] + rotation[2] = array_orientations[index, 2] + rotation[3] = array_orientations[index, 3] + if array_scales.shape[0] > 0: + if broadcast_scales: + index = 0 + else: + index = i + scale[0] = array_scales[index, 0] + scale[1] = array_scales[index, 1] + scale[2] = array_scales[index, 2] + + fabric_matrices[view_index] = wp.mat44d( # type: ignore[arg-type] + wp.transpose(wp.transform_compose(position, rotation, scale)) # type: ignore[arg-type] + ) + + +@wp.kernel(enable_backward=False) +def update_indexed_local_matrix_from_world( + child_world_matrices: IndexedFabricArrayMat44d, + parent_world_matrices: IndexedFabricArrayMat44d, + child_local_matrices: IndexedFabricArrayMat44d, + indices: ArrayUInt32, +): + """Recompute child localMatrix from (parent worldMatrix, child worldMatrix). + + Computes ``child_local = inv(parent_world) * child_world`` per prim and writes the + result back to the child's :data:`omni:fabric:localMatrix` so that subsequent + ``get_local_poses`` calls see consistent values after a world-pose write. + + All three indexed arrays are expected to be indexed by the same per-view indices + (i.e. ``view_to_child_fabric``, ``view_to_parent_fabric``, ``view_to_child_fabric``) + so the kernel only needs the view-side indices. + + Storage convention: Fabric matrices are stored as the transpose of the standard + column-major math convention. Math is ``local = inv(parent) * world``; under + the transpose identity ``(A * B)^T = B^T * A^T`` (and ``inv(A^T) = inv(A)^T``) + that is equivalent to storage-side ``local^T = world^T * inv(parent^T)``, so we + can compute it directly on the stored matrices without explicit transposes. + + Args: + child_world_matrices: Indexed fabric array of child world matrices (read). + parent_world_matrices: Indexed fabric array of parent world matrices (read). + child_local_matrices: Indexed fabric array of child local matrices (written). + indices: View indices to process. + """ + i = wp.tid() + view_index = indices[i] + child_world = wp.mat44f(child_world_matrices[view_index]) + parent_world = wp.mat44f(parent_world_matrices[view_index]) + child_local_matrices[view_index] = wp.mat44d( # type: ignore[arg-type] + child_world * wp.inverse(parent_world) + ) + + +@wp.kernel(enable_backward=False) +def update_indexed_world_matrix_from_local( + child_local_matrices: IndexedFabricArrayMat44d, + parent_world_matrices: IndexedFabricArrayMat44d, + child_world_matrices: IndexedFabricArrayMat44d, + indices: ArrayUInt32, +): + """Recompute child worldMatrix from (parent worldMatrix, child localMatrix). + + Computes ``child_world = parent_world * child_local`` per prim and writes the + result back to the child's :data:`omni:fabric:worldMatrix`. Used after a + ``set_local_poses`` write so that subsequent ``get_world_poses`` calls see + consistent values. Mirror of :func:`update_indexed_local_matrix_from_world`. + + Args: + child_local_matrices: Indexed fabric array of child local matrices (read). + parent_world_matrices: Indexed fabric array of parent world matrices (read). + child_world_matrices: Indexed fabric array of child world matrices (written). + indices: View indices to process. + + Storage convention: same as :func:`update_indexed_local_matrix_from_world`. + Math is ``world = parent * local``; under the transpose identity that becomes + storage-side ``world^T = local^T * parent^T``, no explicit transposes needed. + """ + i = wp.tid() + view_index = indices[i] + child_local = wp.mat44f(child_local_matrices[view_index]) + parent_world = wp.mat44f(parent_world_matrices[view_index]) + child_world_matrices[view_index] = wp.mat44d( # type: ignore[arg-type] + child_local * parent_world + ) + + @wp.func def _decompose_transformation_matrix(m: Any): # -> tuple[wp.vec3f, wp.quatf, wp.vec3f] """Decompose a 4x4 transformation matrix into position, orientation, and scale. diff --git a/source/isaaclab/test/sim/frame_view_contract_utils.py b/source/isaaclab/test/sim/frame_view_contract_utils.py index 8cd73c02b6f..7ff53025415 100644 --- a/source/isaaclab/test/sim/frame_view_contract_utils.py +++ b/source/isaaclab/test/sim/frame_view_contract_utils.py @@ -193,7 +193,8 @@ def test_set_world_roundtrip(device, view_factory): try: new_pos = _wp_vec3f([[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]], device=device) new_quat = _wp_vec4f([[0.0, 0.0, 0.7071068, 0.7071068], [0.0, 0.0, 0.0, 1.0]], device=device) - bundle.view.set_world_poses(new_pos, new_quat) + with bundle.view.xform_world_space_writer() as w: + w.set_poses(new_pos, new_quat) ret_pos, ret_quat = bundle.view.get_world_poses() torch.testing.assert_close(_t(ret_pos), _t(new_pos), atol=ATOL, rtol=0) @@ -209,7 +210,8 @@ def test_set_local_roundtrip(device, view_factory): try: new_pos = _wp_vec3f([[0.5, 0.3, 0.1], [0.2, 0.7, 0.4]], device=device) new_quat = _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device) - bundle.view.set_local_poses(new_pos, new_quat) + with bundle.view.xform_local_space_writer() as w: + w.set_poses(new_pos, new_quat) ret_pos, ret_quat = bundle.view.get_local_poses() torch.testing.assert_close(_t(ret_pos), _t(new_pos), atol=ATOL, rtol=0) @@ -224,10 +226,11 @@ def test_set_world_does_not_move_parent(device, view_factory): bundle = view_factory(num_envs=2, device=device) try: parent_before = bundle.get_parent_pos(2, device).clone() - bundle.view.set_world_poses( - _wp_vec3f([[99.0, 99.0, 99.0], [88.0, 88.0, 88.0]], device=device), - _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), - ) + with bundle.view.xform_world_space_writer() as w: + w.set_poses( + _wp_vec3f([[99.0, 99.0, 99.0], [88.0, 88.0, 88.0]], device=device), + _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), + ) parent_after = bundle.get_parent_pos(2, device) torch.testing.assert_close(parent_after, parent_before, atol=0, rtol=0) @@ -241,10 +244,11 @@ def test_set_local_does_not_move_parent(device, view_factory): bundle = view_factory(num_envs=2, device=device) try: parent_before = bundle.get_parent_pos(2, device).clone() - bundle.view.set_local_poses( - _wp_vec3f([[0.5, 0.5, 0.5], [1.0, 1.0, 1.0]], device=device), - _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), - ) + with bundle.view.xform_local_space_writer() as w: + w.set_poses( + _wp_vec3f([[0.5, 0.5, 0.5], [1.0, 1.0, 1.0]], device=device), + _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), + ) parent_after = bundle.get_parent_pos(2, device) torch.testing.assert_close(parent_after, parent_before, atol=0, rtol=0) @@ -264,10 +268,11 @@ def test_set_world_updates_local(device, view_factory): desired_offset = torch.tensor([[0.3, 0.7, 0.2], [0.8, 0.1, 0.6]], device=device) new_world = parent_pos + desired_offset - bundle.view.set_world_poses( - _wp_vec3f(new_world.tolist(), device=device), - _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), - ) + with bundle.view.xform_world_space_writer() as w: + w.set_poses( + _wp_vec3f(new_world.tolist(), device=device), + _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), + ) local_pos = _t(bundle.view.get_local_poses()[0]) torch.testing.assert_close(local_pos, desired_offset, atol=ATOL, rtol=0) @@ -285,10 +290,11 @@ def test_set_local_updates_world(device, view_factory): try: parent_pos = bundle.get_parent_pos(2, device) new_offset = torch.tensor([[0.4, 0.9, 0.15], [0.6, 0.2, 0.85]], device=device) - bundle.view.set_local_poses( - _wp_vec3f(new_offset.tolist(), device=device), - _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), - ) + with bundle.view.xform_local_space_writer() as w: + w.set_poses( + _wp_vec3f(new_offset.tolist(), device=device), + _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), + ) world_pos = _t(bundle.view.get_world_poses()[0]) torch.testing.assert_close(world_pos, parent_pos + new_offset, atol=ATOL, rtol=0) @@ -303,7 +309,8 @@ def test_set_world_partial_position_only(device, view_factory): try: _, orig_quat = bundle.view.get_world_poses() new_pos = _wp_vec3f([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], device=device) - bundle.view.set_world_poses(positions=new_pos) + with bundle.view.xform_world_space_writer() as w: + w.set_poses(positions=new_pos) ret_pos, ret_quat = bundle.view.get_world_poses() torch.testing.assert_close(_t(ret_pos), _t(new_pos), atol=ATOL, rtol=0) @@ -319,7 +326,8 @@ def test_set_world_partial_orientation_only(device, view_factory): try: orig_pos, _ = bundle.view.get_world_poses() new_quat = _wp_vec4f([[0.0, 0.0, 0.7071068, 0.7071068], [0.7071068, 0.0, 0.0, 0.7071068]], device=device) - bundle.view.set_world_poses(orientations=new_quat) + with bundle.view.xform_world_space_writer() as w: + w.set_poses(orientations=new_quat) ret_pos, ret_quat = bundle.view.get_world_poses() torch.testing.assert_close(_t(ret_pos), _t(orig_pos), atol=ATOL, rtol=0) @@ -335,7 +343,8 @@ def test_set_local_partial_position_only(device, view_factory): try: _, orig_quat = bundle.view.get_local_poses() new_pos = _wp_vec3f([[0.2, 0.3, 0.4], [0.5, 0.6, 0.7]], device=device) - bundle.view.set_local_poses(translations=new_pos) + with bundle.view.xform_local_space_writer() as w: + w.set_poses(positions=new_pos) ret_pos, ret_quat = bundle.view.get_local_poses() torch.testing.assert_close(_t(ret_pos), _t(new_pos), atol=ATOL, rtol=0) @@ -352,7 +361,8 @@ def test_set_world_indexed_only_affects_subset(device, view_factory): orig_pos = _t(bundle.view.get_world_poses()[0]).clone() indices = wp.array([1, 3], dtype=wp.int32, device=device) new_pos = _wp_vec3f([[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]], device=device) - bundle.view.set_world_poses(positions=new_pos, indices=indices) + with bundle.view.xform_world_space_writer() as w: + w.set_poses(positions=new_pos, indices=indices) updated = _t(bundle.view.get_world_poses()[0]) torch.testing.assert_close(updated[0], orig_pos[0], atol=0, rtol=0) @@ -401,6 +411,24 @@ def test_return_types_are_torcharray(device, view_factory): f"get_local_poses(indices)[1] must be ProxyArray, got {type(lquat_idx).__name__}" ) + world_scales_full = bundle.view.get_world_scales() + assert isinstance(world_scales_full, ProxyArray), ( + f"get_world_scales() must be ProxyArray, got {type(world_scales_full).__name__}" + ) + world_scales_idx = bundle.view.get_world_scales(indices) + assert isinstance(world_scales_idx, ProxyArray), ( + f"get_world_scales(indices) must be ProxyArray, got {type(world_scales_idx).__name__}" + ) + + local_scales_full = bundle.view.get_local_scales() + assert isinstance(local_scales_full, ProxyArray), ( + f"get_local_scales() must be ProxyArray, got {type(local_scales_full).__name__}" + ) + local_scales_idx = bundle.view.get_local_scales(indices) + assert isinstance(local_scales_idx, ProxyArray), ( + f"get_local_scales(indices) must be ProxyArray, got {type(local_scales_idx).__name__}" + ) + scales_full = bundle.view.get_scales() assert isinstance(scales_full, ProxyArray), f"get_scales() must be ProxyArray, got {type(scales_full).__name__}" scales_idx = bundle.view.get_scales(indices) @@ -409,3 +437,110 @@ def test_return_types_are_torcharray(device, view_factory): ) finally: bundle.teardown() + + +# ================================================================== +# Contract: Scales +# ================================================================== + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_local_scales_default_identity(device, view_factory): + """Local scales are (1, 1, 1) by default (no authored scale transforms).""" + bundle = view_factory(num_envs=2, device=device) + try: + scales = _t(bundle.view.get_local_scales()) + expected = torch.ones(2, 3, device=device) + torch.testing.assert_close(scales, expected, atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_world_scales_default_identity(device, view_factory): + """World scales are (1, 1, 1) by default (no authored scale transforms).""" + bundle = view_factory(num_envs=2, device=device) + try: + scales = _t(bundle.view.get_world_scales()) + expected = torch.ones(2, 3, device=device) + torch.testing.assert_close(scales, expected, atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_local_scales_roundtrip(device, view_factory): + """Writing scales through the local-space writer roundtrips via ``get_local_scales``.""" + bundle = view_factory(num_envs=2, device=device) + try: + new_scales = _wp_vec3f([[2.0, 3.0, 4.0], [0.5, 1.5, 2.5]], device=device) + with bundle.view.xform_local_space_writer() as w: + w.set_scales(new_scales) + + ret_scales = _t(bundle.view.get_local_scales()) + torch.testing.assert_close(ret_scales, _t(new_scales), atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_world_scales_roundtrip(device, view_factory): + """Writing scales through the world-space writer roundtrips via ``get_world_scales``.""" + bundle = view_factory(num_envs=2, device=device) + try: + new_scales = _wp_vec3f([[2.0, 3.0, 4.0], [0.5, 1.5, 2.5]], device=device) + with bundle.view.xform_world_space_writer() as w: + w.set_scales(new_scales) + + ret_scales = _t(bundle.view.get_world_scales()) + torch.testing.assert_close(ret_scales, _t(new_scales), atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_local_scales_do_not_affect_local_poses(device, view_factory): + """Changing scales does not change local pose translations/orientations.""" + bundle = view_factory(num_envs=2, device=device) + try: + local_pos_before = _t(bundle.view.get_local_poses()[0]).clone() + local_ori_before = _t(bundle.view.get_local_poses()[1]).clone() + + new_scales = _wp_vec3f([[3.0, 3.0, 3.0], [5.0, 5.0, 5.0]], device=device) + with bundle.view.xform_local_space_writer() as w: + w.set_scales(new_scales) + + local_pos_after = _t(bundle.view.get_local_poses()[0]) + local_ori_after = _t(bundle.view.get_local_poses()[1]) + + torch.testing.assert_close(local_pos_after, local_pos_before, atol=ATOL, rtol=0) + torch.testing.assert_close(local_ori_after, local_ori_before, atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_scale_getters_return_proxyarray(device, view_factory): + """Public API contract -- scale getters return ProxyArray.""" + bundle = view_factory(num_envs=2, device=device) + try: + local_scales = bundle.view.get_local_scales() + assert isinstance(local_scales, ProxyArray), ( + f"get_local_scales() must return ProxyArray, got {type(local_scales).__name__}" + ) + world_scales = bundle.view.get_world_scales() + assert isinstance(world_scales, ProxyArray), ( + f"get_world_scales() must return ProxyArray, got {type(world_scales).__name__}" + ) + + indices = wp.array([0], dtype=wp.int32, device=bundle.view.device) + local_indexed = bundle.view.get_local_scales(indices) + assert isinstance(local_indexed, ProxyArray), ( + f"get_local_scales(indices) must return ProxyArray, got {type(local_indexed).__name__}" + ) + world_indexed = bundle.view.get_world_scales(indices) + assert isinstance(world_indexed, ProxyArray), ( + f"get_world_scales(indices) must return ProxyArray, got {type(world_indexed).__name__}" + ) + finally: + bundle.teardown() diff --git a/source/isaaclab/test/sim/test_views_xform_prim.py b/source/isaaclab/test/sim/test_views_xform_prim.py index 64cd86a7466..61b281ca5e9 100644 --- a/source/isaaclab/test/sim/test_views_xform_prim.py +++ b/source/isaaclab/test/sim/test_views_xform_prim.py @@ -226,8 +226,10 @@ def test_nested_hierarchy_world_poses(device): frames_view = FrameView("/World/Frame_.*", device=device) targets_view = FrameView("/World/Frame_.*/Target", device=device) - frames_view.set_local_poses(translations=torch.tensor(frame_positions, device=device)) - targets_view.set_local_poses(translations=torch.tensor(target_positions, device=device)) + with frames_view.xform_local_space_writer() as w: + w.set_poses(positions=torch.tensor(frame_positions, device=device)) + with targets_view.xform_local_space_writer() as w: + w.set_poses(positions=torch.tensor(target_positions, device=device)) world_pos = targets_view.get_world_poses()[0].torch expected = torch.tensor( @@ -237,6 +239,67 @@ def test_nested_hierarchy_world_poses(device): torch.testing.assert_close(world_pos, expected, atol=1e-5, rtol=0) +# ================================================================== +# USD-only: Cross-space scale conversion under a scaled parent +# ================================================================== +# +# These exercise the USD-specific world<->local scale math that the shared +# contract suite cannot cover: the contract fixtures only expose a unit-scale +# parent, and Newton has no independent local scale (local == world), so the +# parent-aware conversions below are not universal invariants. OvPhysxFrameView +# inherits this behavior by delegating to UsdFrameView. + + +def _make_scaled_parent_child_view(device, parent_scale, child_scale=None): + """Build a 1-prim view with a scaled parent (and optional authored child scale).""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/Parent_0", "Xform", translation=PARENT_POS, scale=parent_scale, stage=stage) + child_kwargs = {} if child_scale is None else {"scale": child_scale} + sim_utils.create_prim("/World/Parent_0/Child", "Xform", translation=CHILD_OFFSET, stage=stage, **child_kwargs) + return FrameView("/World/Parent_.*/Child", device=device) + + +@pytest.mark.parametrize("device", ["cpu", "cuda"]) +def test_world_scale_composes_with_parent_scale(device): + """Under a scaled parent, ``get_world_scales`` returns ``parent_scale * local_scale``. + + Writes the child's local scale via the local-space writer and verifies + that reading the world scale composes with the parent's scale. + """ + if device == "cuda" and not torch.cuda.is_available(): + pytest.skip("CUDA not available") + + view = _make_scaled_parent_child_view(device, parent_scale=(2.0, 1.0, 1.0)) + local_scales = wp.array([wp.vec3f(3.0, 1.0, 1.0)], dtype=wp.vec3f, device=device) + with view.xform_local_space_writer() as w: + w.set_scales(local_scales) + + world_scales = view.get_world_scales().torch + expected = torch.tensor([[6.0, 1.0, 1.0]], dtype=torch.float32, device=device) + torch.testing.assert_close(world_scales, expected, atol=1e-5, rtol=0) + + +@pytest.mark.parametrize("device", ["cpu", "cuda"]) +def test_local_scale_inverts_parent_when_writing_world_scale(device): + """Writing a world scale derives ``local = world / parent_scale`` under a scaled parent. + + Writes the child's world scale via the world-space writer and verifies + that the derived local scale is the world scale divided by the + parent's scale. + """ + if device == "cuda" and not torch.cuda.is_available(): + pytest.skip("CUDA not available") + + view = _make_scaled_parent_child_view(device, parent_scale=(2.0, 1.0, 1.0)) + world_scales = wp.array([wp.vec3f(6.0, 1.0, 1.0)], dtype=wp.vec3f, device=device) + with view.xform_world_space_writer() as w: + w.set_scales(world_scales) + + local_scales = view.get_local_scales().torch + expected = torch.tensor([[3.0, 1.0, 1.0]], dtype=torch.float32, device=device) + torch.testing.assert_close(local_scales, expected, atol=1e-5, rtol=0) + + # ================================================================== # USD-only: Comparison with Isaac Sim # ================================================================== @@ -291,7 +354,8 @@ def test_with_franka_robots(device): new_pos = torch.tensor([[10.0, 10.0, 0.0], [-40.0, -40.0, 0.0]], device=device) new_quat = torch.tensor([[0.0, 0.0, 0.7071068, 0.7071068], [0.0, 0.0, -0.7071068, 0.7071068]], device=device) - view.set_world_poses(positions=new_pos, orientations=new_quat) + with view.xform_world_space_writer() as w: + w.set_poses(positions=new_pos, orientations=new_quat) ret_pos = view.get_world_poses()[0].torch torch.testing.assert_close(ret_pos, new_pos, atol=1e-5, rtol=0) diff --git a/source/isaaclab/test/terrains/check_terrain_importer.py b/source/isaaclab/test/terrains/check_terrain_importer.py index a8229023f90..901cfc420be 100644 --- a/source/isaaclab/test/terrains/check_terrain_importer.py +++ b/source/isaaclab/test/terrains/check_terrain_importer.py @@ -159,7 +159,8 @@ def main(): ball_initial_positions = terrain_importer.env_origins.clone() ball_initial_positions[:, 2] += 5.0 # set initial poses (writes to USD before simulation) - xform_view.set_world_poses(positions=ball_initial_positions) + with xform_view.xform_world_space_writer() as w: + w.set_poses(positions=ball_initial_positions) # Play simulator sim.reset() diff --git a/source/isaaclab/test/terrains/test_terrain_importer.py b/source/isaaclab/test/terrains/test_terrain_importer.py index 5234df4cae5..96375cab075 100644 --- a/source/isaaclab/test/terrains/test_terrain_importer.py +++ b/source/isaaclab/test/terrains/test_terrain_importer.py @@ -316,4 +316,5 @@ def _populate_scene(sim: SimulationContext, num_balls: int = 2048, geom_sphere: ball_initial_positions[:, 2] += 5.0 # set initial poses # note: setting here writes to USD :) - ball_view.set_world_poses(positions=wp.from_torch(ball_initial_positions)) + with ball_view.xform_world_space_writer() as w: + w.set_poses(positions=wp.from_torch(ball_initial_positions)) diff --git a/source/isaaclab_mimic/changelog.d/xform-space-writer.skip b/source/isaaclab_mimic/changelog.d/xform-space-writer.skip new file mode 100644 index 00000000000..8556272d818 --- /dev/null +++ b/source/isaaclab_mimic/changelog.d/xform-space-writer.skip @@ -0,0 +1 @@ +no user-facing change: internal migration of one set_world_poses call site to the new FrameViewSpaceWriter context API 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 4ba068fc8f5..00a29bc04af 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py +++ b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py @@ -126,7 +126,8 @@ def set_pose(self, pose: torch.Tensor): xform_prim = self._get_xform_view() position = pose[..., :3] orientation = pose[..., 3:] - xform_prim.set_world_poses(wp.from_torch(position.contiguous()), wp.from_torch(orientation.contiguous()), None) + with xform_prim.xform_world_space_writer() as writer: + writer.set_poses(wp.from_torch(position.contiguous()), wp.from_torch(orientation.contiguous()), None) class RelativePose(HasPose): diff --git a/source/isaaclab_newton/changelog.d/fabric-local-poses.rst b/source/isaaclab_newton/changelog.d/fabric-local-poses.rst new file mode 100644 index 00000000000..c5c0b0f3c7d --- /dev/null +++ b/source/isaaclab_newton/changelog.d/fabric-local-poses.rst @@ -0,0 +1,19 @@ +Added +^^^^^ + +* Added :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.get_local_scales` + and :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.get_world_scales` + for reading transform (xform) scales. Scale writes go through the writer + scope (see the ``xform-space-writer`` fragment). These transform-scale + APIs are intentionally separate from Newton collision shape geometry + sizes. + +Deprecated +^^^^^^^^^^ + +* Deprecated :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.get_scales` + and :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.set_scales` in favor + of the explicit transform-scale getters ``get_world_scales`` / + ``get_local_scales`` (and the writer scope's ``set_scales``). The + deprecated methods still work but emit a ``DeprecationWarning`` and + preserve Newton's legacy collision shape geometry-scale behavior. diff --git a/source/isaaclab_newton/changelog.d/xform-space-writer.rst b/source/isaaclab_newton/changelog.d/xform-space-writer.rst new file mode 100644 index 00000000000..e04d5325e6a --- /dev/null +++ b/source/isaaclab_newton/changelog.d/xform-space-writer.rst @@ -0,0 +1,13 @@ +Changed +^^^^^^^ + +* :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` now ships + pass-through ``FrameViewWorldSpaceWriter`` / ``FrameViewLocalSpaceWriter`` + implementations so writes follow the new + :meth:`~isaaclab.sim.views.BaseFrameView.xform_world_space_writer` / + :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer` context API. + ``set_world_poses`` / ``set_local_poses`` shims still work (one-time + ``DeprecationWarning`` per class). The legacy ``set_scales`` / + ``get_scales`` paths continue to operate on Newton collision-shape + geometry sizes -- they are not routed through the writer because the + writer's ``set_scales`` writes the transform-scale state. diff --git a/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py b/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py index c7c31ae3692..bbb29c745ea 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py @@ -17,6 +17,7 @@ from isaaclab.cloner.cloner_utils import get_suffix, iter_clone_plan_matches, split_clone_template from isaaclab.physics import PhysicsEvent from isaaclab.sim.views.base_frame_view import BaseFrameView +from isaaclab.sim.views.xform_space_writer import FrameViewLocalSpaceWriter, FrameViewWorldSpaceWriter from isaaclab.utils.string import resolve_matching_names from isaaclab.utils.warp import ProxyArray @@ -104,7 +105,7 @@ def _write_site_local_from_local_poses( @wp.kernel -def _gather_scales( +def _gather_shape_scales( shape_scale: wp.array(dtype=wp.vec3f), shape_body: wp.array(dtype=wp.int32), site_body: wp.array(dtype=wp.int32), @@ -112,7 +113,7 @@ def _gather_scales( num_shapes: wp.int32, out_scales: wp.array(dtype=wp.vec3f), ): - """Gather per-site scales from collision shapes on the same body.""" + """Gather legacy per-site geometry scales from collision shapes on the same body.""" i = wp.tid() si = indices[i] bid = site_body[si] @@ -126,7 +127,7 @@ def _gather_scales( @wp.kernel -def _scatter_scales( +def _scatter_shape_scales( site_body: wp.array(dtype=wp.int32), indices: wp.array(dtype=wp.int32), new_scales: wp.array(dtype=wp.vec3f), @@ -134,7 +135,7 @@ def _scatter_scales( num_shapes: wp.int32, shape_scale: wp.array(dtype=wp.vec3f), ): - """Scatter per-site scales to collision shapes on the same body.""" + """Scatter legacy per-site geometry scales to collision shapes on the same body.""" i = wp.tid() si = indices[i] bid = site_body[si] @@ -143,6 +144,28 @@ def _scatter_scales( shape_scale[s] = new_scales[i] +@wp.kernel +def _gather_xform_scales( + site_xform_scale: wp.array(dtype=wp.vec3f), + indices: wp.array(dtype=wp.int32), + out_scales: wp.array(dtype=wp.vec3f), +): + """Gather per-site xform scales.""" + i = wp.tid() + out_scales[i] = site_xform_scale[indices[i]] + + +@wp.kernel +def _scatter_xform_scales( + indices: wp.array(dtype=wp.int32), + new_scales: wp.array(dtype=wp.vec3f), + site_xform_scale: wp.array(dtype=wp.vec3f), +): + """Scatter per-site xform scales.""" + i = wp.tid() + site_xform_scale[indices[i]] = new_scales[i] + + class NewtonSiteFrameView(BaseFrameView): """Batched Newton site view for non-physics frames. @@ -178,43 +201,51 @@ def __init__( stage = sim_utils.get_current_stage() if stage is None else stage self._site_specs = self._resolve_site_specs(stage, validate_xform_ops) self._site_labels: list[str] = [] + self._site_label_scales: list[tuple[float, float, float]] = [] self._site_body: wp.array | None = None self._site_local: wp.array | None = None + self._site_xform_scale: wp.array | None = None self._site_indices: wp.array | None = None self._pos_buf: wp.array | None = None self._quat_buf: wp.array | None = None self._local_pos_buf: wp.array | None = None self._local_quat_buf: wp.array | None = None + self._scale_buf: wp.array | None = None self._pos_ta: ProxyArray | None = None self._quat_ta: ProxyArray | None = None self._local_pos_ta: ProxyArray | None = None self._local_quat_ta: ProxyArray | None = None + self._scale_ta: ProxyArray | None = None self._count = 0 model = NewtonManager.get_model() if model is not None: self._initialize_from_specs(model) else: - for body_patterns, xform, per_world, _env_ids in self._site_specs: + for body_patterns, xform, scale, per_world, _env_ids in self._site_specs: if body_patterns is None: self._site_labels.append(NewtonManager.cl_register_site(None, xform, per_world=per_world)) + self._site_label_scales.append(scale) else: for body_pattern in body_patterns: self._site_labels.append(NewtonManager.cl_register_site(body_pattern, xform)) + self._site_label_scales.append(scale) self._physics_ready_handle = NewtonManager.register_callback( self._on_physics_ready, PhysicsEvent.PHYSICS_READY, name=f"site_view_{self._prim_path}" ) def _resolve_site_specs( self, stage, validate_xform_ops: bool - ) -> list[tuple[tuple[str, ...] | None, wp.transform, bool, tuple[int, ...] | None]]: + ) -> list[tuple[tuple[str, ...] | None, wp.transform, tuple[float, float, float], bool, tuple[int, ...] | None]]: """Resolve source prims into Newton site registration specs.""" plan = sim_utils.SimulationContext.instance().get_clone_plan() model = NewtonManager.get_model() body_labels = list(model.body_label) if model is not None else () shape_labels = list(model.shape_label) if model is not None else () use_clone_body_pattern = model is None - specs: list[tuple[tuple[str, ...] | None, wp.transform, bool, tuple[int, ...] | None]] = [] + specs: list[ + tuple[tuple[str, ...] | None, wp.transform, tuple[float, float, float], bool, tuple[int, ...] | None] + ] = [] for path_expr in self._prim_paths: if resolve_matching_names(path_expr, body_labels, raise_when_no_match=False)[1]: @@ -268,8 +299,8 @@ def _resolve_source_prim( env_ids: tuple[int, ...] | None, use_clone_body_pattern: bool, stage, - ) -> tuple[tuple[str, ...] | None, wp.transform, bool, tuple[int, ...] | None]: - """Resolve one source prim into body patterns and a local frame.""" + ) -> tuple[tuple[str, ...] | None, wp.transform, tuple[float, float, float], bool, tuple[int, ...] | None]: + """Resolve one source prim into body patterns, local frame, and xform scale.""" prim_path = prim.GetPath().pathString if prim.HasAPI(UsdPhysics.RigidBodyAPI) or prim.HasAPI(UsdPhysics.ArticulationRootAPI): raise ValueError( @@ -281,6 +312,13 @@ def _resolve_source_prim( if not sim_utils.validate_standard_xform_ops(prim): raise ValueError(f"FrameView prim '{prim_path}' does not have standard xform ops.") + scale_attr = prim.GetAttribute("xformOp:scale") + scale = ( + tuple(float(v) for v in scale_attr.Get()) + if scale_attr and scale_attr.HasAuthoredValue() + else (1.0, 1.0, 1.0) + ) + body_prim = prim.GetParent() while body_prim and body_prim.IsValid(): if body_prim.HasAPI(UsdPhysics.RigidBodyAPI) or body_prim.HasAPI(UsdPhysics.ArticulationRootAPI): @@ -300,7 +338,7 @@ def _resolve_source_prim( raise RuntimeError( f"FrameView destination root '{destination_root}' does not end with '{suffix}'." ) - return (destination_root[: -len(suffix)],), wp.transform(pos, quat), False, env_ids + return (destination_root[: -len(suffix)],), wp.transform(pos, quat), scale, False, env_ids body_patterns = [] for env_id in env_ids: destination_root = destination_template.format(env_id) @@ -309,7 +347,7 @@ def _resolve_source_prim( f"FrameView destination root '{destination_root}' does not end with '{suffix}'." ) body_patterns.append(destination_root[: -len(suffix)]) - return tuple(body_patterns), wp.transform(pos, quat), False, env_ids + return tuple(body_patterns), wp.transform(pos, quat), scale, False, env_ids else: raise RuntimeError(f"FrameView source body '{body_path}' is not under '{source_root}'.") if use_clone_body_pattern: @@ -318,7 +356,7 @@ def _resolve_source_prim( body_patterns = tuple(destination_template.format(env_id) + suffix for env_id in env_ids) else: body_patterns = (body_path,) - return body_patterns, wp.transform(pos, quat), False, env_ids + return body_patterns, wp.transform(pos, quat), scale, False, env_ids body_prim = body_prim.GetParent() ref_path = source_root @@ -329,7 +367,7 @@ def _resolve_source_prim( ref_path = source_root[: -len(source_suffix)] if source_suffix else source_root ref_prim = stage.GetPrimAtPath(ref_path) if ref_path is not None else None pos, quat = sim_utils.resolve_prim_pose(prim, ref_prim if ref_prim and ref_prim.IsValid() else None) - return None, wp.transform(pos, quat), source_root is not None, env_ids + return None, wp.transform(pos, quat), scale, source_root is not None, env_ids def _on_physics_ready(self, _event) -> None: """Callback invoked when the Newton model becomes available.""" @@ -342,8 +380,9 @@ def _initialize_from_site_map(self, model) -> None: xform_t = wp.to_torch(model.shape_transform) site_bodies: list[int] = [] site_locals: list[list[float]] = [] + site_scales: list[tuple[float, float, float]] = [] - for site_label in self._site_labels: + for site_label, scale in zip(self._site_labels, self._site_label_scales, strict=True): global_idx, per_world = site_map[site_label] site_indices = ( [global_idx] if per_world is None else [site_idx for sites in per_world for site_idx in sites] @@ -351,16 +390,18 @@ def _initialize_from_site_map(self, model) -> None: for site_idx in site_indices: site_bodies.append(int(body_t[site_idx].item())) site_locals.append([float(v) for v in xform_t[site_idx].tolist()]) + site_scales.append(scale) - self._create_buffers(site_bodies, site_locals) + self._create_buffers(site_bodies, site_locals, site_scales) def _initialize_from_specs(self, model) -> None: """Initialize arrays directly from resolved specs and Newton body labels.""" body_labels = list(model.body_label) site_bodies: list[int] = [] site_locals: list[list[float]] = [] + site_scales: list[tuple[float, float, float]] = [] - for body_patterns, xform, per_world, env_ids in self._site_specs: + for body_patterns, xform, scale, per_world, env_ids in self._site_specs: if body_patterns is None: if per_world: if NewtonManager._world_xforms is None: @@ -370,9 +411,11 @@ def _initialize_from_specs(self, model) -> None: world_xform = NewtonManager._world_xforms[world_id] site_bodies.append(WORLD_BODY_INDEX) site_locals.append([float(v) for v in wp.transform_multiply(world_xform, xform)]) + site_scales.append(scale) else: site_bodies.append(WORLD_BODY_INDEX) site_locals.append([float(v) for v in xform]) + site_scales.append(scale) continue for body_pattern in body_patterns: @@ -385,24 +428,33 @@ def _initialize_from_specs(self, model) -> None: for body_idx in matched_indices: site_bodies.append(body_idx) site_locals.append([float(v) for v in xform]) + site_scales.append(scale) - self._create_buffers(site_bodies, site_locals) + self._create_buffers(site_bodies, site_locals, site_scales) - def _create_buffers(self, site_bodies: list[int], site_locals: list[list[float]]) -> None: + def _create_buffers( + self, + site_bodies: list[int], + site_locals: list[list[float]], + site_scales: list[tuple[float, float, float]], + ) -> None: """Allocate view buffers from body indices and local transforms.""" self._count = len(site_bodies) device = self._device self._site_body = wp.array(site_bodies, dtype=wp.int32, device=device) self._site_local = wp.array([wp.transform(*x) for x in site_locals], dtype=wp.transformf, device=device) + self._site_xform_scale = wp.array([wp.vec3f(*scale) for scale in site_scales], dtype=wp.vec3f, device=device) self._site_indices = wp.array(list(range(self._count)), dtype=wp.int32, device=device) self._pos_buf = wp.zeros(self._count, dtype=wp.vec3f, device=device) self._quat_buf = wp.zeros(self._count, dtype=wp.vec4f, device=device) self._local_pos_buf = wp.zeros(self._count, dtype=wp.vec3f, device=device) self._local_quat_buf = wp.zeros(self._count, dtype=wp.vec4f, device=device) + self._scale_buf = wp.zeros(self._count, dtype=wp.vec3f, 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) + self._scale_ta = ProxyArray(self._site_xform_scale) @property def prims(self) -> list: @@ -422,7 +474,21 @@ def device(self) -> str: """Device where arrays are allocated.""" return self._device - def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + # ------------------------------------------------------------------ + # Writer factory hooks (pass-through; Newton has no separate Fabric storage) + # ------------------------------------------------------------------ + + def _make_world_space_writer(self) -> FrameViewWorldSpaceWriter: + return _NewtonWorldSpaceWriter(self) + + def _make_local_space_writer(self) -> FrameViewLocalSpaceWriter: + return _NewtonLocalSpaceWriter(self) + + # ------------------------------------------------------------------ + # Backend hooks + # ------------------------------------------------------------------ + + def _get_world_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: """Get world-space positions and orientations.""" state = NewtonManager.get_state_0() site_indices = self._site_indices if indices is None else indices @@ -441,7 +507,7 @@ def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, return self._pos_ta, self._quat_ta return ProxyArray(pos_buf), ProxyArray(quat_buf) - def set_world_poses( + def _apply_world_pose_write( self, positions: wp.array | None = None, orientations: wp.array | None = None, @@ -453,7 +519,7 @@ def set_world_poses( state = NewtonManager.get_state_0() if positions is None or orientations is None: - cur_pos_ta, cur_quat_ta = self.get_world_poses(indices) + cur_pos_ta, cur_quat_ta = self._get_world_poses_impl(indices) if positions is None: positions = cur_pos_ta.warp if orientations is None: @@ -468,7 +534,7 @@ def set_world_poses( device=self._device, ) - def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + def _get_local_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: """Get body-local positions and orientations.""" site_indices = self._site_indices if indices is None else indices n = self.count if indices is None else len(indices) @@ -486,7 +552,7 @@ def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, return self._local_pos_ta, self._local_quat_ta return ProxyArray(pos_buf), ProxyArray(quat_buf) - def set_local_poses( + def _apply_local_pose_write( self, translations: wp.array | None = None, orientations: wp.array | None = None, @@ -497,7 +563,7 @@ def set_local_poses( return if translations is None or orientations is None: - cur_pos_ta, cur_quat_ta = self.get_local_poses(indices) + cur_pos_ta, cur_quat_ta = self._get_local_poses_impl(indices) if translations is None: translations = cur_pos_ta.warp if orientations is None: @@ -512,15 +578,70 @@ def set_local_poses( device=self._device, ) - def get_scales(self, indices: wp.array | None = None) -> ProxyArray: - """Get per-site scales by reading from the first collision shape on the same body.""" + # ------------------------------------------------------------------ + # Scales + # ------------------------------------------------------------------ + + def _get_world_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Get per-site world xform scales. + + These are transform scales, matching the USD FrameView scale API. They + are intentionally separate from Newton collision shape geometry sizes. + """ + if indices is None: + return self._scale_ta + n = len(indices) + out = wp.zeros(n, dtype=wp.vec3f, device=self._device) + wp.launch( + _gather_xform_scales, + dim=n, + inputs=[self._site_xform_scale, indices], + outputs=[out], + device=self._device, + ) + return ProxyArray(out) + + def _get_local_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Get per-site local xform scales. + + These are transform scales, matching the USD FrameView scale API. They + are intentionally separate from Newton collision shape geometry sizes. + """ + return self._get_world_scales_impl(indices) + + def _apply_world_scale_write(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set per-site world xform scales. + + These update transform scale state only; use deprecated ``set_scales`` if + legacy Newton collision shape geometry-scale behavior is required. + """ + if indices is None: + indices = self._site_indices + n = self.count if indices is self._site_indices else len(indices) + wp.launch( + _scatter_xform_scales, + dim=n, + inputs=[indices, scales, self._site_xform_scale], + device=self._device, + ) + + def _apply_local_scale_write(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set per-site local xform scales. + + These update transform scale state only; use deprecated ``set_scales`` if + legacy Newton collision shape geometry-scale behavior is required. + """ + self._apply_world_scale_write(scales, indices) + + def _get_legacy_shape_scales(self, indices: wp.array | None = None) -> ProxyArray: + """Get Newton legacy geometry scales from collision shapes.""" model = NewtonManager.get_model() num_shapes = model.shape_count site_indices = self._site_indices if indices is None else indices n = self.count if indices is None else len(indices) out = wp.zeros(n, dtype=wp.vec3f, device=self._device) wp.launch( - _gather_scales, + _gather_shape_scales, dim=n, inputs=[model.shape_scale, model.shape_body, self._site_body, site_indices, num_shapes], outputs=[out], @@ -528,15 +649,66 @@ def get_scales(self, indices: wp.array | None = None) -> ProxyArray: ) return ProxyArray(out) - def set_scales(self, scales: wp.array, indices: wp.array | None = None) -> None: - """Set per-site scales by writing to all collision shapes on the same body.""" + def _set_legacy_shape_scales(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set Newton legacy geometry scales on collision shapes.""" model = NewtonManager.get_model() num_shapes = model.shape_count site_indices = self._site_indices if indices is None else indices n = self.count if indices is None else len(indices) wp.launch( - _scatter_scales, + _scatter_shape_scales, dim=n, inputs=[self._site_body, site_indices, scales, model.shape_body, num_shapes, model.shape_scale], device=self._device, ) + + def _get_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Newton legacy: get_scales returns collision shape geometry scales.""" + return self._get_legacy_shape_scales(indices) + + def _set_scales_impl(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Newton legacy: deprecated set_scales writes collision shape geometry scales. + + Newton's legacy ``set_scales`` path is *not* routed through the + :class:`FrameViewSpaceWriterBase` API because it targets a different state + (collision-shape geometry sizes) than the transform-scale state that + the writer's :meth:`~FrameViewSpaceWriterBase.set_scales` operates on. + """ + self._set_legacy_shape_scales(scales, indices) + + +# ---------------------------------------------------------------------- +# Pass-through writer classes +# ---------------------------------------------------------------------- + + +class _NewtonWorldSpaceWriter(FrameViewWorldSpaceWriter): + """Newton world-space writer: pass-through to backend ``_apply_*`` hooks.""" + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + self._view._apply_world_pose_write(positions, orientations, indices) # type: ignore[attr-defined] + + def set_scales(self, scales, indices=None) -> None: + self._view._apply_world_scale_write(scales, indices) # type: ignore[attr-defined] + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._get_world_poses_impl(indices) # type: ignore[attr-defined] + + def get_scales(self, indices=None) -> ProxyArray: + return self._view._get_world_scales_impl(indices) # type: ignore[attr-defined] + + +class _NewtonLocalSpaceWriter(FrameViewLocalSpaceWriter): + """Newton local-space writer: pass-through to backend ``_apply_*`` hooks.""" + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + self._view._apply_local_pose_write(positions, orientations, indices) # type: ignore[attr-defined] + + def set_scales(self, scales, indices=None) -> None: + self._view._apply_local_scale_write(scales, indices) # type: ignore[attr-defined] + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._get_local_poses_impl(indices) # type: ignore[attr-defined] + + def get_scales(self, indices=None) -> ProxyArray: + return self._view._get_local_scales_impl(indices) # type: ignore[attr-defined] 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 d114a1da2a8..174f0684fb5 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 @@ -215,7 +215,8 @@ def test_world_attached_set_world_roundtrip(device): new_pos = _wp_vec3f([[10.0, 20.0, 30.0]], device=device) new_quat = _wp_vec4f([[0.0, 0.0, 0.0, 1.0]], device=device) - view.set_world_poses(new_pos, new_quat) + with view.xform_world_space_writer() as w: + w.set_poses(new_pos, new_quat) ret_pos, ret_quat = view.get_world_poses() torch.testing.assert_close(ret_pos.torch, wp.to_torch(new_pos), atol=1e-5, rtol=0) diff --git a/source/isaaclab_ovphysx/changelog.d/fabric-local-poses.rst b/source/isaaclab_ovphysx/changelog.d/fabric-local-poses.rst new file mode 100644 index 00000000000..fdf33f0b7d9 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/fabric-local-poses.rst @@ -0,0 +1,19 @@ +Added +^^^^^ + +* Added :meth:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView.get_local_scales` + and :meth:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView.get_world_scales`, + which delegate to the internal :class:`~isaaclab.sim.views.UsdFrameView`. + Scale writes go through the writer scope (see the ``xform-space-writer`` + fragment). + +Deprecated +^^^^^^^^^^ + +* Deprecated :meth:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView.get_scales` and + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView.set_scales`. For reads, + use the explicit ``get_local_scales`` (operates on ``xformOp:scale``) or + ``get_world_scales``. For writes, use the writer scope's + ``set_scales``. The deprecated methods still work but emit a + ``DeprecationWarning`` and default to local scales, preserving prior + behavior. diff --git a/source/isaaclab_ovphysx/changelog.d/xform-space-writer.rst b/source/isaaclab_ovphysx/changelog.d/xform-space-writer.rst new file mode 100644 index 00000000000..3561232a311 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/xform-space-writer.rst @@ -0,0 +1,13 @@ +Changed +^^^^^^^ + +* :class:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView` now ships + pass-through ``FrameViewWorldSpaceWriter`` / ``FrameViewLocalSpaceWriter`` + implementations so writes follow the new + :meth:`~isaaclab.sim.views.BaseFrameView.xform_world_space_writer` / + :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer` context API. + ``set_world_poses`` / ``set_local_poses`` shims still work (one-time + ``DeprecationWarning`` per class). Scale writes inside the writer scope + delegate to the internal :class:`~isaaclab.sim.views.UsdFrameView` and + land in the USD stage (no propagation to OVPhysX-side collision-shape + scales). 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 index 5965662d420..ff7912f20f6 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/ovphysx_frame_view.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sim/views/ovphysx_frame_view.py @@ -19,6 +19,7 @@ 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.sim.views.xform_space_writer import FrameViewLocalSpaceWriter, FrameViewWorldSpaceWriter from isaaclab.utils.warp import ProxyArray from isaaclab_ovphysx.physics import OvPhysxManager @@ -629,17 +630,22 @@ def _current_body_q(self) -> wp.array: # World poses # ------------------------------------------------------------------ - def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: - """Get world-space positions and orientations. + # ------------------------------------------------------------------ + # Writer factory hooks (pass-through; OvPhysX has no separate Fabric storage) + # ------------------------------------------------------------------ - Args: - indices: Subset of sites to query. ``None`` means all sites. + def _make_world_space_writer(self) -> FrameViewWorldSpaceWriter: + return _OvPhysxWorldSpaceWriter(self) - 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. - """ + def _make_local_space_writer(self) -> FrameViewLocalSpaceWriter: + return _OvPhysxLocalSpaceWriter(self) + + # ------------------------------------------------------------------ + # Backend hooks + # ------------------------------------------------------------------ + + def _get_world_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + """Get world-space positions and orientations.""" self._require_initialized() body_q = self._current_body_q() @@ -665,31 +671,20 @@ def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ) return self._pos_ta, self._quat_ta - def set_world_poses( + def _apply_world_pose_write( 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. - """ + """Set world-space positions and/or orientations.""" 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) + cur_pos_ta, cur_quat_ta = self._get_world_poses_impl(indices) if positions is None: positions = cur_pos_ta.warp if orientations is None: @@ -714,18 +709,8 @@ def set_world_poses( # 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. - """ + def _get_local_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + """Get parent-relative positions and orientations.""" self._require_initialized() body_q = self._current_body_q() @@ -764,30 +749,20 @@ def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ) return self._local_pos_ta, self._local_quat_ta - def set_local_poses( + def _apply_local_pose_write( 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. - """ + """Set parent-relative translations and/or orientations.""" 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) + cur_pos_ta, cur_quat_ta = self._get_local_poses_impl(indices) if translations is None: translations = cur_pos_ta.warp if orientations is None: @@ -839,39 +814,43 @@ def _ensure_usd_view(self) -> UsdFrameView: ) return self._usd_view - def get_scales(self, indices: wp.array | None = None) -> ProxyArray: - """Get prim scales from the USD stage's ``xformOp:scale`` attribute. + def _get_local_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Get local-space scales (xformOp:scale) via the USD view. .. 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: - A :class:`~isaaclab.utils.warp.ProxyArray` of shape ``(M, 3)``. + updates are not reflected here. """ - return self._ensure_usd_view().get_scales(indices) + return self._ensure_usd_view()._get_local_scales_impl(indices) - def set_scales(self, scales: wp.array, indices: wp.array | None = None) -> None: - """Set prim scales by writing the USD ``xformOp:scale`` attribute. + def _get_world_scales_impl(self, indices: wp.array | None = None) -> ProxyArray: + """Get world-space (composed) scales via the USD view.""" + return self._ensure_usd_view()._get_world_scales_impl(indices) + + def _apply_local_scale_write(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set local-space scales (xformOp:scale) via the USD view. .. 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. + stage-only annotation. """ - self._ensure_usd_view().set_scales(scales, indices) + self._ensure_usd_view()._apply_local_scale_write(scales, indices) + + def _apply_world_scale_write(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set world-space scales via the USD view.""" + self._ensure_usd_view()._apply_world_scale_write(scales, indices) + + def _get_scales_impl(self, indices=None): + """OvPhysX legacy: deprecated get_scales returns local scales.""" + return self._get_local_scales_impl(indices) + + def _set_scales_impl(self, scales, indices=None): + """OvPhysX legacy: deprecated set_scales writes local scales via a one-shot writer scope.""" + with self.xform_local_space_writer() as writer: + writer.set_scales(scales, indices) def get_visibility(self, indices: wp.array | None = None): """Get visibility for prims in the view (USD-backed). @@ -893,3 +872,40 @@ def _gf_matrix_to_xform7(mat: Gf.Matrix4d) -> list[float]: 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())] + + +# ---------------------------------------------------------------------- +# Pass-through writer classes +# ---------------------------------------------------------------------- + + +class _OvPhysxWorldSpaceWriter(FrameViewWorldSpaceWriter): + """OvPhysX world-space writer: pass-through to backend ``_apply_*`` hooks.""" + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + self._view._apply_world_pose_write(positions, orientations, indices) # type: ignore[attr-defined] + + def set_scales(self, scales, indices=None) -> None: + self._view._apply_world_scale_write(scales, indices) # type: ignore[attr-defined] + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._get_world_poses_impl(indices) # type: ignore[attr-defined] + + def get_scales(self, indices=None) -> ProxyArray: + return self._view._get_world_scales_impl(indices) # type: ignore[attr-defined] + + +class _OvPhysxLocalSpaceWriter(FrameViewLocalSpaceWriter): + """OvPhysX local-space writer: pass-through to backend ``_apply_*`` hooks.""" + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + self._view._apply_local_pose_write(positions, orientations, indices) # type: ignore[attr-defined] + + def set_scales(self, scales, indices=None) -> None: + self._view._apply_local_scale_write(scales, indices) # type: ignore[attr-defined] + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._get_local_poses_impl(indices) # type: ignore[attr-defined] + + def get_scales(self, indices=None) -> ProxyArray: + return self._view._get_local_scales_impl(indices) # type: ignore[attr-defined] diff --git a/source/isaaclab_physx/changelog.d/fabric-local-poses.rst b/source/isaaclab_physx/changelog.d/fabric-local-poses.rst new file mode 100644 index 00000000000..94c48cf8369 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/fabric-local-poses.rst @@ -0,0 +1,36 @@ +Added +^^^^^ + +* Added :func:`~isaaclab.utils.warp.fabric.decompose_indexed_fabric_transforms` + and :func:`~isaaclab.utils.warp.fabric.compose_indexed_fabric_transforms` + Warp kernels. They mirror the existing + ``decompose_fabric_transformation_matrix_to_warp_arrays`` / + ``compose_fabric_transformation_matrix_from_warp_arrays`` kernels but + operate on :class:`wp.indexedfabricarray`, so the view-to-fabric mapping + is baked into the array and the kernel just dereferences + ``ifa[view_index]`` instead of taking a separate ``mapping`` argument. + +* Added :func:`~isaaclab.utils.warp.fabric.update_indexed_local_matrix_from_world` + and :func:`~isaaclab.utils.warp.fabric.update_indexed_world_matrix_from_local` + Warp kernels that propagate ``local = world * inv(parent)`` and + ``world = local * parent`` directly on Fabric storage matrices. + +* Added Fabric-accelerated local-pose read/write paths to + :class:`~isaaclab_physx.sim.views.FabricFrameView`. Local-pose + operations now use :class:`wp.indexedfabricarray` to read and write + ``omni:fabric:localMatrix`` directly on the GPU, propagating between + parent world matrices and child local/world matrices via Warp kernels + without round-tripping through USD. + +* Added topology-change recovery via automatic ``PrepareForReuse`` detection + and per-selection index rebuild. + +Deprecated +^^^^^^^^^^ + +* Deprecated ``get_scales`` / ``set_scales`` on ``FabricFrameView``. For + reads, use the explicit ``get_local_scales`` (operates on + ``localMatrix``) or ``get_world_scales`` (composed world-space scale). + For writes, use the writer scope's ``set_scales``. The deprecated + methods still work but emit a ``DeprecationWarning``; ``FabricFrameView`` + defaults to world (preserving prior behavior). diff --git a/source/isaaclab_physx/changelog.d/xform-space-writer.rst b/source/isaaclab_physx/changelog.d/xform-space-writer.rst new file mode 100644 index 00000000000..c87325bf69a --- /dev/null +++ b/source/isaaclab_physx/changelog.d/xform-space-writer.rst @@ -0,0 +1,36 @@ +Changed +^^^^^^^ + +* :class:`~isaaclab_physx.sim.views.FabricFrameView` now writes Fabric + ``omni:fabric:worldMatrix`` and ``omni:fabric:localMatrix`` through the + new context-managed + :class:`~isaaclab.sim.views.FrameViewSpaceWriterBase` scope. Each scope: + + - eagerly writes both the primary matrix (world or local, per the + chosen space) and derives the opposite-space matrix in a single Warp + kernel on ``__exit__``; + - calls ``wp.synchronize()`` once on ``__exit__``; + - pauses :meth:`IFabricHierarchy.track_local_xform_changes` and + :meth:`track_world_xform_changes` while the scope is active and + restores their prior state on exit, so Fabric Hierarchy's + ``update_world_xforms()`` on the next tick has no recorded changes + to replay for these prims. The Fabric Scene Delegate (FSD) reads + ``omni:fabric:worldMatrix`` from Fabric storage directly and + observes the writes. + - runs the opposite-space derive + ``wp.synchronize()`` on exit even + when the scope unwinds via exception (including ``KeyboardInterrupt`` + in interactive notebooks), as a best-effort to keep ``worldMatrix`` + and ``localMatrix`` mutually consistent prim-by-prim. The partial + write itself is not rolled back -- callers needing transactional + semantics should snapshot the matrices themselves before entering + the scope. + + Two persistent selections back the two access modes: ``_sel_ro`` + (``worldMatrix=RO, localMatrix=RO``, steady state) and ``_sel_rw`` + (``worldMatrix=RW, localMatrix=RW``, used inside a writer scope). + Both are built once during ``_initialize_fabric`` and kept for the + view's lifetime; the writer flips a single ``_is_rw`` flag on + enter/exit and neither selection is rebuilt on the flip. The RO + steady state tells Fabric Hierarchy's next ``update_world_xforms()`` + tick that no attribute is user-authored, so it leaves the pair + alone. 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 3d3c0a3b0d9..068d4c5b875 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 @@ -12,12 +12,12 @@ import torch import warp as wp -from pxr import Usd +from pxr import Gf, Usd, UsdGeom -import isaaclab.sim as sim_utils from isaaclab.app.settings_manager import SettingsManager from isaaclab.sim.views.base_frame_view import BaseFrameView from isaaclab.sim.views.usd_frame_view import UsdFrameView +from isaaclab.sim.views.xform_space_writer import FrameViewLocalSpaceWriter, FrameViewWorldSpaceWriter from isaaclab.utils.warp import ProxyArray from isaaclab.utils.warp import fabric as fabric_utils @@ -44,25 +44,125 @@ class FabricFrameView(BaseFrameView): """FrameView with Fabric GPU acceleration for the PhysX backend. Uses composition: holds a :class:`UsdFrameView` internally for USD - fallback and non-accelerated operations (local poses, visibility, scales - when Fabric is disabled). - - When Fabric is enabled, world-pose and scale operations use Warp kernels - 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. + fallback and non-accelerated operations (visibility, and all pose/scale + operations when Fabric is disabled). - 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. + When Fabric is enabled, world-pose, local-pose, and scale operations run + on the GPU via Warp kernels that read and write + ``omni:fabric:worldMatrix`` and ``omni:fabric:localMatrix`` directly. + All other operations delegate to the internal USD view. - Pose getters return :class:`~isaaclab.utils.warp.ProxyArray`. Setters accept ``wp.array``. + All writes go through the writer-scope API + (:meth:`xform_world_space_writer` / :meth:`xform_local_space_writer`, + recommended) or the + convenience :meth:`set_world_poses` / :meth:`set_local_poses` / etc. helpers + inherited from :class:`BaseFrameView`. + + Behavior (Fabric path): + + * **Leaf-prim assumption.** This view manages a flat set of sibling prims + (e.g. all cameras under ``/World/Env_*/Camera``). It does NOT propagate + transforms to child prims. If a managed prim has children whose world + matrices depend on the parent, those children must be updated via a + separate view, a physics step, or ``IFabricHierarchy.update_world_xforms``. + * **No write-back to USD.** Fabric writes update only + ``omni:fabric:worldMatrix`` / ``omni:fabric:localMatrix``; the prim's + USD ``xformOp:*`` attributes are unchanged. Downstream consumers that + read the prim's USD attributes after a Fabric write will see stale + values until the next USD-side sync. + * **Eager dual-write inside a writer scope (no dirty tracking).** + When a writer scope is open, all writes go to the primary attribute + (``worldMatrix`` for the world writer, ``localMatrix`` for the local + writer). On scope exit, a single Warp kernel derives the opposite + attribute and a single ``wp.synchronize()`` runs. After the scope + exits, both Fabric matrices are self-consistent. Getters launch + their own decompose kernel and ``wp.synchronize()`` before returning, + so a returned :class:`ProxyArray` is always immediately readable from + either GPU or host code (no caller-side sync required). + + The opposite-space derive runs even when the scope unwinds via + exception (including ``KeyboardInterrupt`` in interactive notebooks), + as a best-effort to keep ``worldMatrix`` and ``localMatrix`` + mutually consistent on whatever partial-write state Fabric holds. + The partial write itself is not rolled back -- if you need + transactional all-or-nothing semantics, snapshot the matrices + yourself before entering the scope. + * **Fabric Hierarchy listeners are paused while a writer scope is + active.** On enter, the writer calls + :meth:`IFabricHierarchy.track_local_xform_changes(False)` / + :meth:`track_world_xform_changes(False)` (saving the prior state). + Fabric itself is just a flat attribute store; the plugin that + keeps ``omni:fabric:worldMatrix`` and ``omni:fabric:localMatrix`` + mutually consistent across the prim hierarchy is + :class:`usdrt.hierarchy.IFabricHierarchy` (a.k.a. Fabric + Hierarchy). Its change tracking is pull-based: a per-attribute + listener records writes into a private changelog, and the plugin + drains and processes that changelog on the next call to + ``IFabricHierarchy::update_world_xforms()`` (typically from the + render path). "Tracking off" just stops the listener from + recording new entries -- writes still land in Fabric storage; + they are simply invisible to the next ``update_world_xforms()`` + call. + + That is exactly what we want. Inside the scope we write one + space (world or local) and, at scope exit, derive the other in a + single batched kernel so both matrices are mutually consistent. + If tracking were left on, our writes would be queued in the + changelog and the next ``update_world_xforms()`` tick would + process them -- choosing a canonical direction (e.g. "user + authored local, recompute world from it") and potentially + overwriting one half of our just-consistent pair. With tracking + paused for the duration of the scope, the changelog stays empty + for these prims and the next tick is a no-op for them. + + ``__exit__`` restores the prior tracking state (so we do not + re-enable listeners the caller had previously paused). The + Fabric Scene Delegate (FSD) reads ``omni:fabric:worldMatrix`` + directly from Fabric storage on the render path; it observes our + final writes unchanged. + + Note: the scope is synchronous Python code, so no simulation step + and no render tick can run while it is open -- callers must not + advance the simulation from inside the scope (see + :mod:`isaaclab.sim.views.xform_space_writer` for the full contract). + The "torn data" concern is what motivates that no-step rule; it + is separate from why the tracking pause exists. + * **Two persistent selections, flipped by the writer scope.** Two + selections are built once during ``_initialize_fabric`` and kept for + the view's lifetime: + + .. code-block:: text + + _sel_ro : worldMatrix=RO, localMatrix=RO (steady state) + _sel_rw : worldMatrix=RW, localMatrix=RW (inside writer scope) + + Each selection has its own bundle of indexed-fabric arrays + (``_world_ifa_*``, ``_local_ifa_*``, ``_parent_world_ifa_*``) cached + against the selection's path ordering. Writer ``__enter__`` flips an + ``_is_rw`` flag so subsequent get/set helpers resolve to the RW + bundle; ``__exit__`` flips back to RO. Nothing is rebuilt on the + flip -- both bundles are always kept consistent via independent + ``PrepareForReuse()`` polls in the accessors. + + The RO steady state tells Kit's next-tick + ``update_world_xforms()`` that no attribute is user-authored, so it + leaves both alone. Combined with the tracking pause and the + opposite-space derive at scope exit, this is what keeps the next + render tick from overwriting our writes. + * **Topology-adaptive.** Fabric topology changes are detected on each + access via per-selection ``PrepareForReuse()`` polls; the affected + indexed arrays rebuild automatically and no manual refresh is required. + + Pose getters return :class:`~isaaclab.utils.warp.ProxyArray`; the + convenience :meth:`set_world_poses` / :meth:`set_local_poses` helpers accept + :class:`wp.array`. Inside a writer scope, the writer's + :meth:`~FrameViewSpaceWriterBase.set_poses` / :meth:`~FrameViewSpaceWriterBase.set_scales` + accept :class:`wp.array`. """ + _WORLD_MATRIX_NAME = "omni:fabric:worldMatrix" + _LOCAL_MATRIX_NAME = "omni:fabric:localMatrix" + def __init__( self, prim_path: str, @@ -76,7 +176,7 @@ def __init__( Args: prim_path: USD prim-path pattern to match. device: Device for Warp arrays. Either ``"cpu"`` or any CUDA - device string (``"cuda:0"``, ``"cuda:1"``, …); Fabric + 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. @@ -90,18 +190,40 @@ 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; + + # 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 - self._fabric_selection = None - self._fabric_to_view: wp.array | None = None - self._view_to_fabric: wp.array | None = None - self._default_view_indices: wp.array | None = None + self._stage = None self._fabric_hierarchy = None - self._view_index_attr = f"isaaclab:view_index:{abs(hash(self))}" + + # Two persistent Fabric selections. ``_is_rw`` is True only inside + # an active writer scope; the accessors below resolve to the matching + # bundle of indexed arrays. + self._sel_ro = None + self._sel_rw = None + self._is_rw: bool = False + + # View-side indices array (shared across both bundles). + self._view_indices: wp.array | None = None + + # Per-selection view->fabric mappings. + self._ro_fabric_indices: wp.array | None = None + self._rw_fabric_indices: wp.array | None = None + self._ro_parent_fabric_indices: wp.array | None = None + self._rw_parent_fabric_indices: wp.array | None = None + + # Indexed fabric arrays per (selection, attribute) pair. + self._world_ifa_ro = None + self._local_ifa_ro = None + self._parent_world_ifa_ro = None + self._world_ifa_rw = None + self._local_ifa_rw = None + self._parent_world_ifa_rw = None + + # Sentinel passed to compose/decompose kernels for unused slots. + self._fabric_empty_2d_array_sentinel: wp.array | None = None # ------------------------------------------------------------------ # Delegated properties @@ -135,59 +257,29 @@ def set_visibility(self, visibility, indices=None): self._usd_view.set_visibility(visibility, indices) # ------------------------------------------------------------------ - # World poses — Fabric-accelerated or USD fallback + # Writer factory hooks # ------------------------------------------------------------------ - def set_world_poses(self, positions=None, orientations=None, indices=None): + def _make_world_space_writer(self) -> FrameViewWorldSpaceWriter: if not self._use_fabric: - self._usd_view.set_world_poses(positions, orientations, indices) - return + return _FabricFallbackWorldWriter(self) + return _FabricWorldSpaceWriter(self) - if not self._fabric_initialized: - self._initialize_fabric() - - self._prepare_for_reuse() - - indices_wp = self._resolve_indices_wp(indices) - count = indices_wp.shape[0] - - dummy = wp.zeros((0, 3), dtype=wp.float32, device=self._device) - positions_wp = _to_float32_2d(positions) if positions is not None else dummy - orientations_wp = ( - _to_float32_2d(orientations) - if orientations is not None - else wp.zeros((0, 4), dtype=wp.float32, device=self._device) - ) - - wp.launch( - kernel=fabric_utils.compose_fabric_transformation_matrix_from_warp_arrays, - dim=count, - inputs=[ - self._fabric_world_matrices, - positions_wp, - orientations_wp, - dummy, - False, - False, - False, - indices_wp, - self._view_to_fabric, - ], - device=self._fabric_device, - ) - wp.synchronize() + def _make_local_space_writer(self) -> FrameViewLocalSpaceWriter: + if not self._use_fabric: + return _FabricFallbackLocalWriter(self) + return _FabricLocalSpaceWriter(self) - self._fabric_hierarchy.update_world_xforms() - self._fabric_usd_sync_done = True + # ------------------------------------------------------------------ + # Getter hooks -- read directly from Fabric (no lazy sync) + # ------------------------------------------------------------------ - def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: + def _get_world_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: if not self._use_fabric: - return self._usd_view.get_world_poses(indices) + return self._usd_view._get_world_poses_impl(indices) if not self._fabric_initialized: self._initialize_fabric() - if not self._fabric_usd_sync_done: - self._sync_fabric_from_usd_once() indices_wp = self._resolve_indices_wp(indices) count = indices_wp.shape[0] @@ -201,85 +293,85 @@ def get_world_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, orientations_wp = wp.zeros((count, 4), dtype=wp.float32, device=self._device) wp.launch( - kernel=fabric_utils.decompose_fabric_transformation_matrix_to_warp_arrays, + kernel=fabric_utils.decompose_indexed_fabric_transforms, dim=count, inputs=[ - self._fabric_world_matrices, + self._get_world_ifa(), positions_wp, orientations_wp, - self._fabric_dummy_buffer, + self._fabric_empty_2d_array_sentinel, indices_wp, - self._view_to_fabric, ], - device=self._fabric_device, + device=self._device, ) + # Sync before returning regardless of caching path: the cached buffers + # are reused (an in-flight kernel from a prior call must finish before + # the new write is observable) and the fresh-allocation path must also + # complete before the caller can read the returned ProxyArray via any + # host-visible accessor without observing zeros. + wp.synchronize() if use_cached: - wp.synchronize() return self._fabric_positions_ta, self._fabric_orientations_ta return ProxyArray(positions_wp), ProxyArray(orientations_wp) - # ------------------------------------------------------------------ - # Local poses — USD fallback (Fabric only accelerates world poses) - # ------------------------------------------------------------------ - - def set_local_poses(self, translations=None, orientations=None, indices=None): - self._usd_view.set_local_poses(translations, orientations, indices) - - def get_local_poses(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: - return self._usd_view.get_local_poses(indices) - - # ------------------------------------------------------------------ - # Scales — Fabric-accelerated or USD fallback - # ------------------------------------------------------------------ - - def set_scales(self, scales, indices=None): + def _get_local_poses_impl(self, indices: wp.array | None = None) -> tuple[ProxyArray, ProxyArray]: if not self._use_fabric: - self._usd_view.set_scales(scales, indices) - return + return self._usd_view._get_local_poses_impl(indices) if not self._fabric_initialized: self._initialize_fabric() - self._prepare_for_reuse() - indices_wp = self._resolve_indices_wp(indices) count = indices_wp.shape[0] - dummy3 = wp.zeros((0, 3), dtype=wp.float32, device=self._device) - dummy4 = wp.zeros((0, 4), dtype=wp.float32, device=self._device) - scales_wp = _to_float32_2d(scales) + use_cached = indices is None or indices == slice(None) + if use_cached: + translations_wp = self._fabric_local_translations_buf + orientations_wp = self._fabric_local_orientations_buf + else: + translations_wp = wp.zeros((count, 3), dtype=wp.float32, device=self._device) + orientations_wp = wp.zeros((count, 4), dtype=wp.float32, device=self._device) wp.launch( - kernel=fabric_utils.compose_fabric_transformation_matrix_from_warp_arrays, + kernel=fabric_utils.decompose_indexed_fabric_transforms, dim=count, inputs=[ - self._fabric_world_matrices, - dummy3, - dummy4, - scales_wp, - False, - False, - False, + self._get_local_ifa(), + translations_wp, + orientations_wp, + self._fabric_empty_2d_array_sentinel, indices_wp, - self._view_to_fabric, ], - device=self._fabric_device, + device=self._device, ) + + # See note in _get_world_poses_impl: sync regardless of caching path. wp.synchronize() + if use_cached: + return self._fabric_local_translations_ta, self._fabric_local_orientations_ta + return ProxyArray(translations_wp), ProxyArray(orientations_wp) - self._fabric_hierarchy.update_world_xforms() - self._fabric_usd_sync_done = True + def _get_world_scales_impl(self, indices=None) -> ProxyArray: + if not self._use_fabric: + return self._usd_view._get_world_scales_impl(indices) + + if not self._fabric_initialized: + self._initialize_fabric() - def get_scales(self, indices: wp.array | None = None) -> ProxyArray: + return self._decompose_scales(self._get_world_ifa(), indices) + + def _get_local_scales_impl(self, indices=None) -> ProxyArray: if not self._use_fabric: - return self._usd_view.get_scales(indices) + return self._usd_view._get_local_scales_impl(indices) if not self._fabric_initialized: self._initialize_fabric() - if not self._fabric_usd_sync_done: - self._sync_fabric_from_usd_once() + return self._decompose_scales(self._get_local_ifa(), indices) + + def _decompose_scales(self, ro_array, indices) -> ProxyArray: + """Shared scale-decompose path for world / local getters.""" indices_wp = self._resolve_indices_wp(indices) count = indices_wp.shape[0] @@ -290,168 +382,598 @@ def get_scales(self, indices: wp.array | None = None) -> ProxyArray: scales_wp = wp.zeros((count, 3), dtype=wp.float32, device=self._device) wp.launch( - kernel=fabric_utils.decompose_fabric_transformation_matrix_to_warp_arrays, + kernel=fabric_utils.decompose_indexed_fabric_transforms, dim=count, inputs=[ - self._fabric_world_matrices, - self._fabric_dummy_buffer, - self._fabric_dummy_buffer, + ro_array, + self._fabric_empty_2d_array_sentinel, + self._fabric_empty_2d_array_sentinel, scales_wp, indices_wp, - self._view_to_fabric, ], - device=self._fabric_device, + device=self._device, ) + # See note in _get_world_poses_impl: sync regardless of caching path. + wp.synchronize() if use_cached: - wp.synchronize() + return self._fabric_scales_ta return ProxyArray(scales_wp) # ------------------------------------------------------------------ - # Internal — PrepareForReuse (renderer notification + topology tracking) + # Deprecated get_scales / set_scales hooks # ------------------------------------------------------------------ - def _prepare_for_reuse(self) -> None: - """Call PrepareForReuse on the PrimSelection to notify the renderer. + def _get_scales_impl(self, indices=None) -> ProxyArray: + """Fabric: get_scales returns world-space scales (legacy behavior).""" + return self._get_world_scales_impl(indices) - PrepareForReuse serves two purposes: + def _set_scales_impl(self, scales, indices=None) -> None: + """Fabric: set_scales writes world-space scales via a one-shot writer scope.""" + with self.xform_world_space_writer() as writer: + writer.set_scales(scales, indices) - 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 + # ------------------------------------------------------------------ + # Internal -- helpers shared by writers + initialization + # ------------------------------------------------------------------ - topology_changed = self._fabric_selection.PrepareForReuse() - if topology_changed: - logger.debug("Fabric topology changed — rebuilding view-to-fabric index mapping.") - self._rebuild_fabric_arrays() + def _to_float32_2d_or_empty(self, data): + return self._fabric_empty_2d_array_sentinel if data is None else _to_float32_2d(data) - def _rebuild_fabric_arrays(self) -> None: - """Rebuild fabricarray and view↔fabric mappings after a topology change. + def _recompute_local_from_world_all(self) -> None: + """Derive ``localMatrix = inv(parent) * worldMatrix`` for every prim in the view. - 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. + Called from :class:`_FabricWorldSpaceWriter` ``__exit__`` to keep the + (world, local) pair self-consistent after a world-space write. + Storage convention: see + :func:`isaaclab.utils.warp.fabric.update_indexed_local_matrix_from_world`. """ - 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." + wp.launch( + kernel=fabric_utils.update_indexed_local_matrix_from_world, + dim=self.count, + inputs=[ + self._get_world_ifa(), + self._get_parent_world_ifa(), + self._get_local_ifa(), + self._view_indices, + ], + device=self._device, ) - 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) + def _recompute_world_from_local_all(self) -> None: + """Derive ``worldMatrix = parent * localMatrix`` for every prim in the view. + + Called from :class:`_FabricLocalSpaceWriter` ``__exit__`` and from + :meth:`_sync_fabric_from_usd_initial` after seeding local matrices. + Storage convention: see + :func:`isaaclab.utils.warp.fabric.update_indexed_world_matrix_from_local`. + """ 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, + kernel=fabric_utils.update_indexed_world_matrix_from_local, + dim=self.count, + inputs=[ + self._get_local_ifa(), + self._get_parent_world_ifa(), + self._get_world_ifa(), + self._view_indices, + ], + device=self._device, ) - wp.synchronize() - self._fabric_world_matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") + # ------------------------------------------------------------------ + # Internal -- selection accessors with on-demand index rebuild + # ------------------------------------------------------------------ + + def _get_world_ifa(self): + self._refresh_active_bundle_if_needed() + return self._world_ifa_rw if self._is_rw else self._world_ifa_ro + + def _get_local_ifa(self): + self._refresh_active_bundle_if_needed() + return self._local_ifa_rw if self._is_rw else self._local_ifa_ro + + def _get_parent_world_ifa(self): + self._refresh_active_bundle_if_needed() + return self._parent_world_ifa_rw if self._is_rw else self._parent_world_ifa_ro + + def _refresh_active_bundle_if_needed(self) -> None: + """Rebuild the active bundle's indexed arrays if its selection's buckets changed.""" + if self._is_rw: + if self._world_ifa_rw is None or self._sel_rw.PrepareForReuse(): + self._rebuild_rw_arrays() + else: + if self._world_ifa_ro is None or self._sel_ro.PrepareForReuse(): + self._rebuild_ro_arrays() + + def _rebuild_ro_arrays(self) -> None: + """Rebuild the four ``_sel_ro``-keyed indexed arrays (children + parents).""" + self._ro_fabric_indices = self._compute_fabric_indices(self._sel_ro) + self._world_ifa_ro = self._build_indexed_array(self._sel_ro, self._WORLD_MATRIX_NAME, self._ro_fabric_indices) + self._local_ifa_ro = self._build_indexed_array(self._sel_ro, self._LOCAL_MATRIX_NAME, self._ro_fabric_indices) + self._ro_parent_fabric_indices = self._compute_parent_fabric_indices(self._sel_ro) + self._parent_world_ifa_ro = wp.indexedfabricarray( + fa=wp.fabricarray(self._sel_ro, self._WORLD_MATRIX_NAME), + indices=self._ro_parent_fabric_indices, + ) + + def _rebuild_rw_arrays(self) -> None: + """Rebuild the four ``_sel_rw``-keyed indexed arrays (children + parents).""" + self._rw_fabric_indices = self._compute_fabric_indices(self._sel_rw) + self._world_ifa_rw = self._build_indexed_array(self._sel_rw, self._WORLD_MATRIX_NAME, self._rw_fabric_indices) + self._local_ifa_rw = self._build_indexed_array(self._sel_rw, self._LOCAL_MATRIX_NAME, self._rw_fabric_indices) + self._rw_parent_fabric_indices = self._compute_parent_fabric_indices(self._sel_rw) + self._parent_world_ifa_rw = wp.indexedfabricarray( + fa=wp.fabricarray(self._sel_rw, self._WORLD_MATRIX_NAME), + indices=self._rw_parent_fabric_indices, + ) + + # ------------------------------------------------------------------ + # Internal -- index computation + # ------------------------------------------------------------------ + + def _compute_fabric_indices(self, selection) -> wp.array: + """View-side indices that map each managed prim into ``selection``.""" + return self._compute_fabric_indices_for(selection, list(self.prim_paths)) + + def _compute_parent_fabric_indices(self, selection) -> wp.array: + """View-side indices that map each managed prim's parent into ``selection``.""" + + def parent_path(prim_path: str) -> str: + p = prim_path.rsplit("/", 1)[0] + if not p: + raise RuntimeError( + f"Child prim '{prim_path}' is at stage root and has no parent prim. " + "FabricFrameView requires every prim to have a non-pseudoroot parent " + "with Fabric world+local matrices." + ) + return p + + return self._compute_fabric_indices_for(selection, [parent_path(p) for p in self.prim_paths]) + + def _build_indexed_array(self, selection, attribute_name: str, fabric_indices: wp.array) -> wp.indexedfabricarray: + fa = wp.fabricarray(selection, attribute_name) + return wp.indexedfabricarray(fa=fa, indices=fabric_indices) + + def _resolve_indices_wp(self, indices: wp.array | None) -> wp.array: + """Resolve view indices as a Warp uint32 array.""" + if indices is None or indices == slice(None): + if self._view_indices is None: + raise RuntimeError("Fabric view indices are not initialized.") + return self._view_indices + if indices.dtype != wp.uint32: + return wp.array(indices.numpy().astype("uint32"), dtype=wp.uint32, device=self._device) + return indices # ------------------------------------------------------------------ - # Internal — Fabric initialization + # Internal -- Fabric initialization # ------------------------------------------------------------------ def _initialize_fabric(self) -> None: - """Initialize Fabric batch infrastructure for GPU-accelerated pose queries.""" + """One-time Fabric setup: hierarchy handle, attribute population, selections, indexed arrays.""" import usdrt # noqa: PLC0415 from usdrt import Rt # noqa: PLC0415 - stage_id = sim_utils.get_current_stage_id() - fabric_stage = usdrt.Usd.Stage.Attach(stage_id) + from isaaclab.sim.utils import get_current_stage_id # noqa: PLC0415 - for i in range(self.count): - rt_prim = fabric_stage.GetPrimAtPath(self.prim_paths[i]) - rt_xformable = Rt.Xformable(rt_prim) + # Attach usdrt stage and create hierarchy handle. + stage_id = get_current_stage_id() + self._stage = usdrt.Usd.Stage.Attach(stage_id) + fabric_id = self._stage.GetFabricId() + self._fabric_id = fabric_id.id + self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( + fabric_id, self._stage.GetStageIdAsStageId() + ) - has_attr = ( - rt_xformable.HasFabricHierarchyWorldMatrixAttr() - if hasattr(rt_xformable, "HasFabricHierarchyWorldMatrixAttr") - else False - ) - if not has_attr: + # Ensure each child prim AND its parent have BOTH Fabric world and local matrix + # attributes. ``Create*Attr`` calls are idempotent. + seen_paths: set[str] = set() + for child_path in self.prim_paths: + for path in (child_path, child_path.rsplit("/", 1)[0]): + if path in seen_paths: + continue + seen_paths.add(path) + rt_prim = self._stage.GetPrimAtPath(path) + if not rt_prim.IsValid(): + continue + rt_xformable = Rt.Xformable(rt_prim) rt_xformable.CreateFabricHierarchyWorldMatrixAttr() + rt_xformable.CreateFabricHierarchyLocalMatrixAttr() + rt_xformable.SetLocalXformFromUsd() + rt_xformable.SetWorldXformFromUsd() + + # Two persistent selections: all-RO (steady state) and all-RW (active + # only inside a writer scope). Each will own its own bundle of + # indexed-fabric arrays built lazily by ``_rebuild_{ro,rw}_arrays``. + matrix = usdrt.Sdf.ValueTypeNames.Matrix4d + ro = usdrt.Usd.Access.Read + rw = usdrt.Usd.Access.ReadWrite + wm_ro = (matrix, self._WORLD_MATRIX_NAME, ro) + lm_ro = (matrix, self._LOCAL_MATRIX_NAME, ro) + wm_rw = (matrix, self._WORLD_MATRIX_NAME, rw) + lm_rw = (matrix, self._LOCAL_MATRIX_NAME, rw) + self._sel_ro = self._stage.SelectPrims(require_attrs=[wm_ro, lm_ro], device=self._device, want_paths=True) + self._sel_rw = self._stage.SelectPrims(require_attrs=[wm_rw, lm_rw], device=self._device, want_paths=True) + + self._view_indices = wp.array(list(range(self.count)), dtype=wp.uint32, device=self._device) + self._rebuild_ro_arrays() + self._rebuild_rw_arrays() + + # Pre-allocated reusable output buffers (world + local + scales). + self._fabric_positions_buf = wp.zeros((self.count, 3), dtype=wp.float32, device=self._device) + self._fabric_orientations_buf = wp.zeros((self.count, 4), dtype=wp.float32, device=self._device) + self._fabric_scales_buf = wp.zeros((self.count, 3), dtype=wp.float32, device=self._device) + self._fabric_local_translations_buf = wp.zeros((self.count, 3), dtype=wp.float32, device=self._device) + self._fabric_local_orientations_buf = wp.zeros((self.count, 4), dtype=wp.float32, device=self._device) + self._fabric_empty_2d_array_sentinel = wp.zeros((0, 0), dtype=wp.float32, device=self._device) - rt_xformable.SetWorldXformFromUsd() + self._fabric_positions_ta = ProxyArray(self._fabric_positions_buf) + self._fabric_orientations_ta = ProxyArray(self._fabric_orientations_buf) + self._fabric_scales_ta = ProxyArray(self._fabric_scales_buf) + self._fabric_local_translations_ta = ProxyArray(self._fabric_local_translations_buf) + self._fabric_local_orientations_ta = ProxyArray(self._fabric_local_orientations_buf) - rt_prim.CreateAttribute(self._view_index_attr, usdrt.Sdf.ValueTypeNames.UInt, custom=True) - rt_prim.GetAttribute(self._view_index_attr).Set(i) + self._fabric_initialized = True - self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( - fabric_stage.GetFabricId(), fabric_stage.GetStageIdAsStageId() + # Seed Fabric matrices from USD authoritatively. The seed writes, so + # flip into the RW bundle for its duration; flip back to RO afterwards + # so steady-state getters use the RO bundle. + self._is_rw = True + try: + self._sync_fabric_from_usd_initial() + finally: + self._is_rw = False + + def _sync_fabric_from_usd_initial(self) -> None: + """Populate Fabric world+local matrices for children and parents from USD. + + Performed once during ``_initialize_fabric``. Without this step Fabric's + matrices are identity for stages that haven't been rendered yet, and our + getters (which read from Fabric) would return wrong values. + """ + # --- Children: compose child localMatrix from USD-authored local transforms. + scales_wp = _to_float32_2d(self._usd_view.get_local_scales().warp) + local_pos_ta, local_ori_ta = self._usd_view.get_local_poses() + wp.launch( + kernel=fabric_utils.compose_indexed_fabric_transforms, + dim=self.count, + inputs=[ + self._local_ifa_rw, # explicit RW: init-time write, no scope yet + _to_float32_2d(local_pos_ta.warp), + _to_float32_2d(local_ori_ta.warp), + _to_float32_2d(scales_wp), + False, + False, + False, + self._view_indices, + ], + device=self._device, ) - self._fabric_hierarchy.update_world_xforms() - self._default_view_indices = wp.zeros((self.count,), dtype=wp.uint32, device=self._device) + # --- Parents (one entry per unique parent path) --- + unique_parent_paths = list(dict.fromkeys(p.rsplit("/", 1)[0] for p in self.prim_paths)) + if unique_parent_paths: + from isaaclab.sim.utils import get_current_stage # noqa: PLC0415 + + usd_stage = get_current_stage() + xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) + world_pos_rows: list[list[float]] = [] + world_ori_rows: list[list[float]] = [] + world_scale_rows: list[list[float]] = [] + decomposer = Gf.Transform() + warned_shear = False + for path in unique_parent_paths: + prim = usd_stage.GetPrimAtPath(path) + tf = xform_cache.GetLocalToWorldTransform(prim) + decomposer.SetMatrix(tf) + s = decomposer.GetScale() + if not warned_shear: + row0 = Gf.Vec3d(tf[0][0], tf[0][1], tf[0][2]).GetNormalized() + row1 = Gf.Vec3d(tf[1][0], tf[1][1], tf[1][2]).GetNormalized() + row2 = Gf.Vec3d(tf[2][0], tf[2][1], tf[2][2]).GetNormalized() + if ( + abs(Gf.Dot(row0, row1)) > 1e-3 + or abs(Gf.Dot(row0, row2)) > 1e-3 + or abs(Gf.Dot(row1, row2)) > 1e-3 + ): + warned_shear = True + logger.warning( + "FabricFrameView: parent prim '%s' has a sheared/skewed world " + "transform. TRS decomposition (used by scale getters and world<->local " + "propagation) does not support shear -- extracted scales and rotations " + "will be approximate. Avoid shear in parent transforms for correct results.", + path, + ) + tf.Orthonormalize() + t = tf.ExtractTranslation() + q = tf.ExtractRotationQuat() + img, real = q.GetImaginary(), q.GetReal() + world_pos_rows.append([float(t[0]), float(t[1]), float(t[2])]) + world_ori_rows.append([float(img[0]), float(img[1]), float(img[2]), float(real)]) + world_scale_rows.append([float(s[0]), float(s[1]), float(s[2])]) + parent_view_indices = wp.array(list(range(len(unique_parent_paths))), dtype=wp.uint32, device=self._device) + parent_pos_wp = wp.array(world_pos_rows, dtype=wp.float32, device=self._device) + parent_ori_wp = wp.array(world_ori_rows, dtype=wp.float32, device=self._device) + parent_scale_wp = wp.array(world_scale_rows, dtype=wp.float32, device=self._device) + parent_world_rw = wp.indexedfabricarray( + fa=wp.fabricarray(self._sel_rw, self._WORLD_MATRIX_NAME), + indices=self._compute_fabric_indices_for(self._sel_rw, unique_parent_paths), + ) + wp.launch( + kernel=fabric_utils.compose_indexed_fabric_transforms, + dim=len(unique_parent_paths), + inputs=[ + parent_world_rw, + parent_pos_wp, + parent_ori_wp, + parent_scale_wp, + False, + False, + False, + parent_view_indices, + ], + device=self._device, + ) + wp.synchronize() + + # After seeding local matrices from USD, recompute world matrices so + # the view starts with consistent state. + self._recompute_world_from_local_all() + wp.synchronize() + + def _compute_fabric_indices_for(self, selection, paths: list[str]) -> wp.array: + """Look up each path in ``selection`` and return the matching fabric-side indices. + + Shared primitive used by :meth:`_compute_fabric_indices` (children), + :meth:`_compute_parent_fabric_indices` (parents), and one-off + index arrays such as the parent-world seed in + :meth:`_sync_fabric_from_usd_initial`. + """ + path_to_idx = {str(p): i for i, p in enumerate(selection.GetPaths())} + + def lookup(path: str) -> int: + idx = path_to_idx.get(path) + if idx is None: + raise RuntimeError(f"Path '{path}' not found in Fabric selection.") + return idx + + return wp.array([lookup(p) for p in paths], dtype=wp.int32, device=self._device) + + +# ---------------------------------------------------------------------- +# Concrete writer classes for FabricFrameView +# ---------------------------------------------------------------------- + + +class _FabricWriterMixin: + """Common ``__enter__`` / ``__exit__`` for the Fabric world / local writers. + + On enter: pauses ``track_local_xform_changes`` / ``track_world_xform_changes`` + on the Fabric hierarchy (saving prior state) and flips the view's + ``_is_rw`` so all get/set helpers resolve to the persistent RW selection + bundle (no rebuild -- both bundles are kept alive for the view's lifetime). + + On exit (normal or via exception): runs a best-effort opposite-space + derive + ``wp.synchronize()`` whenever any write happened inside the + scope, then flips ``_is_rw`` back to ``False`` (RO bundle for + steady-state reads) and restores hierarchy-tracking state. + + **Exception safety.** If the scope unwinds because of an exception + (including ``KeyboardInterrupt`` from an interactive notebook), the + opposite-space derive still runs so that ``worldMatrix`` and + ``localMatrix`` are mutually consistent prim-by-prim on whatever + partial-write state Fabric currently holds. The partial write itself + is **not** rolled back -- some prims may carry the new value and the + rest the pre-scope value -- so callers needing transactional + all-or-nothing semantics should snapshot matrices themselves before + entering the scope. If the recovery launch itself fails (typically + because the original exception came from a poisoned CUDA stream), the + failure is logged and the original exception propagates; the view + should then be recreated. + """ + + def _enter_impl(self) -> None: + view: FabricFrameView = self._view # type: ignore[assignment] + if not view._fabric_initialized: + view._initialize_fabric() + self._wrote_anything = False + h = view._fabric_hierarchy + self._was_tracking_local = h.tracking_local_xform_changes + self._was_tracking_world = h.tracking_world_xform_changes + if self._was_tracking_local: + h.track_local_xform_changes(False) + if self._was_tracking_world: + h.track_world_xform_changes(False) + view._is_rw = True + + def _exit_impl(self, exc_type, exc_val, exc_tb) -> None: + view: FabricFrameView = self._view # type: ignore[assignment] + try: + if self._wrote_anything: + try: + self._derive_opposite() + wp.synchronize() + except Exception as recovery_exc: + # Recovery itself failed (e.g. original exception came + # from a device error and the CUDA stream is poisoned). + # If we got here on the happy path, re-raise. If we are + # already unwinding an exception, log and let the original + # propagate -- masking it would hide the actual root cause. + if exc_type is None: + raise + logger.error( + "FabricFrameView writer scope: best-effort opposite-space sync " + "failed during exception handling: %s. World/local matrices may " + "be inconsistent prim-by-prim; recreate the view to recover.", + recovery_exc, + ) + finally: + # Flip back to RO before restoring hierarchy tracking so any + # subsequent updateWorldXforms tick sees a fully-RO selection. + view._is_rw = False + h = view._fabric_hierarchy + if self._was_tracking_world: + h.track_world_xform_changes(True) + if self._was_tracking_local: + h.track_local_xform_changes(True) + + def _derive_opposite(self) -> None: + raise NotImplementedError + + +class _FabricWorldSpaceWriter(_FabricWriterMixin, FrameViewWorldSpaceWriter): + """World-space writer for :class:`FabricFrameView`. + + Writes flow through ``_world_ifa_rw`` (the RW-bundle worldMatrix array); + on exit ``localMatrix`` is derived from the just-written ``worldMatrix`` + via :func:`update_indexed_local_matrix_from_world`. + """ + + def _derive_opposite(self) -> None: + self._view._recompute_local_from_world_all() # type: ignore[attr-defined] + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + view: FabricFrameView = self._view # type: ignore[assignment] + indices_wp = view._resolve_indices_wp(indices) + positions_wp = view._to_float32_2d_or_empty(positions) + orientations_wp = view._to_float32_2d_or_empty(orientations) wp.launch( - kernel=fabric_utils.arange_k, dim=self.count, inputs=[self._default_view_indices], device=self._device + kernel=fabric_utils.compose_indexed_fabric_transforms, + dim=indices_wp.shape[0], + inputs=[ + view._get_world_ifa(), + positions_wp, + orientations_wp, + view._fabric_empty_2d_array_sentinel, + False, + False, + False, + indices_wp, + ], + device=view._device, ) - wp.synchronize() + self._wrote_anything = True - 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), + def set_scales(self, scales, indices=None) -> None: + view: FabricFrameView = self._view # type: ignore[assignment] + indices_wp = view._resolve_indices_wp(indices) + scales_wp = view._to_float32_2d_or_empty(scales) + wp.launch( + kernel=fabric_utils.compose_indexed_fabric_transforms, + dim=indices_wp.shape[0], + inputs=[ + view._get_world_ifa(), + view._fabric_empty_2d_array_sentinel, + view._fabric_empty_2d_array_sentinel, + scales_wp, + False, + False, + False, + indices_wp, ], - device=self._device, + device=view._device, ) + self._wrote_anything = True + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._get_world_poses_impl(indices) # type: ignore[attr-defined] - 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) + def get_scales(self, indices=None) -> ProxyArray: + return self._view._get_world_scales_impl(indices) # type: ignore[attr-defined] + +class _FabricLocalSpaceWriter(_FabricWriterMixin, FrameViewLocalSpaceWriter): + """Local-space writer for :class:`FabricFrameView`. + + Writes flow through ``_local_ifa_rw`` (the RW-bundle localMatrix array); + on exit ``worldMatrix`` is derived from the just-written ``localMatrix`` + via :func:`update_indexed_world_matrix_from_local`. + """ + + def _derive_opposite(self) -> None: + self._view._recompute_world_from_local_all() # type: ignore[attr-defined] + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + view: FabricFrameView = self._view # type: ignore[assignment] + indices_wp = view._resolve_indices_wp(indices) + translations_wp = view._to_float32_2d_or_empty(positions) + orientations_wp = view._to_float32_2d_or_empty(orientations) 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._device, + kernel=fabric_utils.compose_indexed_fabric_transforms, + dim=indices_wp.shape[0], + inputs=[ + view._get_local_ifa(), + translations_wp, + orientations_wp, + view._fabric_empty_2d_array_sentinel, + False, + False, + False, + indices_wp, + ], + device=view._device, ) - wp.synchronize() + self._wrote_anything = True - self._fabric_positions_buf = wp.zeros((self.count, 3), dtype=wp.float32, device=self._device) - self._fabric_orientations_buf = wp.zeros((self.count, 4), dtype=wp.float32, device=self._device) - self._fabric_positions_ta = ProxyArray(self._fabric_positions_buf) - self._fabric_orientations_ta = ProxyArray(self._fabric_orientations_buf) - self._fabric_scales_buf = wp.zeros((self.count, 3), dtype=wp.float32, device=self._device) - self._fabric_dummy_buffer = wp.zeros((0, 3), dtype=wp.float32, device=self._device) - self._fabric_world_matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") - self._fabric_stage = fabric_stage - self._fabric_device = self._device + def set_scales(self, scales, indices=None) -> None: + view: FabricFrameView = self._view # type: ignore[assignment] + indices_wp = view._resolve_indices_wp(indices) + scales_wp = view._to_float32_2d_or_empty(scales) + wp.launch( + kernel=fabric_utils.compose_indexed_fabric_transforms, + dim=indices_wp.shape[0], + inputs=[ + view._get_local_ifa(), + view._fabric_empty_2d_array_sentinel, + view._fabric_empty_2d_array_sentinel, + scales_wp, + False, + False, + False, + indices_wp, + ], + device=view._device, + ) + self._wrote_anything = True - self._fabric_initialized = True - self._fabric_usd_sync_done = False + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._get_local_poses_impl(indices) # type: ignore[attr-defined] - def _sync_fabric_from_usd_once(self) -> None: - """Sync Fabric world matrices from USD once, on the first read. + def get_scales(self, indices=None) -> ProxyArray: + return self._view._get_local_scales_impl(indices) # type: ignore[attr-defined] - ``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() - positions_usd_ta, orientations_usd_ta = self._usd_view.get_world_poses() - positions_usd = positions_usd_ta.warp - orientations_usd = orientations_usd_ta.warp - scales_usd = self._usd_view.get_scales().warp +class _FabricFallbackWorldWriter(FrameViewWorldSpaceWriter): + """Fallback world-space writer used when Fabric is disabled. - self.set_world_poses(positions_usd, orientations_usd) - self.set_scales(scales_usd) + Delegates set/get calls to the internal :class:`UsdFrameView`'s backend + hooks directly. No batching, no listener pausing -- there's no Fabric to + confuse. + """ - def _resolve_indices_wp(self, indices: wp.array | None) -> wp.array: - """Resolve view indices as a Warp uint32 array.""" - if indices is None or indices == slice(None): - if self._default_view_indices is None: - raise RuntimeError("Fabric indices are not initialized.") - return self._default_view_indices - if indices.dtype != wp.uint32: - return wp.array(indices.numpy().astype("uint32"), dtype=wp.uint32, device=self._device) - return indices + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + self._view._usd_view._apply_world_pose_write(positions, orientations, indices) # type: ignore[attr-defined] + + def set_scales(self, scales, indices=None) -> None: + self._view._usd_view._apply_world_scale_write(scales, indices) # type: ignore[attr-defined] + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._usd_view._get_world_poses_impl(indices) # type: ignore[attr-defined] + + def get_scales(self, indices=None) -> ProxyArray: + return self._view._usd_view._get_world_scales_impl(indices) # type: ignore[attr-defined] + + +class _FabricFallbackLocalWriter(FrameViewLocalSpaceWriter): + """Fallback local-space writer used when Fabric is disabled.""" + + def set_poses(self, positions=None, orientations=None, indices=None) -> None: + self._view._usd_view._apply_local_pose_write(positions, orientations, indices) # type: ignore[attr-defined] + + def set_scales(self, scales, indices=None) -> None: + self._view._usd_view._apply_local_scale_write(scales, indices) # type: ignore[attr-defined] + + def get_poses(self, indices=None) -> tuple[ProxyArray, ProxyArray]: + return self._view._usd_view._get_local_poses_impl(indices) # type: ignore[attr-defined] + + def get_scales(self, indices=None) -> ProxyArray: + return self._view._usd_view._get_local_scales_impl(indices) # type: ignore[attr-defined] 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 3cfe70095fd..c023ab40ba8 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 @@ -24,7 +24,7 @@ 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, test_set_world_updates_local # noqa: E402 +from frame_view_contract_utils import CHILD_OFFSET, ViewBundle # noqa: E402, F401 from isaaclab_physx.sim.views import FabricFrameView as FrameView # noqa: E402 from pxr import Gf, UsdGeom # noqa: E402 @@ -57,7 +57,7 @@ def _skip_if_unavailable(device: str): # 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") + pytest.skip(f"{device} not available (device_count={n}) -- multi-GPU test skipped") # ------------------------------------------------------------------ @@ -118,28 +118,11 @@ def factory(num_envs: int, device: str) -> ViewBundle: # ------------------------------------------------------------------ -# 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). +# Override: ensure the shared contract test runs without xfail now that +# get_local_poses computes local from Fabric world matrices. # ------------------------------------------------------------------ - - -@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) +# (No override needed -- the shared test_set_world_updates_local from +# frame_view_contract_utils is imported via wildcard and will run as-is.) # ------------------------------------------------------------------ @@ -174,10 +157,11 @@ def test_fabric_set_world_does_not_write_back_to_usd(device, view_factory): 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) + # 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) + with view.xform_world_space_writer() as w: + w.set_poses(positions=new_pos) # Verify Fabric has the new position fab_pos, _ = view.get_world_poses() @@ -187,7 +171,7 @@ def test_fabric_set_world_does_not_write_back_to_usd(device, view_factory): ) # Verify USD still has the ORIGINAL position (no writeback). Equality, not - # approximate — USD should literally not have moved, so any drift would + # 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) @@ -200,55 +184,457 @@ def test_fabric_set_world_does_not_write_back_to_usd(device, view_factory): @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. +def test_fabric_rebuild_after_topology_change(device, view_factory): + """A simulated topology change rebuilds the indexed 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 + invoke :meth:`FabricFrameView._compute_fabric_indices` and rebuild the indexed + arrays manually, mimicking what ``_get_*_array`` would do on a real topology + event, then verify a roundtrip still works. """ bundle = view_factory(2, device) view = bundle.view - # First write — initializes Fabric and binds _fabric_selection. + # First write -- initializes Fabric. 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() + with view.xform_world_space_writer() as w: + w.set_poses(positions=initial) - def force_topology_changed(): - if view._fabric_selection is not None: - view._fabric_selection.PrepareForReuse() - spy_rebuild() + # Simulate topology change: rebuild both selection bundles, mirroring the + # lazy paths in the ``_refresh_active_bundle_if_needed`` accessor. + view._rebuild_ro_arrays() + view._rebuild_rw_arrays() - monkeypatch.setattr(view, "_prepare_for_reuse", force_topology_changed) - - # Trigger another write — goes through the forced topology-change branch. + # Trigger another write through the rebuilt arrays. 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" + with view.xform_world_space_writer() as w: + w.set_poses(positions=new) - # Read back — proves the rebuilt _view_to_fabric and _fabric_world_matrices - # are still consistent. ret_pos, _ = view.get_world_poses() 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}" + # 1e-5 ≈ 20 ULP at magnitudes ~4-6; absorbs float32 SRT compose/decompose drift. + assert torch.allclose(pos_torch, expected, atol=1e-5), f"Read after rebuild failed on {device}: {pos_torch}" + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_writer_scope_exception_recovers_state(device, view_factory): + """An exception raised inside a writer scope must still: + + 1. Restore ``IFabricHierarchy.track_*_xform_changes`` to their pre-scope state. + 2. Flip the view's ``_is_rw`` flag back to ``False``. + 3. Leave ``worldMatrix`` and ``localMatrix`` mutually consistent prim-by-prim + on whatever partial-write state Fabric currently holds (best-effort). + + Simulates an interactive-notebook scenario where a user-code exception + fires after a ``set_poses`` call but before the scope closes. + """ + bundle = view_factory(2, device) + view = bundle.view + view.get_world_poses() # ensure Fabric is initialized + + # Snapshot pre-scope tracking state. + h = view._fabric_hierarchy + was_tracking_local = h.tracking_local_xform_changes + was_tracking_world = h.tracking_world_xform_changes + + positions = wp.zeros((2, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=2, inputs=[positions, 7.0, 8.0, 9.0], device=device) + + with pytest.raises(RuntimeError, match="user-code failure"): + with view.xform_world_space_writer() as w: + w.set_poses(positions=positions) + raise RuntimeError("user-code failure") + + # Tracking state restored. + assert h.tracking_local_xform_changes == was_tracking_local + assert h.tracking_world_xform_changes == was_tracking_world + # _is_rw flipped back. + assert view._is_rw is False + # World/local mutually consistent: re-reading both spaces succeeds and the + # world positions reflect the partial write we made before the exception. + world_pos, _ = view.get_world_poses() + local_pos, _ = view.get_local_poses() + world_t = torch.as_tensor(world_pos, device=device) + local_t = torch.as_tensor(local_pos, device=device) + expected_world = torch.tensor([[7.0, 8.0, 9.0], [7.0, 8.0, 9.0]], device=device) + assert torch.allclose(world_t, expected_world, atol=1e-5), f"world not updated on {device}: {world_t}" + # Parents at PARENT_POS, so local = world - parent_world (parents identity-rotated). + expected_local = expected_world - torch.tensor(PARENT_POS, device=device) + assert torch.allclose(local_t, expected_local, atol=1e-5), ( + f"local not derived from partial world write on {device}: got {local_t}, expected {expected_local}" + ) + + # The view is still usable for further writer scopes after recovery. + next_pos = wp.zeros((2, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=2, inputs=[next_pos, 10.0, 11.0, 12.0], device=device) + with view.xform_world_space_writer() as w: + w.set_poses(positions=next_pos) + follow_up, _ = view.get_world_poses() + follow_up_t = torch.as_tensor(follow_up, device=device) + assert torch.allclose(follow_up_t, torch.tensor([[10.0, 11.0, 12.0]] * 2, device=device), atol=1e-5) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_prepare_for_reuse_detects_topology_change(device, view_factory): + """Each persistent ``PrimSelection`` exposes ``PrepareForReuse`` and returns a + bool. When the underlying Fabric topology is unchanged it returns False. + """ + bundle = view_factory(1, device) + view = bundle.view + view.get_world_poses() # trigger Fabric init + + assert view._sel_ro is not None, "RO selection not initialized" + assert view._sel_rw is not None, "RW selection not initialized" + for selection in (view._sel_ro, view._sel_rw): + result = selection.PrepareForReuse() + assert isinstance(result, bool), f"PrepareForReuse should return bool, got {type(result)}" + assert not result, "PrepareForReuse should return False when no topology change" + + +def _read_fabric_world_matrix_translation(view, prim_index=0): + """Read cached Fabric worldMatrix directly, without FrameView getter sync.""" + rt_prim = view._stage.GetPrimAtPath(view.prim_paths[prim_index]) + world_attr = rt_prim.GetAttribute(view._WORLD_MATRIX_NAME) + matrix = world_attr.Get() + translation = matrix.ExtractTranslation() + return torch.tensor( + [[float(translation[0]), float(translation[1]), float(translation[2])]], + dtype=torch.float32, + device=view._device, + ) + + +def _read_fabric_world_matrix_scale(view, prim_index=0): + """Read cached Fabric worldMatrix scale directly, without FrameView getter sync.""" + import usdrt # noqa: PLC0415 + + rt_prim = view._stage.GetPrimAtPath(view.prim_paths[prim_index]) + world_attr = rt_prim.GetAttribute(view._WORLD_MATRIX_NAME) + matrix = world_attr.Get() + scale = usdrt.Gf.Transform(matrix).GetScale() + return torch.tensor( + [[float(scale[0]), float(scale[1]), float(scale[2])]], + dtype=torch.float32, + device=view._device, + ) + + +def _read_fabric_local_matrix_translation(view, prim_index=0): + """Read cached Fabric localMatrix directly, without FrameView getter sync.""" + rt_prim = view._stage.GetPrimAtPath(view.prim_paths[prim_index]) + local_attr = rt_prim.GetAttribute(view._LOCAL_MATRIX_NAME) + matrix = local_attr.Get() + translation = matrix.ExtractTranslation() + return torch.tensor( + [[float(translation[0]), float(translation[1]), float(translation[2])]], + dtype=torch.float32, + device=view._device, + ) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_set_local_via_fabric_path(device, view_factory): + """Exercise the Fabric-native set_local_poses path. + + Ensures set_local_poses computes child_world = parent_world * local + entirely within Fabric (not falling back to USD) by first triggering + the Fabric sync via get_world_poses. + """ + bundle = view_factory(num_envs=1, device=device) + view = bundle.view + + # Trigger lazy `_initialize_fabric()` so subsequent calls take the Fabric path. + view.get_world_poses() + + # Now write via the writer scope (Fabric path). + new_local_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_local_pos, 1.0, 2.0, 3.0], device=device) + ori = torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32, device=device) + new_local_ori = wp.from_torch(ori) + + with view.xform_local_space_writer() as w: + w.set_poses(positions=new_local_pos, orientations=new_local_ori) + + # Verify: world = parent(0,0,1) + local(1,2,3) = (1,2,4) + world_pos, _ = view.get_world_poses() + expected = torch.tensor([[1.0, 2.0, 4.0]], dtype=torch.float32, device=device) + torch.testing.assert_close(torch.as_tensor(world_pos, device=device), expected, atol=1e-4, rtol=0) + + # Verify get_local_poses returns the local offset + local_pos, _ = view.get_local_poses() + expected_local = torch.tensor([[1.0, 2.0, 3.0]], dtype=torch.float32, device=device) + torch.testing.assert_close(torch.as_tensor(local_pos, device=device), expected_local, atol=1e-4, rtol=0) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_get_scales_fabric_path(device, view_factory): + """Exercise the Fabric-native get_world_scales path.""" + bundle = view_factory(num_envs=1, device=device) + view = bundle.view + + # Trigger lazy `_initialize_fabric()` so the get_world_scales call below uses Fabric. + view.get_world_poses() + + scales = view.get_world_scales() + scales_t = scales.torch + # Default scale should be (1, 1, 1) + expected = torch.tensor([[1.0, 1.0, 1.0]], dtype=torch.float32, device=device) + torch.testing.assert_close(scales_t, expected, atol=1e-4, rtol=0) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_local_scales_roundtrip(device, view_factory): + """Writing scales through the local-space writer roundtrips via ``localMatrix``.""" + bundle = view_factory(num_envs=2, device=device) + view = bundle.view + + # Force Fabric init + view.get_world_poses() + + 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) + with view.xform_local_space_writer() as w: + w.set_scales(new_scales) + + ret_scales = view.get_local_scales() + scales_torch = ret_scales.torch + expected = torch.tensor([[2.0, 3.0, 4.0], [2.0, 3.0, 4.0]], device=device) + torch.testing.assert_close(scales_torch, expected, atol=1e-5, rtol=0) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_world_scales_roundtrip(device, view_factory): + """Writing scales through the world-space writer roundtrips via ``worldMatrix``.""" + bundle = view_factory(num_envs=2, device=device) + view = bundle.view + + # Force Fabric init + view.get_world_poses() + + new_scales = wp.zeros((2, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=2, inputs=[new_scales, 5.0, 6.0, 7.0], device=device) + with view.xform_world_space_writer() as w: + w.set_scales(new_scales) + + ret_scales = view.get_world_scales() + scales_torch = ret_scales.torch + expected = torch.tensor([[5.0, 6.0, 7.0], [5.0, 6.0, 7.0]], device=device) + torch.testing.assert_close(scales_torch, expected, atol=1e-5, rtol=0) + + +# ------------------------------------------------------------------ +# Transpose-convention verification: world ↔ local kernels rely on the +# identity ``(A·B)ᵀ = Bᵀ·Aᵀ`` to drop explicit transposes when operating +# on Fabric's column-transposed matrix storage. The translation-only +# parents used by the standard fixture cannot distinguish the right +# convention from the wrong one -- the rotation block is identity and +# equals its own transpose. These tests use a parent rotated 90° around +# Z so that an incorrect storage convention would produce a clearly +# wrong child pose. +# ------------------------------------------------------------------ + + +# Parent at (0, 0, 1) rotated +90° around Z (so the parent X axis points +# along world +Y). Quaternion components in (x, y, z, w) order. +_ROTATED_PARENT_POS = (0.0, 0.0, 1.0) +_ROTATED_PARENT_QUAT_XYZW = (0.0, 0.0, 0.70710678, 0.70710678) + + +def _build_rotated_parent_view(device: str) -> "FrameView": + """Build a 1-env FabricFrameView whose parent is rotated 90° around Z.""" + stage = sim_utils.get_current_stage() + sim_utils.create_prim( + "/World/Parent_0", + "Xform", + translation=_ROTATED_PARENT_POS, + orientation=_ROTATED_PARENT_QUAT_XYZW, + stage=stage, + ) + sim_utils.create_prim("/World/Parent_0/Child", "Camera", translation=(0.0, 0.0, 0.0), stage=stage) + sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=0.01, device=device, use_fabric=True)) + view = FrameView("/World/Parent_.*/Child", device=device) + view.get_world_poses() # force Fabric init and USD→Fabric seed + return view + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_set_local_then_get_world_with_rotated_parent(device): + """Verify ``update_indexed_world_matrix_from_local`` under non-identity parent rotation. + + With parent rotated +90° around Z, a child local translation of (1, 0, 0) + must produce world translation (0, 1, 1) -- parent_pos + R · local. If the + transpose convention in the kernel were wrong, the rotation would flip + direction and the world position would land at (0, -1, 1) instead. + """ + _skip_if_unavailable(device) + view = _build_rotated_parent_view(device) + + new_local = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_local, 1.0, 0.0, 0.0], device=device) + identity_quat = wp.from_torch(torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32, device=device)) + with view.xform_local_space_writer() as w: + w.set_poses(positions=new_local, orientations=identity_quat) + + world_pos, _ = view.get_world_poses() + expected = torch.tensor([[0.0, 1.0, 1.0]], dtype=torch.float32, device=device) + torch.testing.assert_close(torch.as_tensor(world_pos, device=device), expected, atol=1e-5, rtol=0) + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_set_world_then_get_local_with_rotated_parent(device): + """Verify ``update_indexed_local_matrix_from_world`` under non-identity parent rotation. + + With parent rotated +90° around Z and at (0, 0, 1), writing child world + translation (5, 0, 2) must yield child local translation Rᵀ · (5, 0, 1) = + (0, -5, 1). A wrong transpose convention would invert the rotation in the + wrong direction and produce (0, 5, 1) instead. + """ + _skip_if_unavailable(device) + view = _build_rotated_parent_view(device) + + new_world = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_world, 5.0, 0.0, 2.0], device=device) + with view.xform_world_space_writer() as w: + w.set_poses(positions=new_world) + + local_pos, _ = view.get_local_poses() + expected = torch.tensor([[0.0, -5.0, 1.0]], dtype=torch.float32, device=device) + torch.testing.assert_close(torch.as_tensor(local_pos, device=device), expected, atol=1e-5, rtol=0) + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_initial_seed_with_scaled_parent(device): + """Verify the initial USD→Fabric seed handles non-unit scales correctly. + + Sets up a parent with world scale (2, 1, 1) and a child with local scale + (3, 1, 1) at local translation (1, 0, 0). Expected world-space values for + the child: + + * world scale = parent_scale * child_local_scale = (6, 1, 1) + * world position = parent_pos + parent_scale * child_local_pos + = (0, 0, 1) + (2 * 1, 0, 0) = (2, 0, 1) + + If the parent's worldMatrix is seeded with a hardcoded unit scale, + ``get_scales`` returns (3, 1, 1) instead of (6, 1, 1) and ``get_world_poses`` + returns (1, 0, 1) instead of (2, 0, 1). If the child's localMatrix is + seeded without scale, the derived world scale collapses to (2, 1, 1). + This test catches both regressions. + """ + _skip_if_unavailable(device) + stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/Parent_0", "Xform", translation=(0.0, 0.0, 1.0), scale=(2.0, 1.0, 1.0), stage=stage) + sim_utils.create_prim( + "/World/Parent_0/Child", + "Camera", + translation=(1.0, 0.0, 0.0), + scale=(3.0, 1.0, 1.0), + stage=stage, + ) + sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=0.01, device=device, use_fabric=True)) + view = FrameView("/World/Parent_.*/Child", device=device) + + world_pos, _ = view.get_world_poses() + torch.testing.assert_close( + torch.as_tensor(world_pos, device=device), + torch.tensor([[2.0, 0.0, 1.0]], dtype=torch.float32, device=device), + atol=1e-5, + rtol=0, + ) + + scales = view.get_world_scales().torch + torch.testing.assert_close( + scales, + torch.tensor([[6.0, 1.0, 1.0]], dtype=torch.float32, device=device), + atol=1e-5, + rtol=0, + ) # ------------------------------------------------------------------ -# Multi-GPU tests (cuda:1) — skipped automatically on single-GPU workstations +# Multi-view per stage: per-view single-writer isolation +# ------------------------------------------------------------------ + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_multi_view_writer_isolation(device): + """Two ``FabricFrameView`` instances on the same stage have independent writer scopes. + + Each view's ``_active_writer`` is per-instance, so view B's read or writer + scope must not interfere with view A's pending writes. Verifies that + writes through one view do not corrupt the other view's poses, and that + each view can hold its own writer scope concurrently with reads on the + other view. + """ + _skip_if_unavailable(device) + stage = sim_utils.get_current_stage() + + sim_utils.create_prim("/World/EnvA_0", "Xform", translation=(0.0, 0.0, 1.0), stage=stage) + sim_utils.create_prim("/World/EnvA_0/ChildA", "Camera", translation=(0.1, 0.0, 0.0), stage=stage) + sim_utils.create_prim("/World/EnvB_0", "Xform", translation=(0.0, 0.0, 2.0), stage=stage) + sim_utils.create_prim("/World/EnvB_0/ChildB", "Camera", translation=(0.2, 0.0, 0.0), stage=stage) + + sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=0.01, device=device, use_fabric=True)) + view_a = FrameView("/World/EnvA_.*/ChildA", device=device) + view_b = FrameView("/World/EnvB_.*/ChildB", device=device) + + expected_a0 = torch.tensor([[0.1, 0.0, 1.0]], dtype=torch.float32, device=device) + expected_b0 = torch.tensor([[0.2, 0.0, 2.0]], dtype=torch.float32, device=device) + torch.testing.assert_close( + torch.as_tensor(view_a.get_world_poses()[0], device=device), expected_a0, atol=1e-5, rtol=0 + ) + torch.testing.assert_close( + torch.as_tensor(view_b.get_world_poses()[0], device=device), expected_b0, atol=1e-5, rtol=0 + ) + + # Write a new local pose on view A only via a writer scope. + new_local_a = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_local_a, 1.0, 0.0, 0.0], device=device) + identity_quat = wp.from_torch(torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32, device=device)) + with view_a.xform_local_space_writer() as w: + w.set_poses(positions=new_local_a, orientations=identity_quat) + + # View B remains undisturbed. + torch.testing.assert_close( + torch.as_tensor(view_b.get_world_poses()[0], device=device), expected_b0, atol=1e-5, rtol=0 + ) + + # View A's world reflects the new local. + expected_a1 = torch.tensor([[1.0, 0.0, 1.0]], dtype=torch.float32, device=device) + torch.testing.assert_close( + torch.as_tensor(view_a.get_world_poses()[0], device=device), expected_a1, atol=1e-5, rtol=0 + ) + + # Write a new local pose on view B; view A unaffected. + new_local_b = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_local_b, 3.0, 0.0, 0.0], device=device) + with view_b.xform_local_space_writer() as w: + w.set_poses(positions=new_local_b, orientations=identity_quat) + torch.testing.assert_close( + torch.as_tensor(view_a.get_world_poses()[0], device=device), expected_a1, atol=1e-5, rtol=0 + ) + expected_b1 = torch.tensor([[3.0, 0.0, 2.0]], dtype=torch.float32, device=device) + torch.testing.assert_close( + torch.as_tensor(view_b.get_world_poses()[0], device=device), expected_b1, atol=1e-5, rtol=0 + ) + + # Single-active-writer is per-view: opening a writer on A leaves B free. + with view_a.xform_world_space_writer(): + assert view_a._active_writer is not None + assert view_b._active_writer is None + # B can still open its own writer concurrently. + with view_b.xform_world_space_writer(): + assert view_b._active_writer is not None + assert view_a._active_writer is None + assert view_b._active_writer is None + + +# ------------------------------------------------------------------ +# Multi-GPU tests (cuda:1) -- skipped automatically on single-GPU workstations # ------------------------------------------------------------------ @@ -268,7 +654,8 @@ def test_fabric_cuda1_world_pose_roundtrip(device, view_factory): 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) + with view.xform_world_space_writer() as w: + w.set_poses(positions=new_pos) ret_pos, _ = view.get_world_poses() pos_torch = torch.as_tensor(ret_pos, device=device) @@ -298,9 +685,10 @@ def test_fabric_cuda1_no_usd_writeback(device, view_factory): 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) + with view.xform_world_space_writer() as w: + w.set_poses(positions=new_pos) - # USD must not have moved at all — equality, not approximate. + # 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), ( @@ -314,20 +702,323 @@ def test_fabric_cuda1_no_usd_writeback(device, view_factory): ) @pytest.mark.parametrize("device", ["cuda:1"]) def test_fabric_cuda1_scales_roundtrip(device, view_factory): - """set_scales -> get_scales roundtrip works on cuda:1. + """World-space scale writes roundtrip via ``worldMatrix`` 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. + Covers the scales path on the non-primary CUDA device: the writer + launches its compose kernel on ``self._device`` and the indexed-fabric + arrays are rebuilt on demand from the corresponding selection. """ 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) + with view.xform_world_space_writer() as w: + w.set_scales(new_scales) - ret_scales = view.get_scales() - scales_torch = torch.as_tensor(ret_scales, device=device) + ret_scales = view.get_world_scales() + scales_torch = ret_scales.torch 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}" + + +# ------------------------------------------------------------------ +# Sequential writer scopes (interleaved world / local writes via two scopes) +# ------------------------------------------------------------------ + + +def _build_two_child_view(device: str) -> "FrameView": + """Build a 2-env FabricFrameView with rotated parent for interleave tests. + + Parent at (0, 0, 1) rotated 90° around Z. Two child prims at identity local. + """ + _skip_if_unavailable(device) + stage = sim_utils.get_current_stage() + for i in range(2): + sim_utils.create_prim( + f"/World/Parent_{i}", + "Xform", + translation=_ROTATED_PARENT_POS, + orientation=_ROTATED_PARENT_QUAT_XYZW, + stage=stage, + ) + sim_utils.create_prim(f"/World/Parent_{i}/Child", "Camera", translation=(0.0, 0.0, 0.0), stage=stage) + sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=0.01, device=device, use_fabric=True)) + view = FrameView("/World/Parent_.*/Child", device=device) + view.get_world_poses() # force init + return view + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_sequential_world_then_local_scopes_partial_indices(device): + """A world writer scope (idx 0), then a local writer scope (idx 1). Both correct.""" + view = _build_two_child_view(device) + + new_world_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_world_pos, 5.0, 0.0, 2.0], device=device) + idx0 = wp.from_torch(torch.tensor([0], dtype=torch.int32, device=device)) + with view.xform_world_space_writer() as w: + w.set_poses(positions=new_world_pos, indices=idx0) + + new_local_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_local_pos, 1.0, 0.0, 0.0], device=device) + identity_quat = wp.from_torch(torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32, device=device)) + idx1 = wp.from_torch(torch.tensor([1], dtype=torch.int32, device=device)) + with view.xform_local_space_writer() as w: + w.set_poses(positions=new_local_pos, orientations=identity_quat, indices=idx1) + + # Verify index 0's world pose is still (5, 0, 2) -- index 1's local-scope write + # derives world from the just-written local; index 0 was outside the derived + # set on entry but the world-scope already wrote it and the second scope + # re-derives world from local for all prims (including idx 0). After the + # second scope, idx 0's local was derived from its world (= (0, -5, 1)), + # so re-deriving world = parent * local lands back on (5, 0, 2). + world_pos, _ = view.get_world_poses(indices=idx0) + torch.testing.assert_close( + torch.as_tensor(world_pos, device=device), + torch.tensor([[5.0, 0.0, 2.0]], dtype=torch.float32, device=device), + atol=1e-5, + rtol=0, + ) + + # Index 0's local (derived from its world): + # local = Rᵀ · (child_world_pos - parent_pos) = Rz(-90)·(5, 0, 1) = (0, -5, 1) + local_pos_0, _ = view.get_local_poses(indices=idx0) + torch.testing.assert_close( + torch.as_tensor(local_pos_0, device=device), + torch.tensor([[0.0, -5.0, 1.0]], dtype=torch.float32, device=device), + atol=1e-5, + rtol=0, + ) + + # Index 1's world (derived from local): + # world = parent_world * local = Rz(90)·(1, 0, 0) + parent_pos = (0, 1, 0) + (0, 0, 1) = (0, 1, 1) + world_pos_1, _ = view.get_world_poses(indices=idx1) + torch.testing.assert_close( + torch.as_tensor(world_pos_1, device=device), + torch.tensor([[0.0, 1.0, 1.0]], dtype=torch.float32, device=device), + atol=1e-5, + rtol=0, + ) + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_sequential_local_then_world_scopes_partial_indices(device): + """A local writer scope (idx 0), then a world writer scope (idx 1). Both correct.""" + view = _build_two_child_view(device) + + new_local_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_local_pos, 2.0, 3.0, 0.0], device=device) + identity_quat = wp.from_torch(torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32, device=device)) + idx0 = wp.from_torch(torch.tensor([0], dtype=torch.int32, device=device)) + with view.xform_local_space_writer() as w: + w.set_poses(positions=new_local_pos, orientations=identity_quat, indices=idx0) + + new_world_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_world_pos, 10.0, 20.0, 30.0], device=device) + idx1 = wp.from_torch(torch.tensor([1], dtype=torch.int32, device=device)) + with view.xform_world_space_writer() as w: + w.set_poses(positions=new_world_pos, indices=idx1) + + # Index 0's world (derived from local): + # world = Rz(90)·(2, 3, 0) + (0, 0, 1) = (-3, 2, 0) + (0, 0, 1) = (-3, 2, 1) + world_pos_0, _ = view.get_world_poses(indices=idx0) + torch.testing.assert_close( + torch.as_tensor(world_pos_0, device=device), + torch.tensor([[-3.0, 2.0, 1.0]], dtype=torch.float32, device=device), + atol=1e-5, + rtol=0, + ) + + # Index 1's world is still (10, 20, 30). + world_pos_1, _ = view.get_world_poses(indices=idx1) + torch.testing.assert_close( + torch.as_tensor(world_pos_1, device=device), + torch.tensor([[10.0, 20.0, 30.0]], dtype=torch.float32, device=device), + atol=1e-5, + rtol=0, + ) + + # Index 1's local (derived from world): + # local = Rᵀ·(world - parent) = Rz(-90)·(10, 20, 29) = (20, -10, 29) + local_pos_1, _ = view.get_local_poses(indices=idx1) + torch.testing.assert_close( + torch.as_tensor(local_pos_1, device=device), + torch.tensor([[20.0, -10.0, 29.0]], dtype=torch.float32, device=device), + atol=1e-5, + rtol=0, + ) + + +# ------------------------------------------------------------------ +# FrameViewSpaceWriterBase contract tests +# ------------------------------------------------------------------ + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_world_writer_writes_world_and_derives_local(device, view_factory): + """A world writer's set_poses + set_scales updates cached Fabric world AND derives local on exit.""" + bundle = view_factory(num_envs=1, device=device) + view = bundle.view + view.get_world_poses() # trigger Fabric init + + new_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_pos, 1.0, 2.0, 4.0], device=device) + new_scales = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_scales, 2.0, 3.0, 4.0], device=device) + + with view.xform_world_space_writer() as w: + w.set_poses(positions=new_pos) + w.set_scales(new_scales) + + # Cached world reflects the writes (parent is at (0, 0, 1) so child world pos + # is whatever we wrote). + expected_world = torch.tensor([[1.0, 2.0, 4.0]], dtype=torch.float32, device=device) + cached_world = _read_fabric_world_matrix_translation(view) + torch.testing.assert_close(cached_world, expected_world, atol=1e-5, rtol=0) + + expected_scale = torch.tensor([[2.0, 3.0, 4.0]], dtype=torch.float32, device=device) + cached_scale = _read_fabric_world_matrix_scale(view) + torch.testing.assert_close(cached_scale, expected_scale, atol=1e-5, rtol=0) + + # Local derived: with parent at (0, 0, 1) (identity rotation, unit scale), + # local translation = world - parent = (1, 2, 3). + expected_local = torch.tensor([[1.0, 2.0, 3.0]], dtype=torch.float32, device=device) + cached_local = _read_fabric_local_matrix_translation(view) + torch.testing.assert_close(cached_local, expected_local, atol=1e-5, rtol=0) + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_local_writer_writes_local_and_derives_world(device, view_factory): + """A local writer's set_poses updates cached Fabric local AND derives world on exit.""" + bundle = view_factory(num_envs=1, device=device) + view = bundle.view + view.get_world_poses() + + new_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_pos, 1.0, 2.0, 3.0], device=device) + identity_quat = wp.from_torch(torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32, device=device)) + + with view.xform_local_space_writer() as w: + w.set_poses(positions=new_pos, orientations=identity_quat) + + expected_local = torch.tensor([[1.0, 2.0, 3.0]], dtype=torch.float32, device=device) + cached_local = _read_fabric_local_matrix_translation(view) + torch.testing.assert_close(cached_local, expected_local, atol=1e-5, rtol=0) + + # World derived: with parent at (0, 0, 1), world = parent + local = (1, 2, 4). + expected_world = torch.tensor([[1.0, 2.0, 4.0]], dtype=torch.float32, device=device) + cached_world = _read_fabric_world_matrix_translation(view) + torch.testing.assert_close(cached_world, expected_world, atol=1e-5, rtol=0) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_writer_single_derivation_per_scope(device, view_factory, monkeypatch): + """Multiple set_* calls inside one scope produce exactly one derive-kernel launch.""" + bundle = view_factory(num_envs=1, device=device) + view = bundle.view + view.get_world_poses() + + calls = 0 + original = view._recompute_local_from_world_all + + def counted(): + nonlocal calls + calls += 1 + original() + + monkeypatch.setattr(view, "_recompute_local_from_world_all", counted) + + new_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_pos, 1.0, 2.0, 4.0], device=device) + new_scales = wp.zeros((1, 3), dtype=wp.float32, device=device) + wp.launch(kernel=_fill_position, dim=1, inputs=[new_scales, 2.0, 3.0, 4.0], device=device) + + with view.xform_world_space_writer() as w: + w.set_poses(positions=new_pos) + w.set_scales(new_scales) + assert calls == 0 # no derive yet + + assert calls == 1 # exactly one derive on exit + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_writer_single_active_invariant(device, view_factory): + """Only one writer scope may be active per view at a time.""" + bundle = view_factory(num_envs=1, device=device) + view = bundle.view + view.get_world_poses() + + with view.xform_world_space_writer(): + with pytest.raises(RuntimeError, match="already has an active writer"): + view.xform_world_space_writer().__enter__() + with pytest.raises(RuntimeError, match="already has an active writer"): + view.xform_local_space_writer().__enter__() + # After the outer scope exits, the lock is released and a new scope succeeds. + with view.xform_local_space_writer(): + pass + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_writer_restores_hierarchy_change_tracking(device, view_factory): + """``__exit__`` restores the prior ``track_*_xform_changes`` state (don't re-enable paused listeners).""" + bundle = view_factory(num_envs=1, device=device) + view = bundle.view + view.get_world_poses() + h = view._fabric_hierarchy + + # Case 1: pre-paused local stays paused after exit. + h.track_local_xform_changes(False) + assert not h.tracking_local_xform_changes + with view.xform_world_space_writer(): + pass + assert not h.tracking_local_xform_changes, "writer must not re-enable a pre-paused local listener" + + # Case 2: pre-enabled local stays enabled after exit. + h.track_local_xform_changes(True) + with view.xform_world_space_writer(): + pass + assert h.tracking_local_xform_changes, "writer must restore the pre-enabled local listener" + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_writer_empty_scope_does_no_derivation(device, view_factory, monkeypatch): + """Entering and exiting a writer scope without any ``set_*`` call must not launch the derive kernel.""" + bundle = view_factory(num_envs=1, device=device) + view = bundle.view + view.get_world_poses() + + calls = 0 + original = view._recompute_local_from_world_all + + def counted(): + nonlocal calls + calls += 1 + original() + + monkeypatch.setattr(view, "_recompute_local_from_world_all", counted) + + with view.xform_world_space_writer(): + pass + + assert calls == 0 + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_view_getter_inside_scope_raises(device, view_factory): + """View-level getters raise ``RuntimeError`` while a writer scope is active.""" + bundle = view_factory(num_envs=1, device=device) + view = bundle.view + view.get_world_poses() + + with view.xform_world_space_writer(): + with pytest.raises(RuntimeError, match="while a writer scope is active"): + view.get_world_poses() + with pytest.raises(RuntimeError, match="while a writer scope is active"): + view.get_local_poses() + with pytest.raises(RuntimeError, match="while a writer scope is active"): + view.get_world_scales() + with pytest.raises(RuntimeError, match="while a writer scope is active"): + view.get_local_scales() + # After the scope exits, view getters work again. + view.get_world_poses() From 1d5d6456b47531cb5d4ea46482cdaa58913dabab Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis <46975745+mmichelis@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:00:05 +0200 Subject: [PATCH 100/139] Bump Newton to include coupled solver framework (#6366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Pins every active newton[sim] dependency to Newton commit c7ae7c7648cd0717df39e5c94b95d5a02c997320 (current main), which includes the experimental coupled solver framework (newton-physics/newton#2848). Upstream comparison: https://github.com/newton-physics/newton/compare/2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee...c7ae7c7648cd0717df39e5c94b95d5a02c997320 Compatibility updates: adds newton-usd-schemas>=0.3.1 next to each pin since Newton now hard-requires it for USD parsing (newton-physics/newton#3117) and it only ships in the heavy newton[importers] extra; removes the cloner fallback reading the deleted ModelBuilder.equality_constraint_* attributes (newton-physics/newton#3296), which are now handled through the mujoco:equality_constraint_* custom attributes, and updates the fake builder in the cloner test accordingly. Verified in a fresh uv env: full isaaclab_newton test suite (340 passed non-gated plus all Isaac-Sim-gated files: articulation 236, rigid object 94, rigid object collection 158, actuators 71, schemas 20), contrib rigid-deformable coupling (4 passed), and end-to-end cartpole training with presets=newton_mjwarp. Note: isaacsim-core 6.0.0.1 hard-pins newton==1.2.0, so installing Isaac Sim after the Newton pin clobbers it; the documented ./isaaclab.sh -i order handles this correctly. ## 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 - [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 --- .../changelog.d/pin-newton-c7ae7c7.rst | 20 +++++++++++++++++++ .../cloner/newton_clone_utils.py | 3 --- source/isaaclab_newton/pyproject.toml | 3 ++- .../test/cloner/test_rename_builder_labels.py | 1 - .../changelog.d/pin-newton-c7ae7c7.rst | 6 ++++++ source/isaaclab_physx/pyproject.toml | 3 ++- .../changelog.d/pin-newton-c7ae7c7.rst | 6 ++++++ source/isaaclab_visualizers/pyproject.toml | 12 +++++++---- tools/wheel_builder/res/python_packages.toml | 3 ++- 9 files changed, 46 insertions(+), 11 deletions(-) create mode 100644 source/isaaclab_newton/changelog.d/pin-newton-c7ae7c7.rst create mode 100644 source/isaaclab_physx/changelog.d/pin-newton-c7ae7c7.rst create mode 100644 source/isaaclab_visualizers/changelog.d/pin-newton-c7ae7c7.rst diff --git a/source/isaaclab_newton/changelog.d/pin-newton-c7ae7c7.rst b/source/isaaclab_newton/changelog.d/pin-newton-c7ae7c7.rst new file mode 100644 index 00000000000..e49d3acc135 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/pin-newton-c7ae7c7.rst @@ -0,0 +1,20 @@ +Changed +^^^^^^^ + +* Changed the ``newton[sim]`` dependency pin to Newton commit + ``c7ae7c7648cd0717df39e5c94b95d5a02c997320``, which includes the experimental + coupled solver framework. + +Added +^^^^^ + +* Added the ``newton-usd-schemas`` dependency, required by Newton's USD parsing + since the new pin. + +Fixed +^^^^^ + +* Fixed the cloner label renaming after Newton's removal of + ``ModelBuilder.equality_constraint_label`` by dropping the equality + constraint fallback; equality constraint labels are renamed through the + generic custom attribute handling. diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py index e73632d656f..a2a3435f246 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py @@ -148,9 +148,6 @@ def _rename_pair(values, worlds, *, collect_body_bindings: bool = False): ): _rename_pair(labels, worlds, collect_body_bindings=collect_body_bindings) - if "mujoco:equality_constraint_label" not in builder.custom_attributes: - _rename_pair(builder.equality_constraint_label, builder.equality_constraint_world) - custom_attrs = builder.custom_attributes.values() worlds_by_freq = {attr.frequency: attr.values for attr in custom_attrs if attr.references == "world"} for attr in custom_attrs: diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index a855efb7ae6..383cb156a86 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -27,7 +27,8 @@ all = [ "prettytable>=3.3.0", "PyOpenGL-accelerate>=3.1.0", "pyglet>=2.1.6,<3", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", + "newton-usd-schemas>=0.3.1", ] [tool.setuptools] 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 2de4e556b28..b0fa0cb00ec 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -29,7 +29,6 @@ "shape_label": "Shape", "articulation_label": "Articulation", "constraint_mimic_label": "ConstraintMimic", - "equality_constraint_label": "EqualityConstraint", } _VIS_LABEL_ATTRS = tuple(_VIS_LABEL_SUFFIXES) diff --git a/source/isaaclab_physx/changelog.d/pin-newton-c7ae7c7.rst b/source/isaaclab_physx/changelog.d/pin-newton-c7ae7c7.rst new file mode 100644 index 00000000000..36a9ce0bed3 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/pin-newton-c7ae7c7.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Changed the ``newton[sim]`` dependency pin of the ``newton`` extra to Newton + commit ``c7ae7c7648cd0717df39e5c94b95d5a02c997320`` and added the + ``newton-usd-schemas`` dependency required by Newton's USD parsing. diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index 82a80f7e532..4b193304613 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -24,7 +24,8 @@ Repository = "https://github.com/isaac-sim/IsaacLab" [project.optional-dependencies] newton = [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", + "newton-usd-schemas>=0.3.1", ] [tool.setuptools] diff --git a/source/isaaclab_visualizers/changelog.d/pin-newton-c7ae7c7.rst b/source/isaaclab_visualizers/changelog.d/pin-newton-c7ae7c7.rst new file mode 100644 index 00000000000..8395d1b459e --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/pin-newton-c7ae7c7.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Changed the ``newton[sim]`` dependency pin of the visualizer extras to Newton + commit ``c7ae7c7648cd0717df39e5c94b95d5a02c997320`` and added the + ``newton-usd-schemas`` dependency required by Newton's USD parsing. diff --git a/source/isaaclab_visualizers/pyproject.toml b/source/isaaclab_visualizers/pyproject.toml index 9fac9331027..d875dfe116b 100644 --- a/source/isaaclab_visualizers/pyproject.toml +++ b/source/isaaclab_visualizers/pyproject.toml @@ -29,24 +29,28 @@ Repository = "https://github.com/isaac-sim/IsaacLab" kit = [] newton = [ "warp-lang", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", + "newton-usd-schemas>=0.3.1", "PyOpenGL-accelerate", "pyglet>=2.1.6,<3", "imgui-bundle>=1.92.5", "typing-extensions>=4.15.0", ] rerun = [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", + "newton-usd-schemas>=0.3.1", "rerun-sdk>=0.29.0", "pyarrow==22.0.0", ] viser = [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", + "newton-usd-schemas>=0.3.1", "viser>=1.0.16", ] all = [ "imgui-bundle>=1.92.5", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", + "newton-usd-schemas>=0.3.1", "PyOpenGL-accelerate", "pyglet>=2.1.6,<3", # Match rerun-sdk's supported Arrow stack and avoid resolver drift across environments. diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index 8f1bcb6a426..a5d22fd8346 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -88,7 +88,8 @@ pyproject.optional-dependencies.all = [ # ================================================================================ { "newton" = [ "warp-lang==1.15.0.dev20260626", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@2064e3b79807dcc1679d1eb86ef7efd9ef0f28ee", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", + "newton-usd-schemas>=0.3.1", "PyOpenGL-accelerate==3.1.10" ] }, # ================================================================================ From b77fe5b90474f1ac14d18e09f592ef16fd91a156 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Tue, 7 Jul 2026 16:04:59 +0200 Subject: [PATCH 101/139] Enable OVPhysX shape-material test via OvPhysxView (#6239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked on #6225** (OVPhysX `Articulation` → `OvPhysxView`), which is stacked on #6224 (`OvPhysxView`). Review/merge those first — the diff here collapses to a single commit once they land. ## What The ovphysx wheel now exposes per-collision-shape material as the `ARTICULATION_SHAPE_FRICTION_AND_RESTITUTION` / `RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION` tensor types. This closes the gap that kept `test_set_material_properties` `xfail`ed: the test had been written against the PhysX-only `root_view.get_material_properties()` / `set_material_properties()` / `max_shapes` view API, which `OvPhysxView` does not provide. - Add `tensor_types` aliases `SHAPE_FRICTION_AND_RESTITUTION` and `RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION`, guarded with `try/except AttributeError` so the module stays import-safe against older wheels. - Rewrite `test_set_material_properties` to read/write the material through `OvPhysxView.get_attribute` / `set_attribute` on the new tensor type. The per-articulation shape count is derived from the binding shape `[N, S, 3]` (`[static_friction, dynamic_friction, restitution]`); the test skips on wheels that lack the type. The now-unused `_MATERIAL_GAP_REASON` is removed. ## Verification The material binding is device-resident (it follows the simulation device). The `set → step → read` round-trip passes on both `cpu` and `cuda:0` for `num_articulations` 1 and 2. --- ...ner-feat-ovphysx_shape_materials.minor.rst | 9 ++++ .../isaaclab_ovphysx/tensor_types.py | 21 ++++++++ .../test/assets/test_articulation.py | 49 +++++++++---------- 3 files changed, 52 insertions(+), 27 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_shape_materials.minor.rst diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_shape_materials.minor.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_shape_materials.minor.rst new file mode 100644 index 00000000000..b9881b3cf95 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_shape_materials.minor.rst @@ -0,0 +1,9 @@ +Added +^^^^^ + +* Added :data:`~isaaclab_ovphysx.tensor_types.SHAPE_FRICTION_AND_RESTITUTION` and + :data:`~isaaclab_ovphysx.tensor_types.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION` aliases for + the per-collision-shape material tensor types (static friction, dynamic friction, + restitution) exposed by the ovphysx wheel. Read and write them through + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`, e.g. + ``root_view.get_attribute(tensor_types.SHAPE_FRICTION_AND_RESTITUTION)``. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/tensor_types.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/tensor_types.py index a208af36ae6..3ad7c92e9c8 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/tensor_types.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/tensor_types.py @@ -252,6 +252,27 @@ except AttributeError: pass +""" +Shape material properties (friction / restitution) — new in ovphysx 0.4.13+. +Guarded so this module stays import-safe against older wheels that lack them. +""" + +try: + SHAPE_FRICTION_AND_RESTITUTION = _TT.ARTICULATION_SHAPE_FRICTION_AND_RESTITUTION + """Per-collision-shape material of each articulation instance — read/write, GPU. + Shape ``(N, S, 3)``: ``[0]`` static friction, ``[1]`` dynamic friction, ``[2]`` + restitution (all dimensionless).""" +except AttributeError: + pass + +try: + RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION = _TT.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION + """Per-collision-shape material of each rigid body — read/write, GPU. Shape + ``(N, S, 3)``: ``[0]`` static friction, ``[1]`` dynamic friction, ``[2]`` restitution + (all dimensionless).""" +except AttributeError: + pass + """ Dynamics tensors (GPU) """ diff --git a/source/isaaclab_ovphysx/test/assets/test_articulation.py b/source/isaaclab_ovphysx/test/assets/test_articulation.py index 9dff7ae6bca..5adf38ad88d 100644 --- a/source/isaaclab_ovphysx/test/assets/test_articulation.py +++ b/source/isaaclab_ovphysx/test/assets/test_articulation.py @@ -94,14 +94,6 @@ "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 an ``OvPhysxView`` over " - "the per-tensor-type bindings 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 attribute into a torch tensor on *device*. @@ -2326,9 +2318,19 @@ 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.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.""" + """Test getting and setting per-shape material properties (friction/restitution). + + OVPhysX exposes per-collision-shape material as the + ``articulation_shape_friction_and_restitution`` tensor binding (shape ``[N, S, 3]`` = + static friction, dynamic friction, restitution), addressed through the + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`. The binding is device-resident, so the + buffer lives on the simulation device. (The PhysX backend instead uses a dedicated + ``root_view.get_material_properties`` / ``set_material_properties`` view API.) + """ + if not hasattr(TT, "SHAPE_FRICTION_AND_RESTITUTION"): + pytest.skip("ovphysx wheel does not expose the shape material tensor type") + articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) articulation, _ = generate_articulation( articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=device @@ -2337,28 +2339,21 @@ def test_set_material_properties(sim, num_articulations, device, add_ground_plan # Play the simulator sim.reset() - # Get number of shapes from the articulation - max_shapes = articulation.root_view.max_shapes + view = articulation.root_view + # Number of collision shapes per articulation, from the material binding's shape [N, S, 3]. + num_shapes = view.binding_for(TT.SHAPE_FRICTION_AND_RESTITUTION).shape[1] - # 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]) + # Random material per shape: (static_friction, dynamic_friction, restitution), on the sim device. + materials = torch.empty(num_articulations, num_shapes, 3, device=device).uniform_(0.0, 1.0) + materials[..., 1] = torch.min(materials[..., 0], materials[..., 1]) # dynamic <= static - # 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 + # Set material properties through the view, then simulate. + view.set_attribute(TT.SHAPE_FRICTION_AND_RESTITUTION, wp.from_torch(materials, dtype=wp.float32)) 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 + # Read back from the simulation and verify the round-trip. + materials_check = wp.to_torch(view.get_attribute(TT.SHAPE_FRICTION_AND_RESTITUTION)) torch.testing.assert_close(materials_check, materials) From 2ae7eaf09da537c2a7fd4d3cf8a96a952bbcfe43 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Tue, 7 Jul 2026 12:49:51 -0400 Subject: [PATCH 102/139] [IsaacLab CI] include pytest marks in omni tests and additional marks (#6356) # Description Currently, Omni tests test-type are broadly marked only as pytest, which makes it difficult to categorize, filter, and run tests by intent or coverage area. Update Omni tests to include the appropriate pytest marks so they are more discoverable and easier to target in local runs, CI, and reporting. Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. `row["test_type"] = f"{test_type}{','.join(markers)}"` Fixes # ([OMPE-100407](https://jirasw.nvidia.com/browse/OMPE-100407)) ## 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 --------- Signed-off-by: Matthew Taylor --- .../junit_to_omni_github_results.py | 29 +++++++++++++- .../test_junit_to_omni_github_results.py | 39 +++++++++++++++++++ conftest.py | 35 +++++++++++++++++ pyproject.toml | 4 ++ .../changelog.d/intent-test-markers.rst | 8 ++++ .../isaaclab/test/actuators/test_dc_motor.py | 2 + .../test/actuators/test_ideal_pd_actuator.py | 2 + .../test/actuators/test_implicit_actuator.py | 2 + .../test/app/test_argparser_launch.py | 2 + .../isaaclab/test/app/test_env_var_launch.py | 2 + source/isaaclab/test/app/test_kwarg_launch.py | 2 + .../test/app/test_non_headless_launch.py | 2 + .../test/assets/test_articulation_iface.py | 2 + .../test_rigid_object_collection_iface.py | 2 + .../test/assets/test_rigid_object_iface.py | 2 + .../test/benchmark/test_benchmark_core.py | 6 +++ .../isaaclab/test/benchmark/test_recorders.py | 2 + source/isaaclab/test/benchmark/test_schema.py | 2 + source/isaaclab/test/cli/test_install.py | 2 + .../test/cli/test_install_command_parsing.py | 4 ++ .../test/cli/test_install_commands.py | 2 + .../test/cli/test_install_prebundle.py | 4 ++ .../test/cli/test_source_package_metadata.py | 3 ++ .../test/cli/test_uv_run_pyproject.py | 3 ++ .../test/cli/test_wheel_builder_metadata.py | 3 ++ .../test/controllers/test_controller_utils.py | 2 + .../test/controllers/test_differential_ik.py | 2 + .../test_differential_ik_features.py | 2 + .../test/controllers/test_local_frame_task.py | 2 + .../test_null_space_posture_task.py | 2 + .../controllers/test_operational_space.py | 2 + .../isaaclab/test/controllers/test_pink_ik.py | 2 +- .../controllers/test_pink_ik_components.py | 2 +- source/isaaclab/test/deps/test_scipy.py | 2 + source/isaaclab/test/deps/test_torch.py | 2 + .../test/devices/test_device_constructors.py | 2 + .../envs/test_action_state_recorder_term.py | 2 + .../test/envs/test_color_randomization.py | 2 + .../envs/test_diffik_jacobian_aliasing.py | 2 + .../test/envs/test_direct_marl_env.py | 2 + .../test/envs/test_env_destructors.py | 2 + .../test/envs/test_env_rendering_logic.py | 2 +- .../test/envs/test_manager_based_env.py | 2 + .../test_manager_based_rl_env_obs_spaces.py | 2 + .../test/envs/test_manager_based_rl_env_ui.py | 4 ++ .../envs/test_modify_env_param_curr_term.py | 2 + .../test/envs/test_null_command_term.py | 2 + .../test/envs/test_scale_randomization.py | 2 + .../isaaclab/test/envs/test_spaces_utils.py | 3 ++ .../test/envs/test_stacked_image_mdp.py | 2 +- .../envs/test_stacked_image_obs_manager.py | 2 +- .../test/envs/test_texture_randomization.py | 2 + .../isaaclab/test/envs/test_video_recorder.py | 2 +- .../test/managers/test_event_manager.py | 2 + .../test/managers/test_manager_base.py | 2 + .../test/managers/test_observation_manager.py | 2 + .../test/managers/test_recorder_manager.py | 2 + .../test/managers/test_reward_manager.py | 2 + .../test/managers/test_termination_manager.py | 2 + .../markers/test_visualization_markers.py | 2 + .../test_kit_startup_performance.py | 4 ++ .../test_robot_load_performance.py | 2 + .../renderers/test_camera_output_contract.py | 2 + .../test/renderers/test_renderer_factory.py | 2 + .../test_simulation_render_context.py | 2 + .../test/scene/test_interactive_scene.py | 2 + source/isaaclab/test/sensors/test_camera.py | 2 +- .../sensors/test_camera_ppisp_gaussian.py | 2 + .../test_camera_ppisp_gaussian_newton.py | 2 + .../test_camera_ppisp_gaussian_ovrtx.py | 2 + .../test_first_frame_textured_rendering.py | 2 + .../sensors/test_multi_mesh_ray_caster.py | 2 + .../test_multi_mesh_ray_caster_camera.py | 2 + .../test/sensors/test_multi_tiled_camera.py | 2 +- .../test/sensors/test_outdated_sensor.py | 2 + .../isaaclab/test/sensors/test_ray_caster.py | 2 + .../test/sensors/test_ray_caster_camera.py | 2 + .../sensors/test_ray_caster_integration.py | 2 + .../test/sensors/test_ray_caster_kernels.py | 2 + .../test/sensors/test_ray_caster_patterns.py | 2 + .../test/sensors/test_ray_caster_sensor.py | 2 + .../isaaclab/test/sensors/test_sensor_base.py | 2 + .../test/sensors/test_tiled_camera.py | 2 + .../test/sensors/test_tiled_camera_env.py | 2 +- .../sensors/test_update_ray_caster_kernel.py | 2 + .../test_build_simulation_context_headless.py | 2 + ...st_build_simulation_context_nonheadless.py | 2 + source/isaaclab/test/sim/test_cloner.py | 2 +- .../test/sim/test_collision_fragments.py | 4 ++ .../test/sim/test_deformable_backend_split.py | 3 ++ .../test/sim/test_joint_drive_fragments.py | 2 + .../isaaclab/test/sim/test_mass_fragments.py | 2 + .../test/sim/test_material_fragments.py | 2 + .../test/sim/test_mesh_collision_fragments.py | 4 ++ .../isaaclab/test/sim/test_mesh_converter.py | 2 + .../isaaclab/test/sim/test_mjcf_converter.py | 2 +- ...test_newton_manager_visualization_state.py | 4 ++ .../test/sim/test_newton_model_utils.py | 2 + .../test/sim/test_schema_fragments.py | 2 + source/isaaclab/test/sim/test_schemas.py | 2 + source/isaaclab/test/sim/test_schemas_shim.py | 2 + .../isaaclab/test/sim/test_service_locator.py | 2 + .../test/sim/test_simulation_context.py | 2 + .../test_simulation_context_visualizers.py | 2 + .../test/sim/test_simulation_render_config.py | 3 ++ .../sim/test_simulation_stage_in_memory.py | 2 + .../test/sim/test_spawn_from_files.py | 2 + source/isaaclab/test/sim/test_spawn_lights.py | 2 +- .../isaaclab/test/sim/test_spawn_materials.py | 2 +- source/isaaclab/test/sim/test_spawn_meshes.py | 2 +- .../isaaclab/test/sim/test_spawn_sensors.py | 2 +- source/isaaclab/test/sim/test_spawn_shapes.py | 2 +- .../isaaclab/test/sim/test_spawn_wrappers.py | 2 +- .../test/sim/test_tendon_fragments.py | 2 + .../isaaclab/test/sim/test_urdf_converter.py | 2 + source/isaaclab/test/sim/test_utils_prims.py | 2 +- .../isaaclab/test/sim/test_utils_queries.py | 2 + .../isaaclab/test/sim/test_utils_semantics.py | 2 + source/isaaclab/test/sim/test_utils_stage.py | 2 +- .../test/sim/test_utils_transforms.py | 2 + .../test/sim/test_views_xform_prim.py | 2 +- .../test/terrains/test_terrain_generator.py | 2 + .../test/terrains/test_terrain_importer.py | 2 + .../test_mock_interfaces/test_mock_assets.py | 2 + .../test_mock_data_properties.py | 2 + .../test_mock_interfaces/test_mock_sensors.py | 2 + source/isaaclab/test/utils/test_assets.py | 2 + .../test/utils/test_circular_buffer.py | 2 + .../isaaclab/test/utils/test_configclass.py | 2 + .../isaaclab/test/utils/test_delay_buffer.py | 2 + source/isaaclab/test/utils/test_dict.py | 2 + .../isaaclab/test/utils/test_episode_data.py | 2 + .../utils/test_hdf5_dataset_file_handler.py | 2 + source/isaaclab/test/utils/test_images.py | 2 + source/isaaclab/test/utils/test_logger.py | 2 + source/isaaclab/test/utils/test_math.py | 2 + source/isaaclab/test/utils/test_modifiers.py | 2 + source/isaaclab/test/utils/test_noise.py | 2 + source/isaaclab/test/utils/test_string.py | 2 + source/isaaclab/test/utils/test_timer.py | 2 + source/isaaclab/test/utils/test_version.py | 2 + .../test/utils/test_wrench_composer.py | 2 + .../utils/test_wrench_composer_integration.py | 2 + .../utils/test_wrench_composer_vs_physx.py | 2 + .../test/utils/warp/test_image_ops.py | 2 + .../test/utils/warp/test_particle_mesh.py | 2 + .../test/utils/warp/test_proxy_array.py | 2 + .../test/visualizers/test_visualizer.py | 2 + 148 files changed, 404 insertions(+), 21 deletions(-) create mode 100644 conftest.py create mode 100644 source/isaaclab/changelog.d/intent-test-markers.rst diff --git a/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py b/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py index 03a81733bfa..8fc15431826 100644 --- a/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py +++ b/.github/actions/upload-omni-github-test-results/junit_to_omni_github_results.py @@ -54,6 +54,28 @@ def _test_id(testcase: ET.Element) -> str: return "::".join(part for part in parts if part) or "unknown-testcase" +def _testcase_markers(testcase: ET.Element) -> list[str]: + """Return the intent markers recorded on a testcase, in stable order. + + pytest serializes ``item.user_properties`` as ```` elements under a + ```` child of the testcase. The repo-root ``conftest.py`` records the + auto-applied intent markers there under the ``markers`` property as a comma-separated + string. Missing or empty values yield an empty list. + """ + properties = _first_child(testcase, {"properties"}) + if properties is None: + return [] + markers: list[str] = [] + for prop in properties: + if _local_name(prop.tag) != "property" or prop.attrib.get("name") != "markers": + continue + for value in prop.attrib.get("value", "").split(","): + value = value.strip() + if value: + markers.append(value) + return markers + + def _short_message(element: ET.Element | None) -> str | None: """Return a compact message from a failure, error, or skip element.""" if element is None: @@ -83,7 +105,6 @@ def _convert_testcase( "test_id": _test_id(testcase), "passed": failure_or_error is None and skipped is None, "duration": _duration_seconds(testcase), - "test_type": test_type, "group_id": group_id, "retries": retries, "log_paths": log_paths, @@ -106,6 +127,12 @@ def _convert_testcase( if any(pattern in detail_text for pattern in patterns): row[flag] = True + markers = _testcase_markers(testcase) + if markers: + row["test_type"] = ",".join([test_type, *markers]) + else: + row["test_type"] = test_type + return row diff --git a/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py b/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py index e25ce26d3a1..73dd3cf36a0 100644 --- a/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py +++ b/.github/actions/upload-omni-github-test-results/test_junit_to_omni_github_results.py @@ -167,3 +167,42 @@ def test_convert_junit_adds_log_paths_for_junit_and_comparison_artifacts(tmp_pat junit_log_url, comparison_images_url, ] + + +def test_convert_junit_appends_markers_to_test_type_with_separator(tmp_path: Path) -> None: + """Recorded intent markers should append to the base test_type with comma separators.""" + converter = _load_converter_module() + junit_file = tmp_path / "report.xml" + output_dir = tmp_path / "out" + junit_file.write_text( + """ + + + + + + + + +""", + encoding="utf-8", + ) + + converter.convert_junit( + junit_file=junit_file, + output_dir=output_dir, + test_tool_id="pytest", + test_type="pytest", + app_platform="linux-x86_64", + app_config="test-job", + group_name="Docker + Tests / isaaclab", + junit_log_url="", + comparison_images_url="", + retries=0, + ) + + marked, unmarked = _load_rows(output_dir) + # The base type must be separated from the first marker, not fused into "pytestintegration". + assert marked["test_type"] == "pytest,integration,rendering" + # Testcases without markers keep the bare base type. + assert unmarked["test_type"] == "pytest" diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000000..621bd8e56db --- /dev/null +++ b/conftest.py @@ -0,0 +1,35 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Repo-root pytest configuration shared by every ``source/`` and ``scripts/`` test. + +Collects the markers applied to each test and records them in the JUnit XML report so that CI +can carry them into the uploaded test artifact. + +Level markers (``unit`` / ``integration`` / ``benchmark``) are applied per file via a module-level ``pytestmark`` +and registered in the repo-root ``pyproject.toml``. Select them with the standard ``-m`` syntax, +e.g. ``pytest -m unit source/isaaclab/test`` or ``pytest -m "not unit" source/isaaclab/test``. +""" + +from __future__ import annotations + + +def pytest_collection_modifyitems(config, items): + """Record each collected test's registered markers for the JUnit report. + + Each item's marker set (taken from its ``@pytest.mark`` annotations) is recorded + as a ``user_properties`` entry so it is emitted into the JUnit XML report as + ````. CI's ``upload-omni-github-test-results`` + action reads that property and carries the markers into the uploaded test artifact. + + Only markers registered in the repo-root ``pyproject.toml`` (e.g. ``unit``, + ``integration``, ``benchmark``, ``rendering``) are recorded; pytest's built-in structural marks + (``parametrize``, ``skip``, ``usefixtures``, ...) are excluded so they do not leak + into the artifact's ``test_type`` field. + """ + registered = {entry.split(":", 1)[0].strip() for entry in config.getini("markers")} + for item in items: + markers = {mark.name for mark in item.iter_markers() if mark.name in registered} + item.user_properties.append(("markers", ",".join(sorted(markers)))) diff --git a/pyproject.toml b/pyproject.toml index caf6b793a9f..6e45542c6c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,6 +199,10 @@ markers = [ "device_split: re-invoke this file once per device (CPU and GPU) in CI due to process-global device locks (e.g., ovphysx<=0.3.7 gap G5)", "windows_ci: mark test to run on Windows platforms in CI", "arm_ci: mark test to run on ARM platforms in CI (e.g. NVIDIA DGX Spark)", + "unit: test exercises isolated logic and does not launch the simulator", + "integration: test drives the simulator/scene/environment end-to-end", + "benchmark: test covers the Isaac Lab benchmark framework and infrastructure", + "rendering: test exercises the rendering / camera / visualizer pipeline", "smoke: tests for core installation, task, and RL functionality", ] diff --git a/source/isaaclab/changelog.d/intent-test-markers.rst b/source/isaaclab/changelog.d/intent-test-markers.rst new file mode 100644 index 00000000000..c4a8794af32 --- /dev/null +++ b/source/isaaclab/changelog.d/intent-test-markers.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added pytest *level* markers (``unit`` and ``integration``) and a ``rendering`` *flavor* marker + registered in the repository-root ``pyproject.toml`` and applied per file via a module-level + ``pytestmark``, so tests can be filtered by kind (e.g. ``pytest -m unit``, ``pytest -m "not unit"``, + or ``pytest -m "integration and not rendering"``). The repository-root ``conftest.py`` records + each test's markers into the JUnit XML report for CI to categorize uploaded results. diff --git a/source/isaaclab/test/actuators/test_dc_motor.py b/source/isaaclab/test/actuators/test_dc_motor.py index 81682cc5d22..a3efc49b3ca 100644 --- a/source/isaaclab/test/actuators/test_dc_motor.py +++ b/source/isaaclab/test/actuators/test_dc_motor.py @@ -8,6 +8,8 @@ from isaaclab.actuators import DCMotorCfg +pytestmark = pytest.mark.integration + @pytest.mark.parametrize("num_envs", [1, 2]) @pytest.mark.parametrize("num_joints", [1, 2]) diff --git a/source/isaaclab/test/actuators/test_ideal_pd_actuator.py b/source/isaaclab/test/actuators/test_ideal_pd_actuator.py index 5fc3a675574..01f19007ae9 100644 --- a/source/isaaclab/test/actuators/test_ideal_pd_actuator.py +++ b/source/isaaclab/test/actuators/test_ideal_pd_actuator.py @@ -9,6 +9,8 @@ from isaaclab.actuators import IdealPDActuatorCfg from isaaclab.utils.types import ArticulationActions +pytestmark = pytest.mark.integration + @pytest.mark.parametrize("num_envs", [1, 2]) @pytest.mark.parametrize("num_joints", [1, 2]) diff --git a/source/isaaclab/test/actuators/test_implicit_actuator.py b/source/isaaclab/test/actuators/test_implicit_actuator.py index c4a26f2f953..860e53765bc 100644 --- a/source/isaaclab/test/actuators/test_implicit_actuator.py +++ b/source/isaaclab/test/actuators/test_implicit_actuator.py @@ -18,6 +18,8 @@ from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.sim import build_simulation_context +pytestmark = pytest.mark.integration + @pytest.fixture def sim(request): diff --git a/source/isaaclab/test/app/test_argparser_launch.py b/source/isaaclab/test/app/test_argparser_launch.py index 6bce001522b..21c75d24dac 100644 --- a/source/isaaclab/test/app/test_argparser_launch.py +++ b/source/isaaclab/test/app/test_argparser_launch.py @@ -9,6 +9,8 @@ from isaaclab.app import AppLauncher +pytestmark = pytest.mark.integration + @pytest.mark.usefixtures("mocker") def test_livestream_launch_with_argparser(mocker): diff --git a/source/isaaclab/test/app/test_env_var_launch.py b/source/isaaclab/test/app/test_env_var_launch.py index 6a58b220692..750723f0672 100644 --- a/source/isaaclab/test/app/test_env_var_launch.py +++ b/source/isaaclab/test/app/test_env_var_launch.py @@ -9,6 +9,8 @@ from isaaclab.app import AppLauncher +pytestmark = pytest.mark.integration + @pytest.mark.usefixtures("mocker") def test_livestream_launch_with_env_vars(mocker): diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index 8c27e9ad437..1e3782840a8 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -15,6 +15,8 @@ from isaaclab.app import AppLauncher from isaaclab.app.sim_launcher import _ensure_livestream_kit_visualizer +pytestmark = pytest.mark.integration + @pytest.mark.usefixtures("mocker") def test_livestream_launch_with_kwargs(mocker): diff --git a/source/isaaclab/test/app/test_non_headless_launch.py b/source/isaaclab/test/app/test_non_headless_launch.py index 5c7b5bad24c..0dab61f16e7 100644 --- a/source/isaaclab/test/app/test_non_headless_launch.py +++ b/source/isaaclab/test/app/test_non_headless_launch.py @@ -26,6 +26,8 @@ from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + @configclass class SensorsSceneCfg(InteractiveSceneCfg): diff --git a/source/isaaclab/test/assets/test_articulation_iface.py b/source/isaaclab/test/assets/test_articulation_iface.py index 989cfa4667f..4f9e072cb75 100644 --- a/source/isaaclab/test/assets/test_articulation_iface.py +++ b/source/isaaclab/test/assets/test_articulation_iface.py @@ -67,6 +67,8 @@ from isaaclab_physx.physics import PhysxManager as SimulationManager +pytestmark = pytest.mark.integration + SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) """ 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 ee5f24df8d2..05ed5675533 100644 --- a/source/isaaclab/test/assets/test_rigid_object_collection_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_collection_iface.py @@ -20,6 +20,8 @@ import warp as wp from _rigid_object_collection_iface_test_utils import BACKENDS, get_rigid_object_collection +pytestmark = pytest.mark.integration + @pytest.fixture def collection_iface(request): diff --git a/source/isaaclab/test/assets/test_rigid_object_iface.py b/source/isaaclab/test/assets/test_rigid_object_iface.py index c934def4bf3..ddfc3d212ad 100644 --- a/source/isaaclab/test/assets/test_rigid_object_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_iface.py @@ -19,6 +19,8 @@ import warp as wp from _rigid_object_iface_test_utils import BACKENDS, get_rigid_object +pytestmark = pytest.mark.integration + @pytest.fixture def rigid_object_iface(request): diff --git a/source/isaaclab/test/benchmark/test_benchmark_core.py b/source/isaaclab/test/benchmark/test_benchmark_core.py index 03bba5bcb0e..10094f2da78 100644 --- a/source/isaaclab/test/benchmark/test_benchmark_core.py +++ b/source/isaaclab/test/benchmark/test_benchmark_core.py @@ -15,6 +15,12 @@ from isaaclab.test.benchmark.benchmark_core import BaseIsaacLabBenchmark from isaaclab.test.benchmark.measurements import SingleMeasurement, StringMetadata +pytestmark = pytest.mark.benchmark + +# ============================================================================== +# BaseIsaacLabBenchmark Tests +# ============================================================================== + @pytest.fixture(autouse=True) def reset_formatters(): diff --git a/source/isaaclab/test/benchmark/test_recorders.py b/source/isaaclab/test/benchmark/test_recorders.py index 003cfc66fbf..300e743bf92 100644 --- a/source/isaaclab/test/benchmark/test_recorders.py +++ b/source/isaaclab/test/benchmark/test_recorders.py @@ -17,6 +17,8 @@ from isaaclab.test.benchmark.recorders.record_memory_info import MemoryInfoRecorder from isaaclab.test.benchmark.recorders.record_version_info import VersionInfoRecorder +pytestmark = pytest.mark.benchmark + # ============================================================================== # CPUInfoRecorder Tests # ============================================================================== diff --git a/source/isaaclab/test/benchmark/test_schema.py b/source/isaaclab/test/benchmark/test_schema.py index 862d2d80d7c..fe028f75598 100644 --- a/source/isaaclab/test/benchmark/test_schema.py +++ b/source/isaaclab/test/benchmark/test_schema.py @@ -33,6 +33,8 @@ ) from isaaclab.test.benchmark.serialize import write_bundle_file +pytestmark = pytest.mark.benchmark + def _versions() -> Versions: return Versions( diff --git a/source/isaaclab/test/cli/test_install.py b/source/isaaclab/test/cli/test_install.py index 9be554c9b11..45444ce7531 100644 --- a/source/isaaclab/test/cli/test_install.py +++ b/source/isaaclab/test/cli/test_install.py @@ -20,6 +20,8 @@ get_pip_command, ) +pytestmark = pytest.mark.unit + def _python_in_venv(venv: Path) -> Path: if sys.platform == "win32": diff --git a/source/isaaclab/test/cli/test_install_command_parsing.py b/source/isaaclab/test/cli/test_install_command_parsing.py index 27281ec013f..2db7370a433 100644 --- a/source/isaaclab/test/cli/test_install_command_parsing.py +++ b/source/isaaclab/test/cli/test_install_command_parsing.py @@ -15,6 +15,8 @@ import os from unittest.mock import patch +import pytest + from isaaclab.cli.commands.install import ( CORE_ISAACLAB_SUBMODULES, MANUAL_EXTRA_FEATURES, @@ -24,6 +26,8 @@ split_install_items, ) +pytestmark = pytest.mark.unit + def _optional_submodule_packages() -> list[str]: """Return flattened optional submodule source package names.""" diff --git a/source/isaaclab/test/cli/test_install_commands.py b/source/isaaclab/test/cli/test_install_commands.py index af1ba751c2a..4c56de6a70b 100644 --- a/source/isaaclab/test/cli/test_install_commands.py +++ b/source/isaaclab/test/cli/test_install_commands.py @@ -26,6 +26,8 @@ _torch_first_on_sys_path_is_prebundle, ) +pytestmark = pytest.mark.unit + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/source/isaaclab/test/cli/test_install_prebundle.py b/source/isaaclab/test/cli/test_install_prebundle.py index 021a247a8e2..b0b21f58849 100644 --- a/source/isaaclab/test/cli/test_install_prebundle.py +++ b/source/isaaclab/test/cli/test_install_prebundle.py @@ -11,11 +11,15 @@ from unittest import mock +import pytest + from isaaclab.cli.commands.install import ( _torch_first_on_sys_path_is_prebundle, split_install_items, ) +pytestmark = pytest.mark.unit + # --------------------------------------------------------------------------- # split_install_items # --------------------------------------------------------------------------- diff --git a/source/isaaclab/test/cli/test_source_package_metadata.py b/source/isaaclab/test/cli/test_source_package_metadata.py index ceeec3e6d2b..138d82211d4 100644 --- a/source/isaaclab/test/cli/test_source_package_metadata.py +++ b/source/isaaclab/test/cli/test_source_package_metadata.py @@ -9,8 +9,11 @@ from pathlib import Path +import pytest import tomllib +pytestmark = pytest.mark.unit + def _repo_root() -> Path: """Find the Isaac Lab repository root from this test file.""" diff --git a/source/isaaclab/test/cli/test_uv_run_pyproject.py b/source/isaaclab/test/cli/test_uv_run_pyproject.py index ab9194d3f9e..e208fb9e2a8 100644 --- a/source/isaaclab/test/cli/test_uv_run_pyproject.py +++ b/source/isaaclab/test/cli/test_uv_run_pyproject.py @@ -10,8 +10,11 @@ import re from pathlib import Path +import pytest import tomllib +pytestmark = pytest.mark.unit + def _repo_root() -> Path: """Find the Isaac Lab repository root from this test file.""" diff --git a/source/isaaclab/test/cli/test_wheel_builder_metadata.py b/source/isaaclab/test/cli/test_wheel_builder_metadata.py index c04db628aa7..055d2498732 100644 --- a/source/isaaclab/test/cli/test_wheel_builder_metadata.py +++ b/source/isaaclab/test/cli/test_wheel_builder_metadata.py @@ -9,8 +9,11 @@ from pathlib import Path +import pytest import tomllib +pytestmark = pytest.mark.unit + def _repo_root() -> Path: """Find the Isaac Lab repository root from this test file.""" diff --git a/source/isaaclab/test/controllers/test_controller_utils.py b/source/isaaclab/test/controllers/test_controller_utils.py index 5c33ce7d818..0ec8b0c4532 100644 --- a/source/isaaclab/test/controllers/test_controller_utils.py +++ b/source/isaaclab/test/controllers/test_controller_utils.py @@ -29,6 +29,8 @@ from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, retrieve_file_path from isaaclab.utils.io.torchscript import load_torchscript_model +pytestmark = pytest.mark.integration + @pytest.fixture def mock_urdf_content(): diff --git a/source/isaaclab/test/controllers/test_differential_ik.py b/source/isaaclab/test/controllers/test_differential_ik.py index 2ba7af0ec02..ae02894aaba 100644 --- a/source/isaaclab/test/controllers/test_differential_ik.py +++ b/source/isaaclab/test/controllers/test_differential_ik.py @@ -33,6 +33,8 @@ ## from isaaclab_assets import FRANKA_PANDA_HIGH_PD_CFG, UR10_CFG # isort:skip +pytestmark = pytest.mark.integration + @pytest.fixture def sim(): diff --git a/source/isaaclab/test/controllers/test_differential_ik_features.py b/source/isaaclab/test/controllers/test_differential_ik_features.py index 40ce0dd3d5c..e4ac4a041ef 100644 --- a/source/isaaclab/test/controllers/test_differential_ik_features.py +++ b/source/isaaclab/test/controllers/test_differential_ik_features.py @@ -18,6 +18,8 @@ from isaaclab.controllers.differential_ik import DifferentialIKController from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg +pytestmark = pytest.mark.integration + _NUM_JOINTS = 5 _ID_QUAT = [0.0, 0.0, 0.0, 1.0] # xyzw identity diff --git a/source/isaaclab/test/controllers/test_local_frame_task.py b/source/isaaclab/test/controllers/test_local_frame_task.py index 524b68f83c8..f96269c1e56 100644 --- a/source/isaaclab/test/controllers/test_local_frame_task.py +++ b/source/isaaclab/test/controllers/test_local_frame_task.py @@ -14,6 +14,8 @@ from isaaclab.controllers.pink_ik.pink_kinematics_configuration import PinkKinematicsConfiguration from isaaclab.controllers.pink_ik.pink_tasks import LocalFrameTask +pytestmark = pytest.mark.integration + # class TestLocalFrameTask: # """Test suite for LocalFrameTask class.""" 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 fa78704bb60..b6e84621380 100644 --- a/source/isaaclab/test/controllers/test_null_space_posture_task.py +++ b/source/isaaclab/test/controllers/test_null_space_posture_task.py @@ -14,6 +14,8 @@ from isaaclab.controllers.pink_ik.null_space_posture_task import NullSpacePostureTask from isaaclab.controllers.pink_ik.pink_task_cfg import NullSpacePostureTaskCfg +pytestmark = pytest.mark.integration + class TestNullSpacePostureTaskSimplifiedRobot: """Test cases for NullSpacePostureTask with simplified robot configuration.""" diff --git a/source/isaaclab/test/controllers/test_operational_space.py b/source/isaaclab/test/controllers/test_operational_space.py index bed0760271e..75e3810fab1 100644 --- a/source/isaaclab/test/controllers/test_operational_space.py +++ b/source/isaaclab/test/controllers/test_operational_space.py @@ -49,6 +49,8 @@ from isaaclab_assets import FRANKA_PANDA_CFG, G1_29DOF_CFG # isort:skip +pytestmark = pytest.mark.integration + @pytest.fixture def sim(): diff --git a/source/isaaclab/test/controllers/test_pink_ik.py b/source/isaaclab/test/controllers/test_pink_ik.py index 70423ebabe7..a7935875fa7 100644 --- a/source/isaaclab/test/controllers/test_pink_ik.py +++ b/source/isaaclab/test/controllers/test_pink_ik.py @@ -30,7 +30,7 @@ import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils.parse_cfg import parse_env_cfg -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] def load_test_config(env_name): diff --git a/source/isaaclab/test/controllers/test_pink_ik_components.py b/source/isaaclab/test/controllers/test_pink_ik_components.py index ea5cf8ae344..4f1b25146b8 100644 --- a/source/isaaclab/test/controllers/test_pink_ik_components.py +++ b/source/isaaclab/test/controllers/test_pink_ik_components.py @@ -14,7 +14,7 @@ from isaaclab.controllers.pink_ik.pink_kinematics_configuration import PinkKinematicsConfiguration -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] class TestPinkKinematicsConfiguration: diff --git a/source/isaaclab/test/deps/test_scipy.py b/source/isaaclab/test/deps/test_scipy.py index d697716aad7..91d47a5c9e6 100644 --- a/source/isaaclab/test/deps/test_scipy.py +++ b/source/isaaclab/test/deps/test_scipy.py @@ -13,6 +13,8 @@ import numpy as np import scipy.interpolate as interpolate +pytestmark = pytest.mark.unit + @pytest.mark.isaacsim_ci def test_interpolation(): diff --git a/source/isaaclab/test/deps/test_torch.py b/source/isaaclab/test/deps/test_torch.py index 6a50110757d..2d4881346f7 100644 --- a/source/isaaclab/test/deps/test_torch.py +++ b/source/isaaclab/test/deps/test_torch.py @@ -7,6 +7,8 @@ import torch import torch.utils.benchmark as benchmark +pytestmark = pytest.mark.unit + @pytest.mark.isaacsim_ci def test_array_slicing(): diff --git a/source/isaaclab/test/devices/test_device_constructors.py b/source/isaaclab/test/devices/test_device_constructors.py index 6c939503fce..a816013b2f4 100644 --- a/source/isaaclab/test/devices/test_device_constructors.py +++ b/source/isaaclab/test/devices/test_device_constructors.py @@ -27,6 +27,8 @@ Se3SpaceMouseCfg, ) +pytestmark = pytest.mark.integration + @pytest.fixture def mock_environment(mocker): diff --git a/source/isaaclab/test/envs/test_action_state_recorder_term.py b/source/isaaclab/test/envs/test_action_state_recorder_term.py index 2e269c55212..4eca8bfcd47 100644 --- a/source/isaaclab/test/envs/test_action_state_recorder_term.py +++ b/source/isaaclab/test/envs/test_action_state_recorder_term.py @@ -27,6 +27,8 @@ import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils.parse_cfg import parse_env_cfg +pytestmark = pytest.mark.integration + @pytest.fixture(scope="session", autouse=True) def setup_carb_settings(): diff --git a/source/isaaclab/test/envs/test_color_randomization.py b/source/isaaclab/test/envs/test_color_randomization.py index cb0bf609ffb..30ad1d447f6 100644 --- a/source/isaaclab/test/envs/test_color_randomization.py +++ b/source/isaaclab/test/envs/test_color_randomization.py @@ -34,6 +34,8 @@ from isaaclab_tasks.core.cartpole.cartpole_manager_env_cfg import CartpoleSceneCfg +pytestmark = pytest.mark.integration + @configclass class ActionsCfg: diff --git a/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py b/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py index 084ea1a47bc..86e287cb952 100644 --- a/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py +++ b/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py @@ -21,6 +21,8 @@ from isaaclab.envs.mdp.actions.task_space_actions import DifferentialInverseKinematicsAction from isaaclab.utils import math as math_utils +pytestmark = pytest.mark.unit + class _Stub: """Minimal stand-in for ``DifferentialInverseKinematicsAction`` that exposes only diff --git a/source/isaaclab/test/envs/test_direct_marl_env.py b/source/isaaclab/test/envs/test_direct_marl_env.py index 7454f21c876..a0fa14b3eaf 100644 --- a/source/isaaclab/test/envs/test_direct_marl_env.py +++ b/source/isaaclab/test/envs/test_direct_marl_env.py @@ -24,6 +24,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + @configclass class EmptySceneCfg(InteractiveSceneCfg): diff --git a/source/isaaclab/test/envs/test_env_destructors.py b/source/isaaclab/test/envs/test_env_destructors.py index 60e0bedcd28..cb96b9b3874 100644 --- a/source/isaaclab/test/envs/test_env_destructors.py +++ b/source/isaaclab/test/envs/test_env_destructors.py @@ -13,6 +13,8 @@ from isaaclab.envs import DirectMARLEnv, DirectRLEnv, ManagerBasedEnv from isaaclab.envs.utils.marl import multi_agent_to_single_agent, multi_agent_with_one_agent +pytestmark = pytest.mark.unit + class _FakeMultiAgentEnv: possible_agents = ["agent_0", "agent_1"] diff --git a/source/isaaclab/test/envs/test_env_rendering_logic.py b/source/isaaclab/test/envs/test_env_rendering_logic.py index ca05ef2f2e2..c3ff82d1883 100644 --- a/source/isaaclab/test/envs/test_env_rendering_logic.py +++ b/source/isaaclab/test/envs/test_env_rendering_logic.py @@ -31,7 +31,7 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.configclass import configclass -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.rendering, pytest.mark.isaacsim_ci] @configclass diff --git a/source/isaaclab/test/envs/test_manager_based_env.py b/source/isaaclab/test/envs/test_manager_based_env.py index aede8915b58..3e5cf4c32a6 100644 --- a/source/isaaclab/test/envs/test_manager_based_env.py +++ b/source/isaaclab/test/envs/test_manager_based_env.py @@ -27,6 +27,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + @configclass class EmptyManagerCfg: diff --git a/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py b/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py index 308978077c2..11c756f03be 100644 --- a/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py +++ b/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces.py @@ -23,6 +23,8 @@ from isaaclab_tasks.core.cartpole.cartpole_manager_camera_env_cfg import CartpoleCameraEnvCfg from isaaclab_tasks.core.cartpole.cartpole_manager_env_cfg import CartpoleEnvCfg +pytestmark = pytest.mark.integration + @pytest.mark.parametrize("device", ["cpu", "cuda"]) def test_non_concatenated_obs_groups_contain_all_terms(device): 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 678e28ffded..ed95c94a02f 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 @@ -16,6 +16,8 @@ """Rest everything follows.""" +import pytest + from isaacsim.core.experimental.utils.app import enable_extension import isaaclab.sim as sim_utils @@ -24,6 +26,8 @@ from isaaclab.scene import InteractiveSceneCfg from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + 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 add28d254f8..d4b4c129766 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 @@ -22,6 +22,8 @@ from isaaclab_tasks.core.cartpole.cartpole_manager_env_cfg import CartpoleEnvCfg +pytestmark = pytest.mark.integration + def replace_value(env, env_id, data, value, num_steps): if env.common_step_counter > num_steps and data != value: diff --git a/source/isaaclab/test/envs/test_null_command_term.py b/source/isaaclab/test/envs/test_null_command_term.py index 48b48d09a43..a6233ab252c 100644 --- a/source/isaaclab/test/envs/test_null_command_term.py +++ b/source/isaaclab/test/envs/test_null_command_term.py @@ -9,6 +9,8 @@ from isaaclab.envs.mdp import NullCommandCfg +pytestmark = pytest.mark.unit + @pytest.fixture def env(): diff --git a/source/isaaclab/test/envs/test_scale_randomization.py b/source/isaaclab/test/envs/test_scale_randomization.py index 1104425b4b1..997b6f985f6 100644 --- a/source/isaaclab/test/envs/test_scale_randomization.py +++ b/source/isaaclab/test/envs/test_scale_randomization.py @@ -37,6 +37,8 @@ from isaaclab.terrains import TerrainImporterCfg from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + ## # Custom action term ## diff --git a/source/isaaclab/test/envs/test_spaces_utils.py b/source/isaaclab/test/envs/test_spaces_utils.py index 57f64770b80..f6ff45091f9 100644 --- a/source/isaaclab/test/envs/test_spaces_utils.py +++ b/source/isaaclab/test/envs/test_spaces_utils.py @@ -9,6 +9,7 @@ from __future__ import annotations import numpy as np +import pytest import torch from gymnasium.spaces import Box, Dict, Discrete, MultiDiscrete, Tuple @@ -20,6 +21,8 @@ spec_to_gym_space, ) +pytestmark = pytest.mark.unit + def test_spec_to_gym_space(): """Test conversion of specs to gym spaces.""" diff --git a/source/isaaclab/test/envs/test_stacked_image_mdp.py b/source/isaaclab/test/envs/test_stacked_image_mdp.py index 7fd5a6f0b0e..60181099bce 100644 --- a/source/isaaclab/test/envs/test_stacked_image_mdp.py +++ b/source/isaaclab/test/envs/test_stacked_image_mdp.py @@ -17,7 +17,7 @@ import pytest import torch -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] from isaaclab.envs.mdp.observations import stacked_image diff --git a/source/isaaclab/test/envs/test_stacked_image_obs_manager.py b/source/isaaclab/test/envs/test_stacked_image_obs_manager.py index 840a6395478..dec8843dd02 100644 --- a/source/isaaclab/test/envs/test_stacked_image_obs_manager.py +++ b/source/isaaclab/test/envs/test_stacked_image_obs_manager.py @@ -31,7 +31,7 @@ from isaaclab.managers import ObservationGroupCfg, ObservationManager, ObservationTermCfg from isaaclab.utils import configclass -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] NUM_ENVS = 4 HEIGHT = 8 diff --git a/source/isaaclab/test/envs/test_texture_randomization.py b/source/isaaclab/test/envs/test_texture_randomization.py index c84bc8e4f4e..83208117f01 100644 --- a/source/isaaclab/test/envs/test_texture_randomization.py +++ b/source/isaaclab/test/envs/test_texture_randomization.py @@ -34,6 +34,8 @@ from isaaclab_tasks.core.cartpole.cartpole_manager_env_cfg import CartpoleSceneCfg +pytestmark = pytest.mark.integration + @configclass class ActionsCfg: diff --git a/source/isaaclab/test/envs/test_video_recorder.py b/source/isaaclab/test/envs/test_video_recorder.py index 4a76036bf9b..5bc92d54b72 100644 --- a/source/isaaclab/test/envs/test_video_recorder.py +++ b/source/isaaclab/test/envs/test_video_recorder.py @@ -14,7 +14,7 @@ from isaaclab.envs.utils.video_recorder import VideoRecorder, _select_video_backend -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] _FRAME = np.zeros((8, 12, 3), dtype=np.uint8) _DEFAULT_CFG = { "env_render_mode": "rgb_array", diff --git a/source/isaaclab/test/managers/test_event_manager.py b/source/isaaclab/test/managers/test_event_manager.py index e716de2f0ba..cd17912515c 100644 --- a/source/isaaclab/test/managers/test_event_manager.py +++ b/source/isaaclab/test/managers/test_event_manager.py @@ -28,6 +28,8 @@ from isaaclab.sim import SimulationContext from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + DummyEnv = namedtuple("ManagerBasedRLEnv", ["num_envs", "dt", "device", "sim", "dummy1", "dummy2"]) """Dummy environment for testing.""" diff --git a/source/isaaclab/test/managers/test_manager_base.py b/source/isaaclab/test/managers/test_manager_base.py index 87d068a300d..74c97639919 100644 --- a/source/isaaclab/test/managers/test_manager_base.py +++ b/source/isaaclab/test/managers/test_manager_base.py @@ -23,6 +23,8 @@ from isaaclab.managers import ManagerTermBase, ManagerTermBaseCfg from isaaclab.managers.manager_base import ManagerBase +pytestmark = pytest.mark.integration + 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 e821fafe23c..7226029c9ce 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -32,6 +32,8 @@ from isaaclab.utils import modifiers from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + 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 443fe50fb64..b7787b04995 100644 --- a/source/isaaclab/test/managers/test_recorder_manager.py +++ b/source/isaaclab/test/managers/test_recorder_manager.py @@ -33,6 +33,8 @@ from isaaclab.sim import SimulationContext from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + 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 92554292e6f..6038404a759 100644 --- a/source/isaaclab/test/managers/test_reward_manager.py +++ b/source/isaaclab/test/managers/test_reward_manager.py @@ -21,6 +21,8 @@ from isaaclab.sim import SimulationContext from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + def grilled_chicken(env): return 1 diff --git a/source/isaaclab/test/managers/test_termination_manager.py b/source/isaaclab/test/managers/test_termination_manager.py index db96e93675c..095a95d69db 100644 --- a/source/isaaclab/test/managers/test_termination_manager.py +++ b/source/isaaclab/test/managers/test_termination_manager.py @@ -18,6 +18,8 @@ from isaaclab.managers import TerminationManager, TerminationTermCfg from isaaclab.sim import SimulationContext +pytestmark = pytest.mark.integration + class DummyEnv: """Minimal mutable env stub for the termination manager tests.""" diff --git a/source/isaaclab/test/markers/test_visualization_markers.py b/source/isaaclab/test/markers/test_visualization_markers.py index 63693a160f5..7fdcde236ad 100644 --- a/source/isaaclab/test/markers/test_visualization_markers.py +++ b/source/isaaclab/test/markers/test_visualization_markers.py @@ -32,6 +32,8 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.math import random_orientation +pytestmark = pytest.mark.integration + @pytest.fixture def sim(): diff --git a/source/isaaclab/test/performance/test_kit_startup_performance.py b/source/isaaclab/test/performance/test_kit_startup_performance.py index 7607f30b792..e8d1de4aae4 100644 --- a/source/isaaclab/test/performance/test_kit_startup_performance.py +++ b/source/isaaclab/test/performance/test_kit_startup_performance.py @@ -11,8 +11,12 @@ import os import time +import pytest + from isaaclab.app import AppLauncher +pytestmark = pytest.mark.integration + _LOCAL_STARTUP_TIME_LIMIT = 15.0 _CI_STARTUP_TIME_LIMIT = 20.0 diff --git a/source/isaaclab/test/performance/test_robot_load_performance.py b/source/isaaclab/test/performance/test_robot_load_performance.py index a298c8b746e..ee2ac17158c 100644 --- a/source/isaaclab/test/performance/test_robot_load_performance.py +++ b/source/isaaclab/test/performance/test_robot_load_performance.py @@ -25,6 +25,8 @@ from isaaclab_assets import ANYMAL_D_CFG, CARTPOLE_CFG +pytestmark = pytest.mark.integration + NUM_ENVS = 4096 SPACING = 2.0 diff --git a/source/isaaclab/test/renderers/test_camera_output_contract.py b/source/isaaclab/test/renderers/test_camera_output_contract.py index 776f40288c4..0f88dd3e289 100644 --- a/source/isaaclab/test/renderers/test_camera_output_contract.py +++ b/source/isaaclab/test/renderers/test_camera_output_contract.py @@ -17,6 +17,8 @@ from isaaclab.sensors.camera.camera_data import CameraData, RenderBufferKind, RenderBufferSpec from isaaclab.sim import PinholeCameraCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + _SPAWN = PinholeCameraCfg( focal_length=24.0, focus_distance=400.0, diff --git a/source/isaaclab/test/renderers/test_renderer_factory.py b/source/isaaclab/test/renderers/test_renderer_factory.py index 66bd76fba59..91095d61c26 100644 --- a/source/isaaclab/test/renderers/test_renderer_factory.py +++ b/source/isaaclab/test/renderers/test_renderer_factory.py @@ -20,6 +20,8 @@ from isaaclab_ov.renderers import OVRTXRendererCfg from isaaclab_physx.renderers import IsaacRtxRendererCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + def _make_mock_renderer_class(name: str): """Create a minimal concrete BaseRenderer subclass for testing.""" diff --git a/source/isaaclab/test/renderers/test_simulation_render_context.py b/source/isaaclab/test/renderers/test_simulation_render_context.py index 4b32544eab9..147398580f0 100644 --- a/source/isaaclab/test/renderers/test_simulation_render_context.py +++ b/source/isaaclab/test/renderers/test_simulation_render_context.py @@ -26,6 +26,8 @@ from isaaclab_newton.renderers import NewtonWarpRendererCfg from isaaclab_physx.renderers import IsaacRtxRendererCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + class _FakeBackend(BaseRenderer): """Test double for :class:`BaseRenderer`; does not load PhysX/Newton/OV renderer classes.""" diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index a2ddc05f5ea..41aaaf7f9c3 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -27,6 +27,8 @@ from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + @configclass class MySceneCfg(InteractiveSceneCfg): diff --git a/source/isaaclab/test/sensors/test_camera.py b/source/isaaclab/test/sensors/test_camera.py index 9b41899765b..a191142914c 100644 --- a/source/isaaclab/test/sensors/test_camera.py +++ b/source/isaaclab/test/sensors/test_camera.py @@ -30,7 +30,7 @@ import isaaclab.sim as sim_utils from isaaclab.sensors.camera import Camera, CameraCfg -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.rendering, pytest.mark.isaacsim_ci] # sample camera poses POSITION = (2.5, 2.5, 2.5) diff --git a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian.py b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian.py index 6f1e681efec..98339c94136 100644 --- a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian.py +++ b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian.py @@ -56,6 +56,8 @@ from isaaclab.sim import SimulationCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + def _collect_renderer_cfg_params() -> list: """Return pytest.param entries for installed RTX-backed renderer packages. diff --git a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_newton.py b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_newton.py index 26aa6787e45..7df34aa9bb8 100644 --- a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_newton.py +++ b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_newton.py @@ -34,6 +34,8 @@ import pytest +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + _REQUIRED_MODULES = ("isaaclab_newton", "newton") _MISSING_MODULES = [module for module in _REQUIRED_MODULES if importlib.util.find_spec(module) is None] _SKIP_MISSING_NEWTON = pytest.mark.skipif( diff --git a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_ovrtx.py b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_ovrtx.py index 82fd1f82953..b0d7c9b404a 100644 --- a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_ovrtx.py +++ b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_ovrtx.py @@ -63,6 +63,8 @@ from isaaclab.sim import SimulationCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + # OVRTX renderer + Newton physics are required (kit-less + non-PhysX). Use a # collection-time skip marker instead of module-level ``importorskip`` so CI's # per-file runner does not see pytest's "no tests collected" exit code. diff --git a/source/isaaclab/test/sensors/test_first_frame_textured_rendering.py b/source/isaaclab/test/sensors/test_first_frame_textured_rendering.py index 1c2ef0ec7b0..8ae3da57599 100644 --- a/source/isaaclab/test/sensors/test_first_frame_textured_rendering.py +++ b/source/isaaclab/test/sensors/test_first_frame_textured_rendering.py @@ -21,6 +21,8 @@ from isaaclab.sensors.camera import Camera, CameraCfg from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + # resolution HEIGHT = 256 WIDTH = 256 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 dcd58fa5acd..85c9438f458 100644 --- a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py +++ b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py @@ -15,6 +15,8 @@ from isaaclab.utils.math import matrix_from_quat, quat_from_euler_xyz, random_orientation from isaaclab.utils.warp.ops import convert_to_warp_mesh, raycast_dynamic_meshes, raycast_single_mesh +pytestmark = pytest.mark.integration + @pytest.fixture(scope="module") def device(): 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 360eb8f4d9e..1bd18904310 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 @@ -37,6 +37,8 @@ from isaaclab_assets.robots.anymal import ANYMAL_C_CFG from isaaclab_assets.robots.spot import SPOT_CFG +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + # sample camera poses (quaternions in xyzw format) POSITION = [2.5, 2.5, 2.5] QUAT_ROS = [0.33985114, 0.82047325, -0.42470819, -0.17591989] diff --git a/source/isaaclab/test/sensors/test_multi_tiled_camera.py b/source/isaaclab/test/sensors/test_multi_tiled_camera.py index 5188f5a897e..fe8f091b225 100644 --- a/source/isaaclab/test/sensors/test_multi_tiled_camera.py +++ b/source/isaaclab/test/sensors/test_multi_tiled_camera.py @@ -32,7 +32,7 @@ # Deprecation warnings from TiledCamera/TiledCameraCfg are expected in this file; # the deprecation mechanism itself is validated in test_tiled_camera.py. -pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning") +pytestmark = [pytest.mark.integration, pytest.mark.rendering, pytest.mark.filterwarnings("ignore::DeprecationWarning")] @pytest.fixture() diff --git a/source/isaaclab/test/sensors/test_outdated_sensor.py b/source/isaaclab/test/sensors/test_outdated_sensor.py index 4c4f4d337b6..d58b1ccfed6 100644 --- a/source/isaaclab/test/sensors/test_outdated_sensor.py +++ b/source/isaaclab/test/sensors/test_outdated_sensor.py @@ -25,6 +25,8 @@ import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils.parse_cfg import parse_env_cfg +pytestmark = pytest.mark.integration + @pytest.fixture() def temp_dir(): diff --git a/source/isaaclab/test/sensors/test_ray_caster.py b/source/isaaclab/test/sensors/test_ray_caster.py index 1749f537ab2..a68a60b25e6 100644 --- a/source/isaaclab/test/sensors/test_ray_caster.py +++ b/source/isaaclab/test/sensors/test_ray_caster.py @@ -23,6 +23,8 @@ from isaaclab.utils.warp.kernels import raycast_mesh_masked_kernel as _raycast_mesh_masked_kernel from isaaclab.utils.warp.ops import convert_to_warp_mesh, raycast_dynamic_meshes, raycast_mesh +pytestmark = pytest.mark.integration + @pytest.fixture(scope="module") def raycast_setup(): diff --git a/source/isaaclab/test/sensors/test_ray_caster_camera.py b/source/isaaclab/test/sensors/test_ray_caster_camera.py index f255caaf94a..f4ed2b97f80 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_ray_caster_camera.py @@ -32,6 +32,8 @@ from isaaclab.terrains.trimesh.utils import make_plane from isaaclab.terrains.utils import create_prim_from_mesh +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + # sample camera poses POSITION = [2.5, 2.5, 2.5] QUAT_ROS = [0.33985114, 0.82047325, -0.42470819, -0.17591989] diff --git a/source/isaaclab/test/sensors/test_ray_caster_integration.py b/source/isaaclab/test/sensors/test_ray_caster_integration.py index 2043d6e34d2..6f38b95ccfd 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_integration.py +++ b/source/isaaclab/test/sensors/test_ray_caster_integration.py @@ -45,6 +45,8 @@ from isaaclab.terrains.trimesh.utils import make_plane from isaaclab.terrains.utils import create_prim_from_mesh +pytestmark = pytest.mark.integration + _GROUND_PATH = "/World/Ground" _DT = 0.01 diff --git a/source/isaaclab/test/sensors/test_ray_caster_kernels.py b/source/isaaclab/test/sensors/test_ray_caster_kernels.py index b0853c2a3bc..a1d78744e53 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_kernels.py +++ b/source/isaaclab/test/sensors/test_ray_caster_kernels.py @@ -24,6 +24,8 @@ import pytest import warp as wp +pytestmark = pytest.mark.unit + # --------------------------------------------------------------------------- # Import kernel modules directly (avoids Isaac Sim / Omniverse dependencies) # --------------------------------------------------------------------------- diff --git a/source/isaaclab/test/sensors/test_ray_caster_patterns.py b/source/isaaclab/test/sensors/test_ray_caster_patterns.py index 8e1b484968d..de5f9ae9aa5 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_patterns.py +++ b/source/isaaclab/test/sensors/test_ray_caster_patterns.py @@ -18,6 +18,8 @@ from isaaclab.sensors.ray_caster.patterns import patterns, patterns_cfg +pytestmark = pytest.mark.integration + @pytest.fixture(scope="module", params=["cuda", "cpu"]) def device(request): diff --git a/source/isaaclab/test/sensors/test_ray_caster_sensor.py b/source/isaaclab/test/sensors/test_ray_caster_sensor.py index a3571fc222d..a82a4175ce6 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_sensor.py +++ b/source/isaaclab/test/sensors/test_ray_caster_sensor.py @@ -23,6 +23,8 @@ from isaaclab.terrains.utils import create_prim_from_mesh from isaaclab.utils.math import quat_from_euler_xyz +pytestmark = pytest.mark.integration + # ------------------------------------------------------------------- # Helpers # ------------------------------------------------------------------- diff --git a/source/isaaclab/test/sensors/test_sensor_base.py b/source/isaaclab/test/sensors/test_sensor_base.py index 0a7d694cd98..072d17c37d4 100644 --- a/source/isaaclab/test/sensors/test_sensor_base.py +++ b/source/isaaclab/test/sensors/test_sensor_base.py @@ -27,6 +27,8 @@ from isaaclab.sensors import SensorBase, SensorBaseCfg from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.integration + @dataclass class DummyData: diff --git a/source/isaaclab/test/sensors/test_tiled_camera.py b/source/isaaclab/test/sensors/test_tiled_camera.py index 47c5aee61d6..a83aca8e63b 100644 --- a/source/isaaclab/test/sensors/test_tiled_camera.py +++ b/source/isaaclab/test/sensors/test_tiled_camera.py @@ -36,6 +36,8 @@ import isaaclab.sim as sim_utils from isaaclab.sensors.camera import Camera, CameraCfg, TiledCamera, TiledCameraCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + @pytest.fixture(scope="function") def setup_camera(device) -> tuple[sim_utils.SimulationContext, CameraCfg, float]: diff --git a/source/isaaclab/test/sensors/test_tiled_camera_env.py b/source/isaaclab/test/sensors/test_tiled_camera_env.py index 68adc23dd85..5b52f925dda 100644 --- a/source/isaaclab/test/sensors/test_tiled_camera_env.py +++ b/source/isaaclab/test/sensors/test_tiled_camera_env.py @@ -39,7 +39,7 @@ import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils.parse_cfg import parse_env_cfg -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.rendering, pytest.mark.isaacsim_ci] @pytest.mark.skip(reason="Currently takes too long to run") diff --git a/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py b/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py index 65402518b7a..1eaa3592c51 100644 --- a/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py +++ b/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py @@ -21,6 +21,8 @@ import torch import warp as wp +pytestmark = pytest.mark.unit + # Import the kernel module directly to avoid pulling in the full isaaclab package # (which requires Isaac Sim / Omniverse dependencies). The kernel file itself only # depends on warp. diff --git a/source/isaaclab/test/sim/test_build_simulation_context_headless.py b/source/isaaclab/test/sim/test_build_simulation_context_headless.py index 4ceae87b987..cf266f73f4f 100644 --- a/source/isaaclab/test/sim/test_build_simulation_context_headless.py +++ b/source/isaaclab/test/sim/test_build_simulation_context_headless.py @@ -27,6 +27,8 @@ from isaaclab.sim.simulation_cfg import SimulationCfg from isaaclab.sim.simulation_context import build_simulation_context +pytestmark = pytest.mark.integration + @pytest.mark.parametrize("gravity_enabled", [True, False]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) diff --git a/source/isaaclab/test/sim/test_build_simulation_context_nonheadless.py b/source/isaaclab/test/sim/test_build_simulation_context_nonheadless.py index 09bfe309183..2ce2345062c 100644 --- a/source/isaaclab/test/sim/test_build_simulation_context_nonheadless.py +++ b/source/isaaclab/test/sim/test_build_simulation_context_nonheadless.py @@ -26,6 +26,8 @@ from isaaclab.sim.simulation_cfg import SimulationCfg from isaaclab.sim.simulation_context import build_simulation_context +pytestmark = pytest.mark.integration + @pytest.mark.parametrize("gravity_enabled", [True, False]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) diff --git a/source/isaaclab/test/sim/test_cloner.py b/source/isaaclab/test/sim/test_cloner.py index f226cf8aab3..a31138d072c 100644 --- a/source/isaaclab/test/sim/test_cloner.py +++ b/source/isaaclab/test/sim/test_cloner.py @@ -39,7 +39,7 @@ ) from isaaclab.sim import build_simulation_context -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture(params=["cpu", "cuda"]) diff --git a/source/isaaclab/test/sim/test_collision_fragments.py b/source/isaaclab/test/sim/test_collision_fragments.py index 69800659592..539701124ab 100644 --- a/source/isaaclab/test/sim/test_collision_fragments.py +++ b/source/isaaclab/test/sim/test_collision_fragments.py @@ -12,11 +12,15 @@ """Rest everything follows.""" +import pytest + from pxr import UsdGeom, UsdPhysics import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext +pytestmark = pytest.mark.integration + def _make_xform(stage, path="/World/Body"): UsdGeom.Xform.Define(stage, path) diff --git a/source/isaaclab/test/sim/test_deformable_backend_split.py b/source/isaaclab/test/sim/test_deformable_backend_split.py index dc170fe55c5..dfa811e0f96 100644 --- a/source/isaaclab/test/sim/test_deformable_backend_split.py +++ b/source/isaaclab/test/sim/test_deformable_backend_split.py @@ -8,6 +8,7 @@ import dataclasses import isaaclab_physx.sim.schemas as physx_schemas +import pytest from isaaclab_newton.sim.schemas import NewtonDeformableBodyPropertiesCfg from isaaclab_newton.sim.spawners.materials import ( NewtonDeformableBodyMaterialCfg, @@ -34,6 +35,8 @@ SurfaceDeformableBodyMaterialBaseCfg, ) +pytestmark = pytest.mark.integration + def _field_names(cls) -> set[str]: return {field.name for field in dataclasses.fields(cls)} diff --git a/source/isaaclab/test/sim/test_joint_drive_fragments.py b/source/isaaclab/test/sim/test_joint_drive_fragments.py index 8e43f6f3d5f..a9c5534ede3 100644 --- a/source/isaaclab/test/sim/test_joint_drive_fragments.py +++ b/source/isaaclab/test/sim/test_joint_drive_fragments.py @@ -21,6 +21,8 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext +pytestmark = pytest.mark.integration + def _make_revolute_joint(stage, path="/World/Articulation/joint_0"): UsdGeom.Xform.Define(stage, "/World/Articulation") diff --git a/source/isaaclab/test/sim/test_mass_fragments.py b/source/isaaclab/test/sim/test_mass_fragments.py index 1e4a28a6121..f017d9d2d16 100644 --- a/source/isaaclab/test/sim/test_mass_fragments.py +++ b/source/isaaclab/test/sim/test_mass_fragments.py @@ -19,6 +19,8 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext +pytestmark = pytest.mark.integration + def _make_xform(stage, path="/World/Body"): UsdGeom.Xform.Define(stage, path) diff --git a/source/isaaclab/test/sim/test_material_fragments.py b/source/isaaclab/test/sim/test_material_fragments.py index 8caf9a99fd9..576a087e788 100644 --- a/source/isaaclab/test/sim/test_material_fragments.py +++ b/source/isaaclab/test/sim/test_material_fragments.py @@ -19,6 +19,8 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext +pytestmark = pytest.mark.integration + # ------------------------------------------------------------------------------------- # RigidBodyMaterialFragment marker + metadata # ------------------------------------------------------------------------------------- diff --git a/source/isaaclab/test/sim/test_mesh_collision_fragments.py b/source/isaaclab/test/sim/test_mesh_collision_fragments.py index c592c6de75e..ae33dbb938d 100644 --- a/source/isaaclab/test/sim/test_mesh_collision_fragments.py +++ b/source/isaaclab/test/sim/test_mesh_collision_fragments.py @@ -12,11 +12,15 @@ """Rest everything follows.""" +import pytest + from pxr import UsdGeom, UsdPhysics import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext +pytestmark = pytest.mark.integration + def _make_xform(stage, path="/World/Mesh"): UsdGeom.Xform.Define(stage, path) diff --git a/source/isaaclab/test/sim/test_mesh_converter.py b/source/isaaclab/test/sim/test_mesh_converter.py index 1373f3753d0..f4551b4ba82 100644 --- a/source/isaaclab/test/sim/test_mesh_converter.py +++ b/source/isaaclab/test/sim/test_mesh_converter.py @@ -27,6 +27,8 @@ from isaaclab.sim.schemas import MESH_APPROXIMATION_TOKENS, schemas_cfg from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, retrieve_file_path +pytestmark = pytest.mark.integration + def random_quaternion(): # Generate four random numbers for the quaternion (x, y, z, w format) diff --git a/source/isaaclab/test/sim/test_mjcf_converter.py b/source/isaaclab/test/sim/test_mjcf_converter.py index 53ef7e5950f..b704d9a64e1 100644 --- a/source/isaaclab/test/sim/test_mjcf_converter.py +++ b/source/isaaclab/test/sim/test_mjcf_converter.py @@ -22,7 +22,7 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.sim.converters import MjcfConverter, MjcfConverterCfg -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] _MJCF_IMPORTER_EXTENSION = "isaacsim.asset.importer.mjcf" 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 e76e1c613e6..91b49a9c361 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -17,6 +17,10 @@ from types import SimpleNamespace +import pytest + +pytestmark = pytest.mark.integration + def _reset_newton_manager_state(): from isaaclab_newton.physics import NewtonManager diff --git a/source/isaaclab/test/sim/test_newton_model_utils.py b/source/isaaclab/test/sim/test_newton_model_utils.py index de66d30e204..4edfa08934b 100644 --- a/source/isaaclab/test/sim/test_newton_model_utils.py +++ b/source/isaaclab/test/sim/test_newton_model_utils.py @@ -25,6 +25,8 @@ replace_newton_builder_shape_colors, ) +pytestmark = pytest.mark.integration + _WARNING_MESSAGE = "Newton shape color replacement is enabled; this workaround will be deprecated in a future release." diff --git a/source/isaaclab/test/sim/test_schema_fragments.py b/source/isaaclab/test/sim/test_schema_fragments.py index 809f4f968b7..c712e4f4a31 100644 --- a/source/isaaclab/test/sim/test_schema_fragments.py +++ b/source/isaaclab/test/sim/test_schema_fragments.py @@ -19,6 +19,8 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext +pytestmark = pytest.mark.integration + def _make_xform(stage, path="/World/Body"): UsdGeom.Xform.Define(stage, path) diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index f22c7db4ad7..5b04d4f0934 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -39,6 +39,8 @@ from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR from isaaclab.utils.string import to_camel_case +pytestmark = pytest.mark.integration + @pytest.fixture def setup_simulation(): diff --git a/source/isaaclab/test/sim/test_schemas_shim.py b/source/isaaclab/test/sim/test_schemas_shim.py index 99d81f55d99..9c9b9ee78ae 100644 --- a/source/isaaclab/test/sim/test_schemas_shim.py +++ b/source/isaaclab/test/sim/test_schemas_shim.py @@ -23,6 +23,8 @@ import isaaclab.sim.spawners.materials as materials import isaaclab.sim.spawners.materials.physics_materials_cfg as materials_cfg_submodule +pytestmark = pytest.mark.integration + FORWARDED_NAMES = [ "RigidBodyPropertiesCfg", "JointDrivePropertiesCfg", diff --git a/source/isaaclab/test/sim/test_service_locator.py b/source/isaaclab/test/sim/test_service_locator.py index 8392fa57a38..28d925dd014 100644 --- a/source/isaaclab/test/sim/test_service_locator.py +++ b/source/isaaclab/test/sim/test_service_locator.py @@ -9,6 +9,8 @@ from isaaclab.sim.service_locator import ServiceLocator +pytestmark = pytest.mark.integration + # -- Dummy service helpers -- diff --git a/source/isaaclab/test/sim/test_simulation_context.py b/source/isaaclab/test/sim/test_simulation_context.py index 6ea578a85e3..65a96259060 100644 --- a/source/isaaclab/test/sim/test_simulation_context.py +++ b/source/isaaclab/test/sim/test_simulation_context.py @@ -23,6 +23,8 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext +pytestmark = pytest.mark.integration + @pytest.fixture(autouse=True) def test_setup_teardown(): diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 40ee55884e5..28d7249950b 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -22,6 +22,8 @@ from isaaclab.sim.simulation_context import SimulationContext from isaaclab.visualizers.visualizer_cfg import VisualizerCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + def test_web_visualizer_cfgs_do_not_open_browser_by_default(): assert RerunVisualizerCfg().open_browser is False diff --git a/source/isaaclab/test/sim/test_simulation_render_config.py b/source/isaaclab/test/sim/test_simulation_render_config.py index 9cc4c503347..cf3b731ca32 100644 --- a/source/isaaclab/test/sim/test_simulation_render_config.py +++ b/source/isaaclab/test/sim/test_simulation_render_config.py @@ -12,12 +12,15 @@ import sys import types +import pytest import tomllib from isaaclab_physx.renderers.isaac_rtx_renderer_cfg import ( IsaacRtxRendererGlobalSettingsCfg, ) from packaging.version import Version +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + class _FakeSettings: """Small settings manager double used by the Isaac RTX settings helper.""" diff --git a/source/isaaclab/test/sim/test_simulation_stage_in_memory.py b/source/isaaclab/test/sim/test_simulation_stage_in_memory.py index 1add70c7098..963b2a6a603 100644 --- a/source/isaaclab/test/sim/test_simulation_stage_in_memory.py +++ b/source/isaaclab/test/sim/test_simulation_stage_in_memory.py @@ -29,6 +29,8 @@ from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR from isaaclab.utils.version import get_isaac_sim_version +pytestmark = pytest.mark.integration + @pytest.fixture def sim(): diff --git a/source/isaaclab/test/sim/test_spawn_from_files.py b/source/isaaclab/test/sim/test_spawn_from_files.py index 009949de4c9..630aa123bf5 100644 --- a/source/isaaclab/test/sim/test_spawn_from_files.py +++ b/source/isaaclab/test/sim/test_spawn_from_files.py @@ -20,6 +20,8 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +pytestmark = pytest.mark.integration + @pytest.fixture def sim(): diff --git a/source/isaaclab/test/sim/test_spawn_lights.py b/source/isaaclab/test/sim/test_spawn_lights.py index 3a019c2f90b..59c77188078 100644 --- a/source/isaaclab/test/sim/test_spawn_lights.py +++ b/source/isaaclab/test/sim/test_spawn_lights.py @@ -21,7 +21,7 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.string import to_camel_case -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture(autouse=True) diff --git a/source/isaaclab/test/sim/test_spawn_materials.py b/source/isaaclab/test/sim/test_spawn_materials.py index 7f517f2bc1f..d1cb86c8702 100644 --- a/source/isaaclab/test/sim/test_spawn_materials.py +++ b/source/isaaclab/test/sim/test_spawn_materials.py @@ -21,7 +21,7 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.assets import NVIDIA_NUCLEUS_DIR -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture diff --git a/source/isaaclab/test/sim/test_spawn_meshes.py b/source/isaaclab/test/sim/test_spawn_meshes.py index 97aca4a23f7..a9ad5158c2f 100644 --- a/source/isaaclab/test/sim/test_spawn_meshes.py +++ b/source/isaaclab/test/sim/test_spawn_meshes.py @@ -18,7 +18,7 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture diff --git a/source/isaaclab/test/sim/test_spawn_sensors.py b/source/isaaclab/test/sim/test_spawn_sensors.py index f8604b99e54..9e50b54496b 100644 --- a/source/isaaclab/test/sim/test_spawn_sensors.py +++ b/source/isaaclab/test/sim/test_spawn_sensors.py @@ -22,7 +22,7 @@ from isaaclab.sim.spawners.sensors.sensors import CUSTOM_FISHEYE_CAMERA_ATTRIBUTES, CUSTOM_PINHOLE_CAMERA_ATTRIBUTES from isaaclab.utils.string import to_camel_case -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture diff --git a/source/isaaclab/test/sim/test_spawn_shapes.py b/source/isaaclab/test/sim/test_spawn_shapes.py index 532b29c2284..be59ea011d0 100644 --- a/source/isaaclab/test/sim/test_spawn_shapes.py +++ b/source/isaaclab/test/sim/test_spawn_shapes.py @@ -17,7 +17,7 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture diff --git a/source/isaaclab/test/sim/test_spawn_wrappers.py b/source/isaaclab/test/sim/test_spawn_wrappers.py index 69ad8b10572..a0be9336a56 100644 --- a/source/isaaclab/test/sim/test_spawn_wrappers.py +++ b/source/isaaclab/test/sim/test_spawn_wrappers.py @@ -19,7 +19,7 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture diff --git a/source/isaaclab/test/sim/test_tendon_fragments.py b/source/isaaclab/test/sim/test_tendon_fragments.py index d18b6c26f8d..c7569081a16 100644 --- a/source/isaaclab/test/sim/test_tendon_fragments.py +++ b/source/isaaclab/test/sim/test_tendon_fragments.py @@ -19,6 +19,8 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext +pytestmark = pytest.mark.integration + def _new_sim(): sim_utils.create_new_stage() diff --git a/source/isaaclab/test/sim/test_urdf_converter.py b/source/isaaclab/test/sim/test_urdf_converter.py index 7794546a21a..c2e060495d8 100644 --- a/source/isaaclab/test/sim/test_urdf_converter.py +++ b/source/isaaclab/test/sim/test_urdf_converter.py @@ -26,6 +26,8 @@ from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.sim.converters import UrdfConverter, UrdfConverterCfg +pytestmark = pytest.mark.integration + # Create a fixture for setup and teardown @pytest.fixture diff --git a/source/isaaclab/test/sim/test_utils_prims.py b/source/isaaclab/test/sim/test_utils_prims.py index 0d11afe22f4..d1b8f44cfb1 100644 --- a/source/isaaclab/test/sim/test_utils_prims.py +++ b/source/isaaclab/test/sim/test_utils_prims.py @@ -25,7 +25,7 @@ from isaaclab.sim.utils.prims import _to_tuple # type: ignore[reportPrivateUsage] from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR, retrieve_file_path -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @pytest.fixture(autouse=True) diff --git a/source/isaaclab/test/sim/test_utils_queries.py b/source/isaaclab/test/sim/test_utils_queries.py index ef9b1d8c834..49801169a78 100644 --- a/source/isaaclab/test/sim/test_utils_queries.py +++ b/source/isaaclab/test/sim/test_utils_queries.py @@ -20,6 +20,8 @@ import isaaclab.sim as sim_utils from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +pytestmark = pytest.mark.integration + @pytest.fixture(autouse=True) def test_setup_teardown(): diff --git a/source/isaaclab/test/sim/test_utils_semantics.py b/source/isaaclab/test/sim/test_utils_semantics.py index fe8cbd37187..926a2d0d80a 100644 --- a/source/isaaclab/test/sim/test_utils_semantics.py +++ b/source/isaaclab/test/sim/test_utils_semantics.py @@ -17,6 +17,8 @@ import isaaclab.sim as sim_utils +pytestmark = pytest.mark.integration + @pytest.fixture(autouse=True) def test_setup_teardown(): diff --git a/source/isaaclab/test/sim/test_utils_stage.py b/source/isaaclab/test/sim/test_utils_stage.py index 6126720ff34..39a70a076f7 100644 --- a/source/isaaclab/test/sim/test_utils_stage.py +++ b/source/isaaclab/test/sim/test_utils_stage.py @@ -23,7 +23,7 @@ import isaaclab.sim as sim_utils -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] def test_create_new_stage(): diff --git a/source/isaaclab/test/sim/test_utils_transforms.py b/source/isaaclab/test/sim/test_utils_transforms.py index 9cd43800b52..e7cc178b65d 100644 --- a/source/isaaclab/test/sim/test_utils_transforms.py +++ b/source/isaaclab/test/sim/test_utils_transforms.py @@ -23,6 +23,8 @@ import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils +pytestmark = pytest.mark.integration + @pytest.fixture(autouse=True) def test_setup_teardown(): diff --git a/source/isaaclab/test/sim/test_views_xform_prim.py b/source/isaaclab/test/sim/test_views_xform_prim.py index 61b281ca5e9..c99593dff50 100644 --- a/source/isaaclab/test/sim/test_views_xform_prim.py +++ b/source/isaaclab/test/sim/test_views_xform_prim.py @@ -32,7 +32,7 @@ from isaaclab.sim.views import UsdFrameView as FrameView # noqa: E402 from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR # noqa: E402 -pytestmark = pytest.mark.isaacsim_ci +pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] PARENT_POS = (0.0, 0.0, 1.0) diff --git a/source/isaaclab/test/terrains/test_terrain_generator.py b/source/isaaclab/test/terrains/test_terrain_generator.py index 56bee0b2546..476f5efaf74 100644 --- a/source/isaaclab/test/terrains/test_terrain_generator.py +++ b/source/isaaclab/test/terrains/test_terrain_generator.py @@ -14,6 +14,8 @@ from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG from isaaclab.utils.seed import configure_seed +pytestmark = pytest.mark.integration + @pytest.fixture def output_dir(): diff --git a/source/isaaclab/test/terrains/test_terrain_importer.py b/source/isaaclab/test/terrains/test_terrain_importer.py index 96375cab075..553f01f7923 100644 --- a/source/isaaclab/test/terrains/test_terrain_importer.py +++ b/source/isaaclab/test/terrains/test_terrain_importer.py @@ -30,6 +30,8 @@ from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +pytestmark = pytest.mark.integration + @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("env_spacing", [1.0, 4.325, 8.0]) diff --git a/source/isaaclab/test/test_mock_interfaces/test_mock_assets.py b/source/isaaclab/test/test_mock_interfaces/test_mock_assets.py index a10fe5150ff..8ea17779950 100644 --- a/source/isaaclab/test/test_mock_interfaces/test_mock_assets.py +++ b/source/isaaclab/test/test_mock_interfaces/test_mock_assets.py @@ -20,6 +20,8 @@ ) from isaaclab.test.mock_interfaces.utils import MockArticulationBuilder +pytestmark = pytest.mark.unit + # ============================================================================== # MockArticulation Tests # ============================================================================== diff --git a/source/isaaclab/test/test_mock_interfaces/test_mock_data_properties.py b/source/isaaclab/test/test_mock_interfaces/test_mock_data_properties.py index b68bb8141e1..cb151f11ccf 100644 --- a/source/isaaclab/test/test_mock_interfaces/test_mock_data_properties.py +++ b/source/isaaclab/test/test_mock_interfaces/test_mock_data_properties.py @@ -20,6 +20,8 @@ MockPvaData, ) +pytestmark = pytest.mark.unit + # ============================================================================== # IMU Data Property Tests # ============================================================================== 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 be69316438f..5ffe9ef7211 100644 --- a/source/isaaclab/test/test_mock_interfaces/test_mock_sensors.py +++ b/source/isaaclab/test/test_mock_interfaces/test_mock_sensors.py @@ -21,6 +21,8 @@ ) from isaaclab.test.mock_interfaces.utils import MockSensorBuilder +pytestmark = pytest.mark.unit + # ============================================================================== # MockImu Tests # ============================================================================== diff --git a/source/isaaclab/test/utils/test_assets.py b/source/isaaclab/test/utils/test_assets.py index 54120215132..90965eb92c0 100644 --- a/source/isaaclab/test/utils/test_assets.py +++ b/source/isaaclab/test/utils/test_assets.py @@ -13,6 +13,8 @@ import isaaclab.utils.assets as assets_utils +pytestmark = pytest.mark.unit + def test_nucleus_connection(): """Test checking the Nucleus connection.""" diff --git a/source/isaaclab/test/utils/test_circular_buffer.py b/source/isaaclab/test/utils/test_circular_buffer.py index 132a86bd664..805acc2c7a5 100644 --- a/source/isaaclab/test/utils/test_circular_buffer.py +++ b/source/isaaclab/test/utils/test_circular_buffer.py @@ -8,6 +8,8 @@ from isaaclab.utils import CircularBuffer +pytestmark = pytest.mark.unit + @pytest.fixture def circular_buffer(): diff --git a/source/isaaclab/test/utils/test_configclass.py b/source/isaaclab/test/utils/test_configclass.py index 1c2f13c1ef1..1d3327e4894 100644 --- a/source/isaaclab/test/utils/test_configclass.py +++ b/source/isaaclab/test/utils/test_configclass.py @@ -20,6 +20,8 @@ from isaaclab.utils.io import dump_yaml, load_yaml from isaaclab.utils.string import ResolvableString +pytestmark = pytest.mark.unit + """ Mock classes and functions. """ diff --git a/source/isaaclab/test/utils/test_delay_buffer.py b/source/isaaclab/test/utils/test_delay_buffer.py index fce156b4a4c..354281c07a2 100644 --- a/source/isaaclab/test/utils/test_delay_buffer.py +++ b/source/isaaclab/test/utils/test_delay_buffer.py @@ -10,6 +10,8 @@ from isaaclab.utils import DelayBuffer +pytestmark = pytest.mark.unit + @pytest.fixture def delay_buffer(): diff --git a/source/isaaclab/test/utils/test_dict.py b/source/isaaclab/test/utils/test_dict.py index b2cbd8bb0e6..7546736497f 100644 --- a/source/isaaclab/test/utils/test_dict.py +++ b/source/isaaclab/test/utils/test_dict.py @@ -10,6 +10,8 @@ import isaaclab.utils.dict as dict_utils import isaaclab.utils.string as string_utils +pytestmark = pytest.mark.unit + def _test_function(x): """Test function for string <-> callable conversion.""" diff --git a/source/isaaclab/test/utils/test_episode_data.py b/source/isaaclab/test/utils/test_episode_data.py index a2d570d9d6e..c767521ceb8 100644 --- a/source/isaaclab/test/utils/test_episode_data.py +++ b/source/isaaclab/test/utils/test_episode_data.py @@ -7,6 +7,8 @@ from isaaclab.utils.datasets import EpisodeData +pytestmark = pytest.mark.unit + @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_is_empty(device): 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 11e8a434b1a..f28b9277d81 100644 --- a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py +++ b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py @@ -12,6 +12,8 @@ from isaaclab.utils.datasets import EpisodeData, HDF5DatasetFileHandler +pytestmark = pytest.mark.unit + def create_test_episode(device): """create a test episode with dummy data.""" diff --git a/source/isaaclab/test/utils/test_images.py b/source/isaaclab/test/utils/test_images.py index 3cf5b9faac2..c8a2ff3781e 100644 --- a/source/isaaclab/test/utils/test_images.py +++ b/source/isaaclab/test/utils/test_images.py @@ -11,6 +11,8 @@ import torch import warp as wp +pytestmark = pytest.mark.unit + wp.config.quiet = True wp.init() diff --git a/source/isaaclab/test/utils/test_logger.py b/source/isaaclab/test/utils/test_logger.py index 4a6d30135d9..8bfad15df0a 100644 --- a/source/isaaclab/test/utils/test_logger.py +++ b/source/isaaclab/test/utils/test_logger.py @@ -15,6 +15,8 @@ from isaaclab.utils.logger import ColoredFormatter, RateLimitFilter, configure_logging +pytestmark = pytest.mark.unit + # Fixtures @pytest.fixture diff --git a/source/isaaclab/test/utils/test_math.py b/source/isaaclab/test/utils/test_math.py index c2e5b355081..90bba67cbda 100644 --- a/source/isaaclab/test/utils/test_math.py +++ b/source/isaaclab/test/utils/test_math.py @@ -14,6 +14,8 @@ import isaaclab.utils.math as math_utils +pytestmark = pytest.mark.unit + DECIMAL_PRECISION = 5 """Precision of the test. diff --git a/source/isaaclab/test/utils/test_modifiers.py b/source/isaaclab/test/utils/test_modifiers.py index 6e0e39820fd..a4f5212a9fd 100644 --- a/source/isaaclab/test/utils/test_modifiers.py +++ b/source/isaaclab/test/utils/test_modifiers.py @@ -11,6 +11,8 @@ import isaaclab.utils.modifiers as modifiers from isaaclab.utils.configclass import configclass +pytestmark = pytest.mark.unit + @configclass class ModifierTestCfg: diff --git a/source/isaaclab/test/utils/test_noise.py b/source/isaaclab/test/utils/test_noise.py index 45f9d2d6138..2d9b836cd42 100644 --- a/source/isaaclab/test/utils/test_noise.py +++ b/source/isaaclab/test/utils/test_noise.py @@ -8,6 +8,8 @@ import isaaclab.utils.noise as noise +pytestmark = pytest.mark.unit + @pytest.mark.parametrize("device", ["cpu", "cuda:0"]) @pytest.mark.parametrize("noise_device", ["cpu", "cuda:0"]) diff --git a/source/isaaclab/test/utils/test_string.py b/source/isaaclab/test/utils/test_string.py index d946f178fdf..1137621e7db 100644 --- a/source/isaaclab/test/utils/test_string.py +++ b/source/isaaclab/test/utils/test_string.py @@ -11,6 +11,8 @@ import isaaclab.utils.string as string_utils from isaaclab.utils.string import _resolve_matching_names_impl +pytestmark = pytest.mark.unit + def test_resolvable_string_metadata_is_non_eager(): """Test metadata access on ResolvableString without triggering import/resolve.""" diff --git a/source/isaaclab/test/utils/test_timer.py b/source/isaaclab/test/utils/test_timer.py index 9337b201a67..f2748a3fdc1 100644 --- a/source/isaaclab/test/utils/test_timer.py +++ b/source/isaaclab/test/utils/test_timer.py @@ -12,6 +12,8 @@ from isaaclab.utils.timer import Timer, TimerError +pytestmark = pytest.mark.unit + # number of decimal places to check PRECISION_PLACES = 2 diff --git a/source/isaaclab/test/utils/test_version.py b/source/isaaclab/test/utils/test_version.py index ba737b53643..b692ce20719 100644 --- a/source/isaaclab/test/utils/test_version.py +++ b/source/isaaclab/test/utils/test_version.py @@ -19,6 +19,8 @@ from isaaclab.utils.version import compare_versions, get_isaac_sim_version +pytestmark = pytest.mark.integration + def test_get_isaac_sim_version(): """Test that get_isaac_sim_version returns cached Version object.""" diff --git a/source/isaaclab/test/utils/test_wrench_composer.py b/source/isaaclab/test/utils/test_wrench_composer.py index b711aaab44a..1d3da72b7c7 100644 --- a/source/isaaclab/test/utils/test_wrench_composer.py +++ b/source/isaaclab/test/utils/test_wrench_composer.py @@ -11,6 +11,8 @@ from isaaclab.test.mock_interfaces.assets import MockRigidObjectCollection from isaaclab.utils.wrench_composer import WrenchComposer +pytestmark = pytest.mark.unit + def create_mock_asset( num_envs: int, diff --git a/source/isaaclab/test/utils/test_wrench_composer_integration.py b/source/isaaclab/test/utils/test_wrench_composer_integration.py index acb1682bde0..49bf1782fbb 100644 --- a/source/isaaclab/test/utils/test_wrench_composer_integration.py +++ b/source/isaaclab/test/utils/test_wrench_composer_integration.py @@ -26,6 +26,8 @@ from isaaclab.sim import build_simulation_context from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +pytestmark = pytest.mark.integration + def generate_cubes_scene( num_cubes: int = 1, diff --git a/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py b/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py index ca8f22be437..122f80021e1 100644 --- a/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py +++ b/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py @@ -31,6 +31,8 @@ from isaaclab.sim import build_simulation_context from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +pytestmark = pytest.mark.integration + def generate_dual_cube_scene( num_cubes: int = 1, diff --git a/source/isaaclab/test/utils/warp/test_image_ops.py b/source/isaaclab/test/utils/warp/test_image_ops.py index 2893b38d62c..377fab96066 100644 --- a/source/isaaclab/test/utils/warp/test_image_ops.py +++ b/source/isaaclab/test/utils/warp/test_image_ops.py @@ -11,6 +11,8 @@ import torch import warp as wp +pytestmark = pytest.mark.unit + wp.config.quiet = True wp.init() diff --git a/source/isaaclab/test/utils/warp/test_particle_mesh.py b/source/isaaclab/test/utils/warp/test_particle_mesh.py index 8ad7442cb66..8a79a35180f 100644 --- a/source/isaaclab/test/utils/warp/test_particle_mesh.py +++ b/source/isaaclab/test/utils/warp/test_particle_mesh.py @@ -13,6 +13,8 @@ from isaaclab.utils.warp import ParticleMeshCounter, make_box_region_mesh, make_frustum_region_mesh +pytestmark = pytest.mark.unit + @pytest.fixture(params=["cpu", "cuda:0"]) def device(request): diff --git a/source/isaaclab/test/utils/warp/test_proxy_array.py b/source/isaaclab/test/utils/warp/test_proxy_array.py index 08bdd4bb916..8ea8dddf2a9 100644 --- a/source/isaaclab/test/utils/warp/test_proxy_array.py +++ b/source/isaaclab/test/utils/warp/test_proxy_array.py @@ -11,6 +11,8 @@ import torch import warp as wp +pytestmark = pytest.mark.unit + wp.config.quiet = True wp.init() diff --git a/source/isaaclab/test/visualizers/test_visualizer.py b/source/isaaclab/test/visualizers/test_visualizer.py index 4f5cc9dbca4..5a25cdc3b00 100644 --- a/source/isaaclab/test/visualizers/test_visualizer.py +++ b/source/isaaclab/test/visualizers/test_visualizer.py @@ -18,6 +18,8 @@ from isaaclab.visualizers.visualizer import Visualizer from isaaclab.visualizers.visualizer_cfg import VisualizerCfg +pytestmark = [pytest.mark.integration, pytest.mark.rendering] + # # Config factory # From 7b0fe6aae2731ceada4649f80c1921a9674f47ec Mon Sep 17 00:00:00 2001 From: hujc Date: Tue, 7 Jul 2026 12:43:59 -0700 Subject: [PATCH 103/139] [MGPU] App: default Kit renderer to one GPU (#5933) ## 1. Summary - **isaaclab**: makes the base Kit experiences use one renderer GPU by default; XR explicitly retains multi-GPU rendering. - Keeps single-process renderer multi-GPU available through existing `kit_args`, avoiding overlap with training `--distributed`. - Adds `ISAACLAB_FABRIC_USE_GPU_INTEROP=0|1` as an independent temporary CI workaround; when unset, the Kit default is preserved. ## 2. Opt-in and workaround Enable single-process renderer multi-GPU explicitly: ```text --kit_args "--/renderer/multiGpu/enabled=true --/renderer/multiGpu/autoEnable=true --/renderer/multiGpu/maxGpuCount=16" ``` The multi-GPU CI lane can temporarily disable Fabric GPU interop with `ISAACLAB_FABRIC_USE_GPU_INTEROP=0`. Explicit raw Kit settings and `kit_args` take precedence over the environment override. Remove the workaround after the underlying Kit/PhysX problem is fixed. ## 3. Validation - [x] Merged current `develop` without conflicts. - [x] Full `./isaaclab.sh -f` passed after the merge. - [x] Five focused Fabric override and explicit-precedence tests passed. - [x] Exact four-A10G diagnostics reproduced instability without the Fabric override while five explicit-false controls completed cleanly: https://github.com/isaac-sim/IsaacLab/pull/6300#issuecomment-4841829935 --- apps/isaaclab.python.headless.kit | 10 ++-- apps/isaaclab.python.headless.rendering.kit | 3 -- apps/isaaclab.python.kit | 6 +++ apps/isaaclab.python.rendering.kit | 3 -- apps/isaaclab.python.xr.openxr.kit | 2 + .../jichuanh-mgpu-pin-kit-resources.rst | 14 ++++++ source/isaaclab/isaaclab/app/app_launcher.py | 28 +++++++++-- .../isaaclab/test/app/test_env_var_launch.py | 47 +++++++++++++++++++ 8 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 source/isaaclab/changelog.d/jichuanh-mgpu-pin-kit-resources.rst diff --git a/apps/isaaclab.python.headless.kit b/apps/isaaclab.python.headless.kit index 2d0858512ed..8dfef00323e 100644 --- a/apps/isaaclab.python.headless.kit +++ b/apps/isaaclab.python.headless.kit @@ -62,11 +62,11 @@ interceptSysStdOutput = false logSysStdOutput = false [settings] -# MGPU is always on, you can turn it from the settings, and force this off to save even more resource if you -# only want to use a single GPU on your MGPU system -# False for Isaac Sim -renderer.multiGpu.enabled = true -renderer.multiGpu.autoEnable = true +# Use one renderer GPU by default. Applications that need single-process multi-GPU rendering can override these +# settings through AppLauncher's ``kit_args``. +renderer.multiGpu.enabled = false +renderer.multiGpu.autoEnable = false +renderer.multiGpu.maxGpuCount = 1 'rtx-transient'.resourcemanager.enableTextureStreaming = true app.asyncRendering = false app.asyncRenderingLowLatency = false diff --git a/apps/isaaclab.python.headless.rendering.kit b/apps/isaaclab.python.headless.rendering.kit index 9bb0a369243..d2910f33822 100644 --- a/apps/isaaclab.python.headless.rendering.kit +++ b/apps/isaaclab.python.headless.rendering.kit @@ -76,9 +76,6 @@ rtx.ambientOcclusion.enabled = false # Set the DLSS model rtx.post.dlss.execMode = 0 # can be 0 (Performance), 1 (Balanced), 2 (Quality), or 3 (Auto) -# Avoids unnecessary GPU context initialization -renderer.multiGpu.maxGpuCount=1 - # Force synchronous rendering to improve training results omni.replicator.asyncRendering = false diff --git a/apps/isaaclab.python.kit b/apps/isaaclab.python.kit index d9dbfd942a6..000eae89a49 100644 --- a/apps/isaaclab.python.kit +++ b/apps/isaaclab.python.kit @@ -249,6 +249,12 @@ omni.replicator.asyncRendering = false app.asyncRendering = false app.asyncRenderingLowLatency = false +# Use one renderer GPU by default. Applications that need single-process multi-GPU rendering can override these +# settings through AppLauncher's ``kit_args``. +renderer.multiGpu.enabled = false +renderer.multiGpu.autoEnable = false +renderer.multiGpu.maxGpuCount = 1 + ### FSD app.useFabricSceneDelegate = true rtx.hydra.readTransformsFromFabricInRenderDelegate = true diff --git a/apps/isaaclab.python.rendering.kit b/apps/isaaclab.python.rendering.kit index 08982f9b495..035d09ab96a 100644 --- a/apps/isaaclab.python.rendering.kit +++ b/apps/isaaclab.python.rendering.kit @@ -71,9 +71,6 @@ rtx.ambientOcclusion.enabled = false # Set the DLSS model rtx.post.dlss.execMode = 0 # can be 0 (Performance), 1 (Balanced), 2 (Quality), or 3 (Auto) -# Avoids unnecessary GPU context initialization -renderer.multiGpu.maxGpuCount=1 - # Force synchronous rendering to improve training results omni.replicator.asyncRendering = false diff --git a/apps/isaaclab.python.xr.openxr.kit b/apps/isaaclab.python.xr.openxr.kit index 6248484e58b..2a4385e0942 100644 --- a/apps/isaaclab.python.xr.openxr.kit +++ b/apps/isaaclab.python.xr.openxr.kit @@ -24,6 +24,8 @@ app.asyncRendering = true app.asyncRenderingLowLatency = true # For XR, set this back to default "#define OMNI_MAX_DEVICE_GROUP_DEVICE_COUNT 16" +renderer.multiGpu.enabled = true +renderer.multiGpu.autoEnable = true renderer.multiGpu.maxGpuCount = 16 renderer.gpuEnumeration.glInterop.enabled = true # Allow Kit XR OpenXR to render headless diff --git a/source/isaaclab/changelog.d/jichuanh-mgpu-pin-kit-resources.rst b/source/isaaclab/changelog.d/jichuanh-mgpu-pin-kit-resources.rst new file mode 100644 index 00000000000..d2f13c6efca --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-mgpu-pin-kit-resources.rst @@ -0,0 +1,14 @@ +Changed +^^^^^^^ + +* Changed the Isaac Lab Kit experiences to use one renderer GPU by default. To + enable single-process multi-GPU rendering, pass the ``renderer.multiGpu`` + settings explicitly through :class:`~isaaclab.app.AppLauncher`'s ``kit_args``. + +Added +^^^^^ + +* Added ``ISAACLAB_FABRIC_USE_GPU_INTEROP`` to override the corresponding PhysX + Fabric Kit setting without changing renderer multi-GPU behavior. The multi-GPU + CI override is a temporary workaround to remove after the underlying Kit/PhysX + problem is fixed. diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 1428e0f763f..b330724f39e 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -33,6 +33,8 @@ # import logger logger = logging.getLogger(__name__) +_FABRIC_GPU_INTEROP_ENV = "ISAACLAB_FABRIC_USE_GPU_INTEROP" + # Suppress noisy debug-level logs from third-party libraries logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger("matplotlib").setLevel(logging.WARNING) @@ -80,6 +82,10 @@ class AppLauncher: value >-1. In other words, if ``livestream=-1``, then the value from the environment variable ``LIVESTREAM`` is used. + The ``ISAACLAB_FABRIC_USE_GPU_INTEROP`` environment variable optionally overrides the + ``/physics/fabricUseGPUInterop`` Kit setting. Set it to ``1`` or ``0`` to enable or disable the setting. + When unset, Kit's configured default is preserved. + """ @staticmethod @@ -431,6 +437,8 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: * ``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" + Isaac Lab experiences use one renderer GPU by default. Applications that need single-process + multi-GPU rendering can override the ``renderer.multiGpu`` settings through this argument. * ``visualizer`` (str): Visualizer backends to enable. Valid options are: @@ -1169,11 +1177,21 @@ def _resolve_anim_recording_settings(self, launcher_args: dict): def _resolve_kit_args(self, launcher_args: dict): """Resolve additional arguments passed to Kit.""" - # Resolve additional arguments passed to Kit - self._kit_args = [] - if "kit_args" in launcher_args: - self._kit_args = [arg for arg in launcher_args["kit_args"].split()] - sys.argv += self._kit_args + self._kit_args = launcher_args.get("kit_args", "").split() + + fabric_gpu_interop = os.environ.get(_FABRIC_GPU_INTEROP_ENV) + if fabric_gpu_interop is not None: + if fabric_gpu_interop not in {"0", "1"}: + raise ValueError( + f"Invalid value for environment variable `{_FABRIC_GPU_INTEROP_ENV}`: {fabric_gpu_interop}." + " Expected: 0 or 1." + ) + argument = f"--/physics/fabricUseGPUInterop={'true' if fabric_gpu_interop == '1' else 'false'}" + setting = argument.partition("=")[0] + if not any(arg.partition("=")[0] == setting for arg in sys.argv + self._kit_args): + self._kit_args.append(argument) + + sys.argv += self._kit_args def _create_app(self): """Launch and create the SimulationApp based on the parsed simulation config.""" diff --git a/source/isaaclab/test/app/test_env_var_launch.py b/source/isaaclab/test/app/test_env_var_launch.py index 750723f0672..6e98df4aef2 100644 --- a/source/isaaclab/test/app/test_env_var_launch.py +++ b/source/isaaclab/test/app/test_env_var_launch.py @@ -4,9 +4,11 @@ # SPDX-License-Identifier: BSD-3-Clause import os +import sys import pytest +import isaaclab.app.app_launcher as app_launcher_module from isaaclab.app import AppLauncher pytestmark = pytest.mark.integration @@ -35,3 +37,48 @@ def test_livestream_launch_with_env_vars(mocker): # close the app on exit app.close() + + +def _resolve_kit_args( + monkeypatch: pytest.MonkeyPatch, launcher_args: dict, argv: list[str] | None = None +) -> tuple[list[str], list[str]]: + monkeypatch.setattr(sys, "argv", ["pytest", *(argv or [])]) + launcher = AppLauncher.__new__(AppLauncher) + launcher._resolve_kit_args(launcher_args) + return sys.argv[1:], launcher._kit_args + + +@pytest.mark.parametrize("env_value, expected", [("0", "false"), ("1", "true")]) +def test_fabric_gpu_interop_env_adds_override(monkeypatch: pytest.MonkeyPatch, env_value: str, expected: str): + monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, env_value) + + kit_args, _ = _resolve_kit_args(monkeypatch, {}) + + assert kit_args == [f"--/physics/fabricUseGPUInterop={expected}"] + + +def test_fabric_gpu_interop_env_rejects_invalid_value(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "false") + + with pytest.raises(ValueError, match="Expected: 0 or 1"): + _resolve_kit_args(monkeypatch, {}) + + +def test_explicit_kit_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "0") + explicit_arg = "--/physics/fabricUseGPUInterop=true" + + kit_args, resolved_args = _resolve_kit_args(monkeypatch, {}, [explicit_arg]) + + assert kit_args == [explicit_arg] + assert resolved_args == [] + + +def test_explicit_kit_args_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "0") + explicit_arg = "--/physics/fabricUseGPUInterop=true" + + kit_args, resolved_args = _resolve_kit_args(monkeypatch, {"kit_args": explicit_arg}) + + assert kit_args == [explicit_arg] + assert resolved_args == [explicit_arg] From 1f92203fc1e48c9dfb003ca429a4695bca7b4b9e Mon Sep 17 00:00:00 2001 From: Piotr Barejko Date: Tue, 7 Jul 2026 14:12:41 -0700 Subject: [PATCH 104/139] Update link to upstream documentation (#6399) # Description Documentation jobs are failing because of broken links from upstream documentation: was: ``` https://nvidia-isaac-ros.github.io/reference_workflows/isaac_for_manipulation/packages/isaac_ros_manipulation_ur_dnn_policy/index.html ``` is: ``` https://nvidia-isaac-ros.github.io/reference_workflows/isaac_for_manipulation/packages/isaac_ros_manipulation_dnn_policy/index.html ``` 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 --- .../02_gear_assembly/gear_assembly_policy.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst b/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst index 9ab12fdeda4..a8a0101e809 100644 --- a/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst +++ b/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst @@ -36,7 +36,7 @@ This environment has been successfully deployed on real UR10e and Flexiv Rizon 4 **Scope of This Tutorial:** -This tutorial focuses exclusively on the **training part** of the sim-to-real transfer workflow in Isaac Lab. For the complete deployment workflow on the real robot, including the exact steps to set up the vision pipeline, robot interface and the ROS inference node to run your trained policy on real hardware, please refer to the `Isaac ROS Documentation `_. +This tutorial focuses exclusively on the **training part** of the sim-to-real transfer workflow in Isaac Lab. For the complete deployment workflow on the real robot, including the exact steps to set up the vision pipeline, robot interface and the ROS inference node to run your trained policy on real hardware, please refer to the `Isaac ROS Documentation `_. Overview -------- @@ -755,7 +755,7 @@ Replace the log directory path with your actual training log location if differe Step 3: Deploy on Real Robot ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Once training is complete, follow the `Isaac ROS inference documentation `_ to deploy your policy. +Once training is complete, follow the `Isaac ROS inference documentation `_ to deploy your policy. The Isaac ROS deployment pipeline directly uses the trained model checkpoint (``.pt`` file) along with the ``agent.yaml`` and ``env.yaml`` configuration files generated during training. No additional export step is required. From c8477f3d8ceb7cfe2fbba10274fac11f63e9ee46 Mon Sep 17 00:00:00 2001 From: hujc Date: Tue, 7 Jul 2026 15:07:51 -0700 Subject: [PATCH 105/139] [Newton] Fix multi-GPU initialization on non-default devices (#6322) ## 1. Summary Fix CUDA initialization on non-default devices by selecting the resolved device in PyTorch before synchronizing Warp at shared launcher and physics-backend initialization boundaries. Newton binds CUDA graph capture explicitly to the simulation device without backend-specific default-device setup. The exact three-rank camera workload failed 2/2 without device-scoped capture and has now passed 6/6 with the fix, including two runs on the final centralized implementation and one after merging current ``develop``. ## 2. Root cause Warp used its default ``cuda:0`` context while nonzero distributed ranks used their assigned devices. Newton's collision pipeline then mixed allocations from different CUDA contexts, producing CUDA errors 900 and 101 during graph capture. ## 3. Design - Select the PyTorch device before synchronizing Warp through a private shared helper. - Synchronize both runtimes after Kit startup in ``AppLauncher``. - Synchronize both runtimes when resolving distributed ranks in ``sim_launcher``. - Reassert the resolved device in ``PhysicsManager.initialize`` before backend-specific setup, covering direct ``SimulationContext`` use for every backend. - Keep ``wp.ScopedCapture(device=device)`` in Newton so graph ownership remains explicit. ## 4. Related work Extracted from #5823 so the product fix can be reviewed and landed independently of the multi-GPU CI infrastructure. ## 5. Test plan - [x] Regression test fails without device-scoped capture and passes with it - [x] Exact three-rank Newton camera training passes on three physical GPUs, including the committed tree merged with current ``develop`` - [x] Device synchronization, launcher, backend-ordering, and distributed-resolution tests: 28 passed - [x] Full Newton manager test file: 71 passed; two unrelated Kamino cases require a newer Newton API than the installed environment provides - [x] Changelog fragment gate - [x] ``./isaaclab.sh -f`` --- ...h-fix-newton-mgpu-capture-device-codex.rst | 5 ++ source/isaaclab/isaaclab/app/app_launcher.py | 7 ++- source/isaaclab/isaaclab/app/sim_launcher.py | 3 +- .../isaaclab/physics/physics_manager.py | 8 ++++ source/isaaclab/isaaclab/utils/_device.py | 27 +++++++++++ source/isaaclab/test/app/test_kwarg_launch.py | 12 +++++ .../devices/test_physics_manager_device.py | 46 +++++++++++++++++++ source/isaaclab/test/utils/test_device.py | 37 +++++++++++++++ ...h-fix-newton-mgpu-capture-device-codex.rst | 5 ++ .../isaaclab_newton/physics/newton_manager.py | 2 +- .../test_newton_manager_abstraction.py | 32 +++++++++++++ ...-fix-newton-mgpu-capture-device-codex.skip | 1 + .../test_distributed_device_resolution.py | 44 +++++++++--------- 13 files changed, 201 insertions(+), 28 deletions(-) create mode 100644 source/isaaclab/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst create mode 100644 source/isaaclab/isaaclab/utils/_device.py create mode 100644 source/isaaclab/test/devices/test_physics_manager_device.py create mode 100644 source/isaaclab/test/utils/test_device.py create mode 100644 source/isaaclab_newton/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst create mode 100644 source/isaaclab_tasks/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.skip diff --git a/source/isaaclab/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst b/source/isaaclab/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst new file mode 100644 index 00000000000..ba2c7b78ec0 --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed PyTorch and Warp selecting different CUDA devices during simulation + initialization. diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index b330724f39e..d538e3b6802 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -29,6 +29,7 @@ from isaacsim import SimulationApp from isaaclab.app.settings_manager import get_settings_manager, initialize_carb_settings +from isaaclab.utils._device import set_cuda_device # import logger logger = logging.getLogger(__name__) @@ -1081,13 +1082,11 @@ def _resolve_device_settings(self, launcher_args: dict): logger.info("Using device: %s", device) def _set_deferred_cuda_device(self) -> None: - """Set the current torch CUDA device after Kit startup.""" + """Set the current 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) + set_cuda_device(self._deferred_cuda_device_id) def _resolve_experience_file(self, launcher_args: dict): """Resolve experience file related settings.""" diff --git a/source/isaaclab/isaaclab/app/sim_launcher.py b/source/isaaclab/isaaclab/app/sim_launcher.py index fc69dd3cdc9..c1f82afc1dd 100644 --- a/source/isaaclab/isaaclab/app/sim_launcher.py +++ b/source/isaaclab/isaaclab/app/sim_launcher.py @@ -30,6 +30,7 @@ from isaaclab.physics.physics_manager_cfg import PhysicsCfg from isaaclab.renderers.renderer_cfg import RendererCfg from isaaclab.sensors.camera.camera_cfg import CameraCfg +from isaaclab.utils._device import set_cuda_device logger = logging.getLogger(__name__) @@ -379,7 +380,7 @@ def _resolve_distributed_device(cfg, launcher_args: argparse.Namespace | dict | sim_cfg = getattr(cfg, "sim", None) if sim_cfg is not None: sim_cfg.device = device_str - torch.cuda.set_device(device_str) + set_cuda_device(device_str) logger.info( "Distributed device resolved to %s (local_rank=%d, visible_gpus=%d)", device_str, diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 5255af87360..5c1a5d4403f 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -14,6 +14,8 @@ from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar +from isaaclab.utils._device import set_cuda_device + if TYPE_CHECKING: from isaaclab.scene_data import SceneDataBackend from isaaclab.sim.simulation_context import SimulationContext @@ -271,6 +273,12 @@ def initialize(cls, sim_context: SimulationContext) -> None: PhysicsManager._device = sim_context.cfg.device PhysicsManager._sim_time = 0.0 + # Synchronize the process-wide CUDA device before backend-specific + # initialization allocates state. PyTorch must select the device before + # Warp so that both runtimes retain the same primary CUDA context. + if "cuda" in PhysicsManager._device: + set_cuda_device(PhysicsManager._device) + @classmethod @abstractmethod def reset(cls, soft: bool = False) -> None: diff --git a/source/isaaclab/isaaclab/utils/_device.py b/source/isaaclab/isaaclab/utils/_device.py new file mode 100644 index 00000000000..020be59dfec --- /dev/null +++ b/source/isaaclab/isaaclab/utils/_device.py @@ -0,0 +1,27 @@ +# Copyright (c) 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 utilities for selecting compute devices.""" + +from __future__ import annotations + + +def set_cuda_device(device: str | int) -> None: + """Set the process-wide CUDA device for both PyTorch and Warp. + + PyTorch must select the device before Warp so that a newly imported Warp + runtime initializes on that device, or an existing runtime switches to it. + + Args: + device: CUDA device index or a device string such as ``"cuda:1"``. + """ + import torch + + torch.cuda.set_device(device) + + import warp as wp + + warp_device = f"cuda:{device}" if isinstance(device, int) else device + wp.set_device(warp_device) diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index 1e3782840a8..10aa804f973 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -80,6 +80,18 @@ def __init__(self, _launcher_args): assert close_args == {"exit_code": 1} +def test_deferred_cuda_device_synchronizes_torch_and_warp(monkeypatch: pytest.MonkeyPatch): + """The post-Kit device hook must synchronize both CUDA runtimes.""" + devices = [] + monkeypatch.setattr(app_launcher_module, "set_cuda_device", devices.append) + launcher = AppLauncher.__new__(AppLauncher) + launcher._deferred_cuda_device_id = 2 + + launcher._set_deferred_cuda_device() + + assert devices == [2] + + class _DummySettings: def __init__(self): self.values = {} diff --git a/source/isaaclab/test/devices/test_physics_manager_device.py b/source/isaaclab/test/devices/test_physics_manager_device.py new file mode 100644 index 00000000000..6b8face7f30 --- /dev/null +++ b/source/isaaclab/test/devices/test_physics_manager_device.py @@ -0,0 +1,46 @@ +# Copyright (c) 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 physics-manager CUDA device initialization.""" + +from types import SimpleNamespace + +from isaaclab.physics import PhysicsManager +from isaaclab.physics import physics_manager as physics_manager_module + + +def test_initialize_synchronizes_cuda_before_backend_setup(monkeypatch): + """The base manager must synchronize CUDA before backend-specific setup.""" + calls = [] + monkeypatch.setattr(physics_manager_module, "set_cuda_device", lambda device: calls.append(("cuda", device))) + monkeypatch.setattr(PhysicsManager, "_sim", None) + monkeypatch.setattr(PhysicsManager, "_cfg", None) + monkeypatch.setattr(PhysicsManager, "_device", "cuda:0") + + class _TestManager(PhysicsManager): + @classmethod + def initialize(cls, sim_context): + super().initialize(sim_context) + calls.append(("backend", PhysicsManager._device)) + + sim_context = SimpleNamespace(cfg=SimpleNamespace(physics=object(), device="cuda:2")) + + _TestManager.initialize(sim_context) + + assert calls == [("cuda", "cuda:2"), ("backend", "cuda:2")] + + +def test_initialize_does_not_synchronize_cpu_device(monkeypatch): + """CPU simulation must not initialize or select CUDA runtimes.""" + devices = [] + monkeypatch.setattr(physics_manager_module, "set_cuda_device", devices.append) + monkeypatch.setattr(PhysicsManager, "_sim", None) + monkeypatch.setattr(PhysicsManager, "_cfg", None) + monkeypatch.setattr(PhysicsManager, "_device", "cuda:0") + sim_context = SimpleNamespace(cfg=SimpleNamespace(physics=object(), device="cpu")) + + PhysicsManager.initialize(sim_context) + + assert devices == [] diff --git a/source/isaaclab/test/utils/test_device.py b/source/isaaclab/test/utils/test_device.py new file mode 100644 index 00000000000..7bfeacec133 --- /dev/null +++ b/source/isaaclab/test/utils/test_device.py @@ -0,0 +1,37 @@ +# Copyright (c) 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 compute-device selection utilities.""" + +import sys +from types import SimpleNamespace + +from isaaclab.utils._device import set_cuda_device + + +def test_set_cuda_device_sets_torch_before_warp(monkeypatch): + """Setting a CUDA device must update PyTorch before Warp.""" + calls = [] + torch = SimpleNamespace(cuda=SimpleNamespace(set_device=lambda device: calls.append(("torch", device)))) + warp = SimpleNamespace(set_device=lambda device: calls.append(("warp", device))) + monkeypatch.setitem(sys.modules, "torch", torch) + monkeypatch.setitem(sys.modules, "warp", warp) + + set_cuda_device("cuda:2") + + assert calls == [("torch", "cuda:2"), ("warp", "cuda:2")] + + +def test_set_cuda_device_normalizes_integer_for_warp(monkeypatch): + """An integer device index must be converted to a Warp CUDA alias.""" + calls = [] + torch = SimpleNamespace(cuda=SimpleNamespace(set_device=lambda device: calls.append(("torch", device)))) + warp = SimpleNamespace(set_device=lambda device: calls.append(("warp", device))) + monkeypatch.setitem(sys.modules, "torch", torch) + monkeypatch.setitem(sys.modules, "warp", warp) + + set_cuda_device(3) + + assert calls == [("torch", 3), ("warp", "cuda:3")] diff --git a/source/isaaclab_newton/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst b/source/isaaclab_newton/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst new file mode 100644 index 00000000000..c53da690a8d --- /dev/null +++ b/source/isaaclab_newton/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed Newton physics failing to initialize on non-default CUDA devices + (``cuda:1`` and higher). diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 69fdc6fe1d0..82d412eae05 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -1536,7 +1536,7 @@ def _capture_or_defer_graph(cls) -> None: 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: + with wp.ScopedCapture(device=device) as capture: simulate() NewtonManager._graph = capture.graph logger.info("Newton CUDA graph captured (standard Warp mode)") diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 56bf4c33f1f..f47e3a3232f 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -426,6 +426,38 @@ def test_mpm_unsupported_cuda_graph_capture_uses_eager_execution(monkeypatch): assert NewtonManager._graph_capture_pending is False +def test_cuda_graph_capture_uses_simulation_device(monkeypatch): + """CUDA graph capture should use the simulation device instead of Warp's default device.""" + from isaaclab.physics import PhysicsManager + + captured_devices = [] + captured_graph = object() + + class FakeScopedCapture: + def __init__(self, device=None): + captured_devices.append(device) + self.graph = captured_graph + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + return False + + monkeypatch.setattr(PhysicsManager, "_cfg", SimpleNamespace(use_cuda_graph=True), raising=False) + monkeypatch.setattr(PhysicsManager, "_device", "cuda:1", raising=False) + monkeypatch.setattr(NewtonManager, "_usdrt_stage", None, raising=False) + monkeypatch.setattr(NewtonManager, "_solver", None, raising=False) + monkeypatch.setattr(NewtonManager, "_is_all_graphable", classmethod(lambda cls: False)) + monkeypatch.setattr(NewtonManager, "_simulate_physics_only", classmethod(lambda cls: None)) + monkeypatch.setattr(wp, "ScopedCapture", FakeScopedCapture) + + NewtonManager._capture_or_defer_graph() + + assert captured_devices == ["cuda:1"] + assert NewtonManager._graph is captured_graph + + # --------------------------------------------------------------------------- # Manager class hierarchy and factory contracts # --------------------------------------------------------------------------- diff --git a/source/isaaclab_tasks/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.skip b/source/isaaclab_tasks/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.skip new file mode 100644 index 00000000000..f03e45edd95 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.skip @@ -0,0 +1 @@ +Test-only updates for shared CUDA device-selection coverage. diff --git a/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py b/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py index 67268cb816c..33de34289b5 100644 --- a/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py +++ b/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py @@ -15,8 +15,8 @@ - Non-distributed: no device override applied - launch_simulation device propagation from AppLauncher -No actual GPUs required — ``torch.cuda.device_count`` and -``torch.cuda.set_device`` are mocked throughout. +No actual GPUs required — ``torch.cuda.device_count`` and the shared CUDA +device-selection helper are mocked throughout. """ from __future__ import annotations @@ -95,7 +95,7 @@ def _make_env_vars( class TestResolveDistributedDeviceNamespace: """Tests for _resolve_distributed_device with argparse.Namespace args.""" - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=4) def test_normal_multi_gpu_rank0(self, mock_count, mock_set_device): """4 visible GPUs, world_size=4, rank 0 → cuda:0.""" @@ -109,7 +109,7 @@ def test_normal_multi_gpu_rank0(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:0" mock_set_device.assert_called_once_with("cuda:0") - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=4) def test_normal_multi_gpu_rank3(self, mock_count, mock_set_device): """4 visible GPUs, world_size=4, rank 3 → cuda:3.""" @@ -123,7 +123,7 @@ def test_normal_multi_gpu_rank3(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:3" mock_set_device.assert_called_once_with("cuda:3") - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=1) def test_cuda_visible_devices_restricted_rank0(self, mock_count, mock_set_device): """1 visible GPU (CUDA_VISIBLE_DEVICES set), world_size=2, rank 0 → cuda:0.""" @@ -137,7 +137,7 @@ def test_cuda_visible_devices_restricted_rank0(self, mock_count, mock_set_device assert env_cfg.sim.device == "cuda:0" mock_set_device.assert_called_once_with("cuda:0") - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=1) def test_cuda_visible_devices_restricted_rank1(self, mock_count, mock_set_device): """1 visible GPU, world_size=2, rank 1 → falls back to cuda:0 (not cuda:1).""" @@ -151,7 +151,7 @@ def test_cuda_visible_devices_restricted_rank1(self, mock_count, mock_set_device assert env_cfg.sim.device == "cuda:0" mock_set_device.assert_called_once_with("cuda:0") - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=2) def test_jax_local_rank_added(self, mock_count, mock_set_device): """JAX_LOCAL_RANK is added to LOCAL_RANK for correct device mapping.""" @@ -166,7 +166,7 @@ def test_jax_local_rank_added(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:1" mock_set_device.assert_called_once_with("cuda:1") - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=1) def test_jax_local_rank_with_restricted_gpus(self, mock_count, mock_set_device): """JAX_LOCAL_RANK + restricted GPUs → fallback to cuda:0.""" @@ -189,7 +189,7 @@ def test_jax_local_rank_with_restricted_gpus(self, mock_count, mock_set_device): class TestResolveDistributedDeviceDict: """Tests for _resolve_distributed_device with dict-style args.""" - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=4) def test_dict_args_distributed(self, mock_count, mock_set_device): """Dict launcher_args with distributed=True should work identically.""" @@ -203,7 +203,7 @@ def test_dict_args_distributed(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:2" mock_set_device.assert_called_once_with("cuda:2") - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=1) def test_dict_args_restricted(self, mock_count, mock_set_device): """Dict args with restricted GPUs should fall back to cuda:0.""" @@ -226,7 +226,7 @@ def test_dict_args_restricted(self, mock_count, mock_set_device): class TestResolveDistributedDeviceNoop: """Tests that non-distributed runs skip device resolution.""" - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") def test_not_distributed_namespace(self, mock_set_device): """distributed=False → device unchanged, set_device not called.""" env_cfg = _DummyEnvCfg(device="cuda:0") @@ -237,7 +237,7 @@ def test_not_distributed_namespace(self, mock_set_device): assert env_cfg.sim.device == "cuda:0" mock_set_device.assert_not_called() - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") def test_not_distributed_dict(self, mock_set_device): """Dict with distributed=False → no-op.""" env_cfg = _DummyEnvCfg(device="cuda:0") @@ -248,7 +248,7 @@ def test_not_distributed_dict(self, mock_set_device): assert env_cfg.sim.device == "cuda:0" mock_set_device.assert_not_called() - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") def test_no_distributed_key(self, mock_set_device): """Dict without 'distributed' key → no-op.""" env_cfg = _DummyEnvCfg(device="cuda:0") @@ -259,7 +259,7 @@ def test_no_distributed_key(self, mock_set_device): assert env_cfg.sim.device == "cuda:0" mock_set_device.assert_not_called() - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") def test_none_launcher_args(self, mock_set_device): """launcher_args=None → no-op.""" env_cfg = _DummyEnvCfg(device="cuda:0") @@ -278,7 +278,7 @@ def test_none_launcher_args(self, mock_set_device): class TestResolveDistributedDeviceEdgeCases: """Edge cases for device resolution.""" - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=2) def test_env_cfg_without_sim(self, mock_count, mock_set_device): """env_cfg with no 'sim' attribute → set_device still called, no crash.""" @@ -296,7 +296,7 @@ class _BareEnvCfg: # Should still call set_device even without sim_cfg mock_set_device.assert_called_once_with("cuda:1") - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=2) def test_world_size_equals_visible_gpus(self, mock_count, mock_set_device): """Exact match: 2 visible GPUs, world_size=2 → use local_rank directly.""" @@ -309,7 +309,7 @@ def test_world_size_equals_visible_gpus(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:1" - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=8) def test_more_gpus_than_world_size(self, mock_count, mock_set_device): """8 visible GPUs but only 2 ranks → use local_rank directly.""" @@ -322,7 +322,7 @@ def test_more_gpus_than_world_size(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:1" - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=0) def test_zero_visible_gpus(self, mock_count, mock_set_device): """0 visible GPUs → fallback to cuda:0 (will fail later at CUDA init).""" @@ -335,7 +335,7 @@ def test_zero_visible_gpus(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:0" - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=4) def test_missing_env_vars_default_to_zero(self, mock_count, mock_set_device): """Missing LOCAL_RANK/WORLD_SIZE → defaults to 0/1.""" @@ -364,7 +364,7 @@ def test_missing_env_vars_default_to_zero(self, mock_count, mock_set_device): class TestResolveDistributedDeviceMultiNode: """Tests for multi-node setups where WORLD_SIZE > local GPU count.""" - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=4) def test_multi_node_rank3_sees_4_gpus(self, mock_count, mock_set_device): """2 nodes × 4 GPUs, WORLD_SIZE=8, local_rank=3, 4 visible → cuda:3. @@ -382,7 +382,7 @@ def test_multi_node_rank3_sees_4_gpus(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:3" mock_set_device.assert_called_once_with("cuda:3") - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=4) def test_multi_node_rank0_sees_4_gpus(self, mock_count, mock_set_device): """2 nodes × 4 GPUs, WORLD_SIZE=8, local_rank=0 → cuda:0.""" @@ -396,7 +396,7 @@ def test_multi_node_rank0_sees_4_gpus(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:0" mock_set_device.assert_called_once_with("cuda:0") - @patch("torch.cuda.set_device") + @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=1) def test_multi_node_restricted_gpus(self, mock_count, mock_set_device): """Multi-node with CUDA_VISIBLE_DEVICES=, local_rank=1 → cuda:0.""" From d77540273399a9aeb850ce161bc4969537b43286 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:02:10 -0700 Subject: [PATCH 106/139] Update the central pyproject, remove subpackage specific dependencies (#6009) # Description Consolidates all dependencies into the central pyproject. This should make it easier to maintain a ground truth. ## 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 --- .github/workflows/daily-compatibility.yml | 15 +- .github/workflows/license-check.yaml | 6 +- .github/workflows/license-exceptions.json | 15 ++ docs/_extensions/isaaclab_docs.py | 87 ++++++- docs/conf.py | 40 ++- .../experimental-features/bleeding-edge.rst | 9 +- .../rlinf_vla_posttraining.rst | 6 +- docs/source/features/multi_gpu.rst | 6 +- docs/source/how-to/cloudxr_teleoperation.rst | 6 +- .../physical-backends/newton/installation.rst | 10 +- .../physical-backends/ovphysx/index.rst | 7 +- .../overview/core-concepts/renderers.rst | 6 +- .../include/selective_install.rst | 5 +- .../isaaclab_pip_installation.rst | 16 +- .../setup/installation/pip_installation.rst | 16 +- docs/source/setup/installation/uv_run.rst | 10 +- pyproject.toml | 210 ++++++++++++---- .../centralize-deps-root-pyproject.rst | 30 +++ ...saacsim-extra-newton-viewer-base.minor.rst | 19 ++ .../mh-cli-subcommand-entrypoints.major.rst | 8 + .../mh-isaacsim-install-single-source.rst | 7 + .../mh-usd-exchange-aarch64-only.rst | 11 + .../isaaclab/changelog.d/uv-root-dotenv.rst | 4 + source/isaaclab/isaaclab/cli/__init__.py | 4 +- .../isaaclab/isaaclab/cli/commands/install.py | 237 +++++++++++++++--- source/isaaclab/pyproject.toml | 83 +----- source/isaaclab/test/cli/test_install.py | 61 +++++ .../test/cli/test_install_command_parsing.py | 3 + .../test/cli/test_install_commands.py | 12 +- .../test/cli/test_source_package_metadata.py | 4 +- .../test/cli/test_uv_run_pyproject.py | 108 ++++++-- .../test/cli/test_wheel_builder_metadata.py | 131 ++++------ source/isaaclab/test/install_ci/utils.py | 13 + ...l_isaaclab_all_isaacsim_trains_cartpole.py | 13 +- ...lab_isaacsim_imports_simulation_context.py | 8 +- .../centralize-deps-root-pyproject.skip | 4 + source/isaaclab_contrib/pyproject.toml | 15 +- .../centralize-deps-root-pyproject.skip | 4 + source/isaaclab_mimic/pyproject.toml | 11 +- .../centralize-deps-root-pyproject.skip | 4 + source/isaaclab_newton/pyproject.toml | 10 +- .../centralize-deps-root-pyproject.skip | 4 + source/isaaclab_ov/pyproject.toml | 5 +- .../centralize-deps-root-pyproject.skip | 4 + source/isaaclab_ovphysx/pyproject.toml | 4 +- .../centralize-deps-root-pyproject.skip | 4 + source/isaaclab_physx/pyproject.toml | 7 +- .../centralize-deps-root-pyproject.skip | 4 + source/isaaclab_rl/pyproject.toml | 39 +-- .../centralize-deps-root-pyproject.skip | 4 + source/isaaclab_tasks/pyproject.toml | 9 +- .../core/test_train_scripts_deterministic.py | 4 +- .../centralize-deps-root-pyproject.skip | 4 + .../pyproject.toml | 5 +- .../centralize-deps-root-pyproject.skip | 4 + source/isaaclab_teleop/pyproject.toml | 8 +- .../centralize-deps-root-pyproject.skip | 4 + source/isaaclab_visualizers/pyproject.toml | 42 +--- tools/wheel_builder/build.sh | 4 +- tools/wheel_builder/gen_pyproject.py | 102 ++++++-- tools/wheel_builder/res/python_packages.toml | 128 ---------- 61 files changed, 1022 insertions(+), 641 deletions(-) create mode 100644 source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst create mode 100644 source/isaaclab/changelog.d/isaacsim-extra-newton-viewer-base.minor.rst create mode 100644 source/isaaclab/changelog.d/mh-cli-subcommand-entrypoints.major.rst create mode 100644 source/isaaclab/changelog.d/mh-isaacsim-install-single-source.rst create mode 100644 source/isaaclab/changelog.d/mh-usd-exchange-aarch64-only.rst create mode 100644 source/isaaclab/changelog.d/uv-root-dotenv.rst create mode 100644 source/isaaclab_contrib/changelog.d/centralize-deps-root-pyproject.skip create mode 100644 source/isaaclab_mimic/changelog.d/centralize-deps-root-pyproject.skip create mode 100644 source/isaaclab_newton/changelog.d/centralize-deps-root-pyproject.skip create mode 100644 source/isaaclab_ov/changelog.d/centralize-deps-root-pyproject.skip create mode 100644 source/isaaclab_ovphysx/changelog.d/centralize-deps-root-pyproject.skip create mode 100644 source/isaaclab_physx/changelog.d/centralize-deps-root-pyproject.skip create mode 100644 source/isaaclab_rl/changelog.d/centralize-deps-root-pyproject.skip create mode 100644 source/isaaclab_tasks/changelog.d/centralize-deps-root-pyproject.skip create mode 100644 source/isaaclab_tasks_experimental/changelog.d/centralize-deps-root-pyproject.skip create mode 100644 source/isaaclab_teleop/changelog.d/centralize-deps-root-pyproject.skip create mode 100644 source/isaaclab_visualizers/changelog.d/centralize-deps-root-pyproject.skip delete mode 100644 tools/wheel_builder/res/python_packages.toml diff --git a/.github/workflows/daily-compatibility.yml b/.github/workflows/daily-compatibility.yml index a92a9e6ccf0..26693761c27 100644 --- a/.github/workflows/daily-compatibility.yml +++ b/.github/workflows/daily-compatibility.yml @@ -95,6 +95,19 @@ jobs: fetch-depth: 1 lfs: true + # Single source of truth: read the OV runtime pins from [tool.isaaclab.versions]. + - name: Resolve OV runtime pins from pyproject + id: ov_pins + run: | + PINS=$(python3 <<'PY' + import re + block = re.search(r"^\[tool\.isaaclab\.versions\]\n(.*?)(?:\n\[|\Z)", open("pyproject.toml").read(), re.S | re.M).group(1) + vals = dict(re.findall(r'(\w+)\s*=\s*"([^"]+)"', block)) + print(f"ovphysx=={vals['ovphysx']} ovrtx{vals['ovrtx']}") + PY + ) + echo "pins=$PINS" >> "$GITHUB_OUTPUT" + - name: Build Docker Image uses: ./.github/actions/docker-build with: @@ -111,7 +124,7 @@ jobs: image-tag: ${{ env.DOCKER_IMAGE_TAG }} pytest-options: "" filter-pattern: "isaaclab_tasks" - extra-pip-packages: "ovrtx ovphysx==0.5.2+head.f62c22207c" + extra-pip-packages: ${{ steps.ov_pins.outputs.pins }} - name: Copy All Test Results from IsaacLab Tasks Container run: | diff --git a/.github/workflows/license-check.yaml b/.github/workflows/license-check.yaml index 45c2befc2dc..fe8275aa742 100644 --- a/.github/workflows/license-check.yaml +++ b/.github/workflows/license-check.yaml @@ -194,8 +194,10 @@ jobs: UV_FIND_LINKS: ${{ steps.extract-wheelhouse.outputs.wheelhouse_dir }} run: | uv sync --extra all - # Isaac Sim isn't in the dev pyproject; sync first so it isn't pruned. - uv pip install 'isaacsim[all,extscache]==${{ vars.ISAACSIM_BASE_VERSION || '6.0.0' }}' + # Isaac Sim conflicts with --extra all under uv, so install it imperatively + # after the sync. Read the pinned spec from pyproject (single source of truth). + ISAACSIM_SPEC=$(.venv/bin/python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['optional-dependencies']['isaacsim'][0])") + uv pip install "$ISAACSIM_SPEC" uv pip install pip-licenses pipdeptree \ -r tools/template/requirements.txt \ -r docs/requirements.txt diff --git a/.github/workflows/license-exceptions.json b/.github/workflows/license-exceptions.json index e1e93a2ae7e..ccf18478822 100644 --- a/.github/workflows/license-exceptions.json +++ b/.github/workflows/license-exceptions.json @@ -488,6 +488,11 @@ "license": "LicenseRef-NVIDIA-SOFTWARE-LICENSE", "comment": "NVIDIA" }, + { + "package": "cuda-toolkit", + "license": null, + "comment": "NVIDIA CUDA Toolkit EULA (LicenseRef-NVIDIA-SOFTWARE-LICENSE); wheel ships no license metadata" + }, { "package": "omniverseclient", "license": "NVIDIA Proprietary Software, https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-software-license-agreement/", @@ -507,5 +512,15 @@ "package": "pytetwild", "license": "Mozilla Public License 2.0 (MPL 2.0)", "comment": "MPL-2.0 / OSRB" + }, + { + "package": "distlib", + "license": "Python Software Foundation License", + "comment": "PSFL / OSRB" + }, + { + "package": "ovrtx", + "license": "NVIDIA Proprietary Software", + "comment": "NVIDIA" } ] diff --git a/docs/_extensions/isaaclab_docs.py b/docs/_extensions/isaaclab_docs.py index e2aa1648c95..395d68e2d92 100644 --- a/docs/_extensions/isaaclab_docs.py +++ b/docs/_extensions/isaaclab_docs.py @@ -111,7 +111,13 @@ def run(self) -> list[nodes.Node]: if "kitless" in self.options: content = _quickstart_kitless(branch, platform) elif "isaacsim" in self.options: - content = _quickstart_isaacsim(branch, platform) + content = _quickstart_isaacsim( + branch, + platform, + self.config.isaacsim_version, + self.config.torch_version, + self.config.torchvision_version, + ) else: raise self.error("Specify either :kitless: or :isaacsim:.") @@ -148,7 +154,69 @@ def _quickstart_kitless(branch: str, platform: str) -> str: """ -def _quickstart_isaacsim(branch: str, platform: str) -> str: +class IsaacLabIsaacSimInstall(SphinxDirective): + """Render the ``uv pip install isaacsim`` command pinned to the pyproject version.""" + + has_content = False + + def run(self) -> list[nodes.Node]: + version = self.config.isaacsim_version + content = f"""\ +.. code-block:: bash + + uv pip install "isaacsim[all,extscache]=={version}" --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow +""" + return _parse_rst(self, content) + + +class IsaacLabTorchInstall(SphinxDirective): + """Render the pinned ``torch``/``torchvision`` install command for a CUDA build. + + Versions come from ``[tool.isaaclab.versions]`` (the single source of truth), + exposed via the ``torch_version`` / ``torchvision_version`` config values. + + Usage:: + + .. isaaclab-torch-install:: cu128 + .. isaaclab-torch-install:: cu130 pip + """ + + required_arguments = 1 # CUDA build tag, e.g. "cu128" + optional_arguments = 1 # installer: "pip" (default is "uv pip") + + def run(self) -> list[nodes.Node]: + cuda_tag = self.arguments[0] + installer = "pip" if len(self.arguments) > 1 and self.arguments[1] == "pip" else "uv pip" + torch_version = self.config.torch_version + torchvision_version = self.config.torchvision_version + content = f"""\ +.. code-block:: bash + + {installer} install -U torch=={torch_version} torchvision=={torchvision_version} --index-url https://download.pytorch.org/whl/{cuda_tag} +""" + return _parse_rst(self, content) + + +class IsaacLabOvrtxInstall(SphinxDirective): + """Render the ``pip install ovrtx`` command pinned to the pyproject spec. + + The spec comes from ``[tool.isaaclab.versions].ovrtx``, exposed via the + ``ovrtx_spec`` config value. + """ + + has_content = False + + def run(self) -> list[nodes.Node]: + spec = self.config.ovrtx_spec + content = f"""\ +.. code-block:: bash + + pip install --extra-index-url https://pypi.nvidia.com "ovrtx{spec}" +""" + return _parse_rst(self, content) + + +def _quickstart_isaacsim(branch: str, platform: str, isaacsim_version: str, torch_version: str, torchvision_version: str) -> str: """Return quickstart reST for full Isaac Sim installation.""" if platform == "linux": return f"""\ @@ -160,10 +228,10 @@ def _quickstart_isaacsim(branch: str, platform: str) -> str: uv venv --python 3.12 --seed env_isaaclab source env_isaaclab/bin/activate uv pip install --upgrade pip - uv pip install "isaacsim[all,extscache]==6.0.0.1" \\ + uv pip install "isaacsim[all,extscache]=={isaacsim_version}" \\ --extra-index-url https://pypi.nvidia.com \\ --index-strategy unsafe-best-match --prerelease=allow - uv pip install -U torch==2.10.0 torchvision==0.25.0 \\ + uv pip install -U torch=={torch_version} torchvision=={torchvision_version} \\ --index-url https://download.pytorch.org/whl/cu128 ./isaaclab.sh -i """ @@ -178,10 +246,10 @@ def _quickstart_isaacsim(branch: str, platform: str) -> str: uv venv --python 3.12 --seed env_isaaclab env_isaaclab\\Scripts\\activate uv pip install --upgrade pip - uv pip install "isaacsim[all,extscache]==6.0.0.1" ^ + uv pip install "isaacsim[all,extscache]=={isaacsim_version}" ^ --extra-index-url https://pypi.nvidia.com ^ --index-strategy unsafe-best-match --prerelease=allow - uv pip install -U torch==2.10.0 torchvision==0.25.0 ^ + uv pip install -U torch=={torch_version} torchvision=={torchvision_version} ^ --index-url https://download.pytorch.org/whl/cu128 isaaclab.bat -i """ @@ -190,10 +258,17 @@ def _quickstart_isaacsim(branch: str, platform: str) -> str: def setup(app): """Register Isaac Lab documentation directives.""" app.add_config_value("isaaclab_latest_branch", "develop", "env") + app.add_config_value("isaacsim_version", "", "env") + app.add_config_value("torch_version", "", "env") + app.add_config_value("torchvision_version", "", "env") + app.add_config_value("ovrtx_spec", "", "env") app.add_directive("isaaclab-clone-commands", IsaacLabCloneCommands) app.add_directive("isaaclab-clone-https", IsaacLabCloneHttps) app.add_directive("isaaclab-kitless-install-snippet", IsaacLabKitlessInstallSnippet) app.add_directive("isaaclab-quickstart-install", IsaacLabQuickstartInstall) + app.add_directive("isaaclab-isaacsim-install", IsaacLabIsaacSimInstall) + app.add_directive("isaaclab-torch-install", IsaacLabTorchInstall) + app.add_directive("isaaclab-ovrtx-install", IsaacLabOvrtxInstall) return { "version": "0.1", "parallel_read_safe": True, diff --git a/docs/conf.py b/docs/conf.py index 8e47671af76..072abf78a68 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,6 +18,8 @@ import os import sys +import tomllib + sys.path.insert(0, os.path.abspath("_extensions")) sys.path.insert(0, os.path.abspath("../source/isaaclab")) sys.path.insert(0, os.path.abspath("../source/isaaclab/isaaclab")) @@ -62,11 +64,41 @@ # Latest release branch referenced by installation documentation. isaaclab_latest_branch = os.getenv("ISAACLAB_LATEST_BRANCH", "develop") + +def _read_pinned_versions() -> dict: + """Read the ``[tool.isaaclab.versions]`` table from the root pyproject. + + This table is the single source of truth for externally-pinned versions + (Isaac Sim, the torch stack, the OV renderer/physics wheels). + """ + pyproject = os.path.join(os.path.dirname(__file__), "..", "pyproject.toml") + with open(pyproject, "rb") as f: + return tomllib.load(f)["tool"]["isaaclab"]["versions"] + + +# Pinned external versions referenced by the installation docs. Shared with the +# ``isaaclab_docs`` extension via config values of the same name. +_pinned_versions = _read_pinned_versions() +isaacsim_version = _pinned_versions["isaacsim"] +torch_version = _pinned_versions["torch"] +torchvision_version = _pinned_versions["torchvision"] +ovrtx_spec = _pinned_versions["ovrtx"] +ovphysx_version = _pinned_versions["ovphysx"] + +# Short version strings used in external documentation URLs and badges. +torch_docs_version = ".".join(torch_version.split(".")[:2]) # e.g. "2.11" +isaacsim_docs_version = ".".join(isaacsim_version.split(".")[:3]) # e.g. "6.0.0" + # Copy buttons on highlighted code blocks (including nested directive output). copybutton_selector = "div.highlight pre" rst_prolog = f""" .. |isaaclab_latest_branch| replace:: {isaaclab_latest_branch} +.. |isaacsim_version| replace:: {isaacsim_version} +.. |torch_version| replace:: {torch_version} +.. |torchvision_version| replace:: {torchvision_version} +.. |ovrtx_spec| replace:: {ovrtx_spec} +.. |ovphysx_version| replace:: {ovphysx_version} """ # -- General configuration --------------------------------------------------- @@ -155,9 +187,9 @@ "python": ("https://docs.python.org/3", None), "numpy": ("https://numpy.org/doc/stable/", None), "trimesh": ("https://trimesh.org/", None), - # NOTE: pinned to /docs/2.11/ because /docs/stable/objects.inv currently 404s - "torch": ("https://docs.pytorch.org/docs/2.11/", None), - "isaacsim": ("https://docs.isaacsim.omniverse.nvidia.com/6.0.0/py/", None), + # pinned to the release version because /docs/stable/objects.inv currently 404s + "torch": (f"https://docs.pytorch.org/docs/{torch_docs_version}/", None), + "isaacsim": (f"https://docs.isaacsim.omniverse.nvidia.com/{isaacsim_docs_version}/py/", None), "gymnasium": ("https://gymnasium.farama.org/", None), # NOTE: pinned to /stable/ because /objects.inv at the root currently 404s "warp": ("https://nvidia.github.io/warp/stable/", None), @@ -318,7 +350,7 @@ { "name": "Isaac Sim", "url": "https://developer.nvidia.com/isaac-sim", - "icon": "https://img.shields.io/badge/IsaacSim-6.0.0-silver.svg", + "icon": f"https://img.shields.io/badge/IsaacSim-{isaacsim_docs_version}-silver.svg", "type": "url", }, { diff --git a/docs/source/experimental-features/bleeding-edge.rst b/docs/source/experimental-features/bleeding-edge.rst index df806e35239..4bbe4d7b026 100644 --- a/docs/source/experimental-features/bleeding-edge.rst +++ b/docs/source/experimental-features/bleeding-edge.rst @@ -28,11 +28,10 @@ only the dependencies needed for the features you use: .. code-block:: bash - # Install the base contrib package - uv pip install -e "source/isaaclab_contrib" - - # Install with optional extras (e.g., for RLinf VLA post-training) - uv pip install -e "source/isaaclab_contrib[rlinf]" + # The base contrib package is installed with the core Isaac Lab packages. + # Install optional dependencies (e.g., for RLinf VLA post-training) via the + # matching root extra. --inexact keeps the existing environment untouched. + uv sync --inexact --extra rlinf Current Contributions --------------------- diff --git a/docs/source/experimental-features/rlinf_vla_posttraining.rst b/docs/source/experimental-features/rlinf_vla_posttraining.rst index 46ee8e094d9..56d2e2bc25e 100644 --- a/docs/source/experimental-features/rlinf_vla_posttraining.rst +++ b/docs/source/experimental-features/rlinf_vla_posttraining.rst @@ -72,10 +72,12 @@ From the Isaac Lab root directory: # (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 + # Step 1: Install safe dependencies via the rlinf extra # NOTE: On DGX Spark / aarch64 systems, build decord from source first # (see "Building decord on DGX Spark / aarch64" below), then run this step. - uv pip install -e "source/isaaclab_contrib[rlinf]" + # --inexact keeps the existing environment (e.g. Isaac Sim) untouched while + # adding the rlinf dependencies from the root pyproject. + uv sync --inexact --extra rlinf # 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 diff --git a/docs/source/features/multi_gpu.rst b/docs/source/features/multi_gpu.rst index dab0fc3b486..a6f05083af8 100644 --- a/docs/source/features/multi_gpu.rst +++ b/docs/source/features/multi_gpu.rst @@ -294,7 +294,7 @@ Single-node training (defaults to all available GPUs): .. code-block:: bash - uv run train_multigpu \ + uv run isaaclab train_multigpu \ --task Isaac-Reorient-KukaAllegro \ --num_envs 4096 --max_iterations 100 @@ -318,7 +318,7 @@ Override the GPU count or torchrun settings when needed: .. code-block:: bash - uv run train_multigpu --num_gpus 4 --master_port 29504 \ + uv run isaaclab train_multigpu --num_gpus 4 --master_port 29504 \ --task Isaac-Reorient-KukaAllegro \ --num_envs 4096 --max_iterations 100 @@ -344,7 +344,7 @@ For skrl JAX training, pass an integer GPU count and the ``--coordinator_address .. code-block:: bash - uv run train_multigpu --rl_library skrl --ml_framework jax --num_gpus 4 \ + uv run isaaclab train_multigpu --rl_library skrl --ml_framework jax --num_gpus 4 \ --coordinator_address localhost:5000 \ --task Isaac-Reorient-KukaAllegro \ --num_envs 4096 --max_iterations 100 diff --git a/docs/source/how-to/cloudxr_teleoperation.rst b/docs/source/how-to/cloudxr_teleoperation.rst index c13068bafff..38f1145284e 100644 --- a/docs/source/how-to/cloudxr_teleoperation.rst +++ b/docs/source/how-to/cloudxr_teleoperation.rst @@ -218,9 +218,9 @@ choose the tab that matches your hardware. .. note:: The web client URL is versioned. The ``release-1.3.x`` path corresponds to the - Isaac Teleop version Isaac Lab is pinned to (``isaacteleop~=1.3.0`` in - ``source/isaaclab_teleop/pyproject.toml``). When Isaac Lab bumps its Isaac Teleop - pin, update this link to the matching client release. + Isaac Teleop version Isaac Lab is pinned to (``isaacteleop~=1.3.0`` in the + ``teleop`` extra of the root ``pyproject.toml``). When Isaac Lab bumps its Isaac + Teleop pin, update this link to the matching client release. .. tip:: diff --git a/docs/source/overview/core-concepts/physical-backends/newton/installation.rst b/docs/source/overview/core-concepts/physical-backends/newton/installation.rst index 8a49b781fb9..e864ae8187c 100644 --- a/docs/source/overview/core-concepts/physical-backends/newton/installation.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/installation.rst @@ -49,17 +49,13 @@ Ensure pip is up to date: uv pip install --upgrade pip -[Optional] Install Isaac Sim 6.0: +[Optional] Install Isaac Sim: -.. code-block:: bash - - uv pip install "isaacsim[all,extscache]==6.0.0.1" --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow +.. isaaclab-isaacsim-install:: Install the correct version of torch and torchvision: -.. code-block:: bash - - uv pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 +.. isaaclab-torch-install:: cu128 Install Isaac Lab extensions and dependencies (this includes Newton 1.0): diff --git a/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst b/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst index 72db1343c95..3120d1dce06 100644 --- a/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst +++ b/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst @@ -85,10 +85,9 @@ You can also install all OV runtime wheels with: ./isaaclab.sh -i 'ov[all]' -The ``ov[ovphysx]`` selector installs ``source/isaaclab_ovphysx`` with its -``[ovphysx]`` extra. If the wheel is missing, OvPhysX-specific tests skip with -``ovphysx wheel not installed`` and user code raises an install hint when it -first imports the runtime-backed modules. +The ``ov[ovphysx]`` selector installs the ``ovphysx`` runtime wheel declared by +the root ``pyproject.toml`` ``ov`` extra. If the wheel is missing, OvPhysX-specific +tests skip with ``ovphysx wheel not installed`` and user code fails at import time. Testing the Installation ------------------------ diff --git a/docs/source/overview/core-concepts/renderers.rst b/docs/source/overview/core-concepts/renderers.rst index d2ff6a4ca92..72d2d9f9de0 100644 --- a/docs/source/overview/core-concepts/renderers.rst +++ b/docs/source/overview/core-concepts/renderers.rst @@ -133,11 +133,9 @@ Install via the Isaac Lab CLI using the ``ov[ovrtx]`` token: packages are already part of the core install). Use ``ov[ovrtx]`` (or ``ov[all]``) to pull in the ``ovrtx`` dependency. -Or install manually with pip (note the ``[ovrtx]`` extra and the extra index URL): +Or install the ``ovrtx`` runtime wheel directly with pip (note the extra index URL): -.. code-block:: bash - - pip install --extra-index-url https://pypi.nvidia.com -e "source/isaaclab_ov[ovrtx]" +.. isaaclab-ovrtx-install:: - **Opaque render data**: The render data object returned by :meth:`~isaaclab.renderers.BaseRenderer.create_render_data` is passed to subsequent renderer methods. It should be completely opaque to the caller: inspecting or modifying it diff --git a/docs/source/setup/installation/include/selective_install.rst b/docs/source/setup/installation/include/selective_install.rst index 7b1a86f6d3e..5abc9d17c16 100644 --- a/docs/source/setup/installation/include/selective_install.rst +++ b/docs/source/setup/installation/include/selective_install.rst @@ -38,8 +38,9 @@ package—request those explicitly when needed. * - Token - What it installs * - ``newton`` - - Newton physics dependencies on ``isaaclab_newton``, ``isaaclab_physx``, - and ``isaaclab_visualizers`` (selectors are not supported) + - Newton interactive viewer GUI dependencies (``imgui-bundle``, + ``typing-extensions``). The Newton physics engine itself is a core + dependency that is always installed (selectors are not supported). * - ``rl[]`` - RL framework extras on ``isaaclab_rl``. Selectors: ``rsl-rl``, ``skrl``, ``sb3``, ``rl-games``. Omit the selector to install all frameworks. diff --git a/docs/source/setup/installation/isaaclab_pip_installation.rst b/docs/source/setup/installation/isaaclab_pip_installation.rst index 1fa49c5532c..f95571a8d49 100644 --- a/docs/source/setup/installation/isaaclab_pip_installation.rst +++ b/docs/source/setup/installation/isaaclab_pip_installation.rst @@ -30,7 +30,7 @@ pip extras include: * - Extra - What it installs * - ``isaacsim`` - - Isaac Sim (``isaacsim[all,extscache]==6.0.0.1``) from `pypi.nvidia.com `_ + - Isaac Sim (``isaacsim[all,extscache]`` version |isaacsim_version|) from `pypi.nvidia.com `_ * - ``all`` - RL frameworks (SB3, SKRL, RSL-RL). Combine with ``isaacsim`` for a full install. @@ -62,7 +62,7 @@ Install with ``isaaclab[isaacsim,all]`` for the full workflow. Installing dependencies ~~~~~~~~~~~~~~~~~~~~~~~ -- Install a CUDA-enabled PyTorch 2.10.0 build that matches your system architecture: +- Install a CUDA-enabled PyTorch |torch_version| build that matches your system architecture: .. tab-set:: :sync-group: pip-platform @@ -70,23 +70,17 @@ Installing dependencies .. tab-item:: :icon:`fa-brands fa-linux` Linux (x86_64) :sync: linux-x86_64 - .. code-block:: bash - - pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 + .. isaaclab-torch-install:: cu128 pip .. tab-item:: :icon:`fa-brands fa-windows` Windows (x86_64) :sync: windows-x86_64 - .. code-block:: bash - - pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 + .. isaaclab-torch-install:: cu128 pip .. tab-item:: :icon:`fa-brands fa-linux` Linux (aarch64) :sync: linux-aarch64 - .. code-block:: bash - - pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu130 + .. isaaclab-torch-install:: cu130 pip .. note:: diff --git a/docs/source/setup/installation/pip_installation.rst b/docs/source/setup/installation/pip_installation.rst index 6b324b2cabb..40007c72afe 100644 --- a/docs/source/setup/installation/pip_installation.rst +++ b/docs/source/setup/installation/pip_installation.rst @@ -49,9 +49,7 @@ Installing dependencies - Install Isaac Sim pip packages: - .. code-block:: bash - - uv pip install "isaacsim[all,extscache]==6.0.0.1" --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow + .. isaaclab-isaacsim-install:: - Install a CUDA-enabled PyTorch build that matches your system architecture: @@ -61,23 +59,17 @@ Installing dependencies .. tab-item:: :icon:`fa-brands fa-linux` Linux (x86_64) :sync: linux-x86_64 - .. code-block:: bash - - uv pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 + .. isaaclab-torch-install:: cu128 .. tab-item:: :icon:`fa-brands fa-windows` Windows (x86_64) :sync: windows-x86_64 - .. code-block:: bash - - uv pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 + .. isaaclab-torch-install:: cu128 .. tab-item:: :icon:`fa-brands fa-linux` Linux (aarch64) :sync: linux-aarch64 - .. code-block:: bash - - uv pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu130 + .. isaaclab-torch-install:: cu130 .. note:: diff --git a/docs/source/setup/installation/uv_run.rst b/docs/source/setup/installation/uv_run.rst index 451badcd2e6..4421c83e736 100644 --- a/docs/source/setup/installation/uv_run.rst +++ b/docs/source/setup/installation/uv_run.rst @@ -20,13 +20,17 @@ Clone the repo and start training immediately — no virtual environment setup r .. code-block:: bash # Newton backend training without Isaac Sim - uv run train --rl_library rsl_rl \ + uv run isaaclab train --rl_library rsl_rl \ --task Isaac-Cartpole-Direct physics=newton_mjwarp # Add OVRTX/OVPhysX extras only when the workflow needs them - uv run --extra ov --extra rtx train --rl_library rsl_rl \ + uv run --extra ov --extra rtx isaaclab train --rl_library rsl_rl \ --task Isaac-Cartpole-Direct physics=newton_mjwarp + # PhysX backend: --extra isaacsim pulls in Isaac Sim on the fly + uv run --extra isaacsim isaaclab train --rl_library rsl_rl \ + --task Isaac-Cartpole-Direct presets=physx + ``uv`` resolves and manages the environment automatically on each invocation. Supported libraries for ``--rl_library`` are: ``rsl_rl``, ``rl_games``, ``skrl``, ``sb3``, and ``rlinf``. @@ -36,4 +40,4 @@ Play / Evaluation .. code-block:: bash - uv run play --rl_library rsl_rl --task + uv run isaaclab play --rl_library rsl_rl --task diff --git a/pyproject.toml b/pyproject.toml index 6e45542c6c0..92cca8dfdf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,63 +8,164 @@ name = "isaaclab-dev" version = "0.1.0" description = "Isaac Lab source checkout development environment." requires-python = ">=3.12,<3.13" +# Single source of truth for all dependencies, also read by the wheel builder and +# ``./isaaclab.sh -i``. Workspace members resolve to ``source/`` via [tool.uv.sources]. dependencies = [ + # ----- workspace members (editable) ----- "isaaclab", "isaaclab-assets", "isaaclab-contrib", "isaaclab-experimental", - "isaaclab-newton[all]", + "isaaclab-newton", "isaaclab-ov", "isaaclab-ovphysx", - "isaaclab-physx[newton]", + "isaaclab-physx", "isaaclab-ppisp", - "isaaclab-rl[rsl-rl]", + "isaaclab-rl", "isaaclab-tasks", "isaaclab-tasks-experimental", "isaaclab-visualizers", - "torch==2.10.0", - "torchaudio==2.10.0", - "torchvision==0.25.0", + # ----- generic ----- + "numpy>=2", + "torch>=2.11", + "torchvision>=0.26.0", + "torchaudio>=2.11", + "onnx>=1.18.0", + "prettytable>=3.3.0", + "protobuf>=4.25.8,!=5.26.0", + "hidapi>=0.14.0", + "gymnasium>=1.2.0", + "trimesh", + "pyglet>=2.1.6,<3", + # x86_64-only wheel (sdist fails to build on aarch64). Pinned ==0.2.3: >=0.3 + # imports pyvista at import time. + "pytetwild==0.2.3 ; platform_machine in 'x86_64 AMD64'", + "transformers==4.57.6", + "einops", + "warp-lang==1.15.0.dev20260626", + "matplotlib>=3.10.3", + "pillow==12.1.1", # keep consistent with isaac sim + "botocore", # omni.replicator.core S3 backend + "starlette>=0.46.0,<0.50", # livestream; range coexists with isaacsim 6.0 + "omniverseclient==2.71.1.7015", + "filelock", + "lazy_loader>=0.4", + # IK controller (Linux only); 3.1.0/0.8.5 pins avoid a breaking change + "pin ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", + "pin-pink==3.1.0 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", + "daqp==0.8.5 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", + # OpenUSD (kit-less mode). Both packages vendor a complete ``pxr`` runtime, so + # they must never co-install: usd-core supplies pxr where it has wheels (x86_64), + # usd-exchange supplies it on aarch64 (where usd-core has no wheel). + "usd-core>=25.11,<26.0 ; platform_machine in 'x86_64 AMD64'", + "usd-exchange>=2.2 ; platform_machine in 'aarch64 arm64'", + # avoid broken hf-xet pre-release cached on NVIDIA Artifactory + "hf-xet>=1.4.1,<2.0.0 ; platform_machine in 'x86_64 AMD64 aarch64 arm64'", + # ----- tasks ----- + "tensorboard", + "numba>=0.63.1", + # ----- rl utilities ----- + "hydra-core", + "h5py>=3.15.0", + "moviepy", + "packaging", + "tqdm>=4.67.1", + "rsl-rl-lib==5.4.1", # default RL framework + "onnxscript>=0.5", + # ----- newton (default physics engine) ----- + # Loose bound so the wheel co-resolves with isaacsim's newton[sim]==1.2.0 pin; the + # exact git commit is forced via [tool.uv].override-dependencies (uv sync only). + "newton[sim]>=1.2.0", + # Loose bound co-resolves with isaacsim's ==0.2.0; the override below forces >=0.3.1. + "newton-usd-schemas>=0.2.0", + "PyOpenGL-accelerate>=3.1.0", + # ----- newton interactive viewer GUI (in base, no extra required) ----- + "imgui-bundle>=1.92.5", + "typing-extensions>=4.12.2", ] [project.optional-dependencies] -contrib = [ - "isaaclab-contrib", -] -mimic = [ - "isaaclab-mimic", +# Developer/test tooling. +test = [ + "pytest", + "pytest-mock", + "junitparser", + "flatdict>=4.1.0", + "flaky", + # numba subclasses coverage.types.Tracer at import; >=7.6.1 restores that shim + "coverage>=7.6.1", ] -newton = [ - "isaaclab-newton[all]", - "isaaclab-physx[newton]", - "isaaclab-visualizers[newton]", +# RL frameworks (rsl-rl is the default, in core deps); install the one you train with. +sb3 = ["stable-baselines3>=2.6", "tqdm", "rich"] +skrl = ["skrl>=2.1.0"] +rl-games = [ + "aiohttp>=3.12.14", + "rl-games @ git+https://github.com/isaac-sim/rl_games.git@python3.11", + "gym", + "standard-distutils", ] -ov = [ - "isaaclab-ovphysx[ovphysx]", +rsl-rl = ["rsl-rl-lib==5.4.1", "onnxscript>=0.5"] +# No ``newton`` extra: the viewer GUI ships in the base install; ``-i newton`` is a no-op. +# Visualizer backends. +viser = ["viser>=1.0.16"] +rerun = [ + "rerun-sdk>=0.29.0", + "pyarrow==22.0.0", # match rerun-sdk's Arrow stack ] -rl = [ - "isaaclab-rl[rsl-rl]", +# Isaac Sim (PhysX backend); co-resolves with the base install via the [tool.uv] conflicts table. +isaacsim = ["isaacsim[all,extscache]==6.0.0.1"] +# Omniverse renderer / physics backends (``ov[ovphysx|ovrtx|all]`` selectors). +ov = ["ovphysx==0.5.2+head.f62c22207c"] +rtx = ["ovrtx>=0.3.0,<0.4.0"] +mimic = [ + "isaaclab-mimic", + "ipywidgets>=8.1.5", + "robomimic @ git+https://github.com/ARISE-Initiative/robomimic.git@v0.4.0 ; sys_platform == 'linux'", ] -rl-all = [ - "isaaclab-rl[all]", +teleop = [ + "isaaclab-teleop", + # IsaacTeleop is Linux x86_64 only + "isaacteleop[retargeters,ui,cloudxr]~=1.3.0 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64'", + "dex-retargeting==0.5.0 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64'", ] -rtx = [ - "isaaclab-ov[ovrtx]", +# RLinf VLA post-training (externally contributed). +rlinf = [ + "ray[default]>=2.47.0", + "av>=12.3.0", + "numpydantic>=1.7.0", + "albumentations>=1.4.18", + "decord2", + "dm_tree>=0.1.8", + "diffusers>=0.35.0", + "timm>=1.0.14", + "peft>=0.17.0", + "pandas", ] +# Aggregate of the compatible extras. Omits teleop/ov/viser, whose pins clash +# with isaacsim (lxml, packaging<24, websockets); install those via their own extra. all = [ - "isaaclab-mimic", - "isaaclab-newton[all]", - "isaaclab-physx[newton]", - "isaaclab-ppisp", - "isaaclab-rl[all]", - "isaaclab-visualizers[all]", + "isaaclab-dev[sb3,skrl,rl-games,rsl-rl,rerun,rlinf,mimic]", ] +# Single source of truth for externally-pinned versions, read by docs/conf.py, the +# install CLI, and CI. TOML cannot interpolate, so the pins in [project.dependencies], +# the extras above, and [tool.uv].override-dependencies mirror these values; +# ``test_version_single_source`` fails CI if they drift. +[tool.isaaclab.versions] +isaacsim = "6.0.0.1" +torch = "2.11.0" +torchvision = "0.26.0" +torchaudio = "2.11.0" +ovphysx = "0.5.2+head.f62c22207c" +ovrtx = ">=0.3.0,<0.4.0" +# Newton git commit + matching warp-lang prerelease (upgraded together). +newton = "c7ae7c7648cd0717df39e5c94b95d5a02c997320" +warp = "1.15.0.dev20260626" + [tool.ruff] line-length = 120 target-version = "py310" -# Exclude directories extend-exclude = [ "logs", "_isaac_sim", @@ -74,7 +175,6 @@ extend-exclude = [ ] [tool.ruff.lint] -# Enable flake8 rules and other useful ones select = [ "E", # pycodestyle errors "W", # pycodestyle warnings @@ -87,7 +187,6 @@ select = [ "RET", # flake8-return ] -# Ignore specific rules (matching your flake8 config) ignore = [ "E402", # Module level import not at top of file "D401", # First line should be in imperative mood @@ -113,28 +212,23 @@ convention = "google" [tool.ruff.lint.isort] -# Custom import sections with separate sections for each Isaac Lab extension section-order = [ "future", "standard-library", "third-party", - # Group omniverse extensions separately since they are run-time dependencies - # which are pulled in by Isaac Lab extensions + # omniverse extensions are run-time deps pulled in by Isaac Lab extensions "omniverse-extensions", - # Group Isaac Lab extensions together since they are all part of the Isaac Lab project "isaaclab", "isaaclab-contrib", "isaaclab-rl", "isaaclab-mimic", "isaaclab-tasks", "isaaclab-assets", - # First-party is reserved for project templates - "first-party", + "first-party", # reserved for project templates "local-folder", ] [tool.ruff.lint.isort.sections] -# Define what belongs in each custom section "omniverse-extensions" = [ "isaacsim", @@ -174,10 +268,8 @@ pythonVersion = "3.12" pythonPlatform = "Linux" enableTypeIgnoreComments = true -# This is required as the CI pre-commit does not download the module (i.e. numpy, torch, prettytable) -# Therefore, we have to ignore missing imports +# CI pre-commit does not install modules (numpy, torch, ...), so ignore missing imports reportMissingImports = "none" -# This is required to ignore for type checks of modules with stubs missing. reportMissingModuleSource = "none" # -> most common: prettytable in mdp managers reportGeneralTypeIssues = "none" # -> raises 218 errors (usage of literal MISSING in dataclasses) @@ -222,13 +314,39 @@ 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. +# unsafe-best-match resolves NVIDIA deps with versions mismatched across +# pypi.nvidia.com and PyPI; prerelease=allow covers prerelease-only packages. [tool.uv] index-strategy = "unsafe-best-match" prerelease = "allow" -override-dependencies = ["numpy>=2"] +# Isaac Lab owns the Newton and torch versions. Overrides replace these requirements +# for EVERY requester (unlike constraints, which only intersect), so ``--extra isaacsim`` +# — which pins ``newton[sim]==1.2.0`` and its own torch stack — can neither block +# resolution nor downgrade an already-installed newton/torch. The torch stack routes to +# the CUDA indexes via [tool.uv.sources]. Values mirror [tool.isaaclab.versions] above. +override-dependencies = [ + "numpy>=2", + "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", + # Force the Newton-matched schemas over isaacsim's ==0.2.0 pin. + "newton-usd-schemas>=0.3.1", + "torch==2.11.0", + "torchvision==0.26.0", + "torchaudio==2.11.0", +] +# teleop (lxml>=5.2.2) and mimic (lxml<5.0.0, also in ``all``) cannot co-resolve; +# declaring the conflict lets uv fork the resolution instead of failing. +# isaacsim pins clash with teleop/ov/viser/mimic/test (and ``all`` aggregates +# several of these); forking keeps ``--extra isaacsim`` resolvable on its own. +conflicts = [ + [{ extra = "teleop" }, { extra = "mimic" }], + [{ extra = "teleop" }, { extra = "all" }], + [{ extra = "isaacsim" }, { extra = "teleop" }], + [{ extra = "isaacsim" }, { extra = "ov" }], + [{ extra = "isaacsim" }, { extra = "viser" }], + [{ extra = "isaacsim" }, { extra = "mimic" }], + [{ extra = "isaacsim" }, { extra = "all" }], + [{ extra = "isaacsim" }, { extra = "test" }], +] python-preference = "only-managed" package = false diff --git a/source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst b/source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst new file mode 100644 index 00000000000..ecab6b97e58 --- /dev/null +++ b/source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst @@ -0,0 +1,30 @@ +Changed +^^^^^^^ + +* Centralized all Isaac Lab third-party dependencies (required and optional) + into the root ``pyproject.toml`` as the single source of truth. The wheel + builder (``tools/wheel_builder/gen_pyproject.py``) and the ``./isaaclab.sh -i`` + install CLI now read the root project's ``dependencies`` and + ``optional-dependencies`` instead of per-sub-package declarations and + ``tools/wheel_builder/res/python_packages.toml`` (removed). Sub-package + ``pyproject.toml`` files no longer declare dependencies. The ``./isaaclab.sh -i`` + token syntax is unchanged. +* **Changed:** Newton (``newton[sim]``) is now a core dependency installed in + every environment as the default physics engine, rather than an opt-in extra. + The Newton interactive viewer GUI is also part of the base install, so the + ``newton`` optional extra has been removed; the ``newton`` install token / + ``--extra newton`` is now a no-op kept for backward compatibility. +* ``./isaaclab.sh -i`` now force-installs the pinned Newton git build (from + ``[tool.uv].override-dependencies``) over the older ``newton[sim]`` bundled by + Isaac Sim, so environments get the Newton version Isaac Lab targets. +* Added the ``[tool.isaaclab.versions]`` table to the root ``pyproject.toml`` as + the single source of truth for externally-pinned versions (Isaac Sim, the + torch stack, and the OV renderer/physics wheels). The install CLI, docs, and + CI read these values; a unit test enforces that the literal pins in the extras + and ``[tool.uv].override-dependencies`` stay in sync with the table. +* **Changed:** The aggregate ``all`` extra now contains only packages that can + co-resolve with ``isaacsim`` (the documented ``[all,isaacsim]`` install). + ``ov`` (OVRTX / OvPhysX), ``viser``, and the mimic USD-to-URDF converter + (``nvidia-srl-usd-to-urdf``) are no longer pulled in by ``all`` because their + pins conflict with isaacsim's; install them explicitly with ``--extra ov`` / + ``--extra viser`` / ``--extra mimic`` when not using isaacsim. diff --git a/source/isaaclab/changelog.d/isaacsim-extra-newton-viewer-base.minor.rst b/source/isaaclab/changelog.d/isaacsim-extra-newton-viewer-base.minor.rst new file mode 100644 index 00000000000..40d049fbeb4 --- /dev/null +++ b/source/isaaclab/changelog.d/isaacsim-extra-newton-viewer-base.minor.rst @@ -0,0 +1,19 @@ +Added +^^^^^ + +* Added an ``isaacsim`` extra to the root ``pyproject.toml`` so the PhysX + backend can be pulled in directly under uv, e.g. + ``uv run --extra isaacsim isaaclab train --task Isaac-Cartpole-Direct presets=physx``. + Isaac Sim narrows ``newton`` to its own pinned version, while the base install + otherwise tracks the latest ``newton[sim]>=1.2.0`` from the package index. + +Changed +^^^^^^^ + +* The Newton interactive viewer GUI (``imgui-bundle``, ``typing-extensions``) is + now part of the base install, so the viewer's HUD controls work without + ``--extra newton``. The ``newton`` extra / ``-i newton`` token is retained as a + backwards-compatible alias. +* The Isaac Sim version is now declared once in the root ``pyproject.toml`` + ``isaacsim`` extra and read from there by the documentation build and the + license-check CI workflow, instead of being hard-coded in each location. diff --git a/source/isaaclab/changelog.d/mh-cli-subcommand-entrypoints.major.rst b/source/isaaclab/changelog.d/mh-cli-subcommand-entrypoints.major.rst new file mode 100644 index 00000000000..95ad94c9eaf --- /dev/null +++ b/source/isaaclab/changelog.d/mh-cli-subcommand-entrypoints.major.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* **Breaking:** Removed the standalone ``train``, ``play``, and ``train_multigpu`` + console scripts. These are now subcommands of the ``isaaclab`` entry point + (``isaaclab train`` / ``isaaclab play`` / ``isaaclab train_multigpu``, e.g. + ``uv run isaaclab train ...``) so they no longer clash with ``train``/``play`` + commands provided by other installed packages. diff --git a/source/isaaclab/changelog.d/mh-isaacsim-install-single-source.rst b/source/isaaclab/changelog.d/mh-isaacsim-install-single-source.rst new file mode 100644 index 00000000000..dad00ed77d3 --- /dev/null +++ b/source/isaaclab/changelog.d/mh-isaacsim-install-single-source.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed ``./isaaclab.sh -i`` (PhysX path) to install the Isaac Sim requirement + pinned in the root ``pyproject.toml`` ``isaacsim`` extra, instead of a + hard-coded ``isaacsim[all]>=6.0.0``. The install CLI now shares the single + source of truth used by the docs, CI, and ``uv run --extra isaacsim``. diff --git a/source/isaaclab/changelog.d/mh-usd-exchange-aarch64-only.rst b/source/isaaclab/changelog.d/mh-usd-exchange-aarch64-only.rst new file mode 100644 index 00000000000..2f66a38115c --- /dev/null +++ b/source/isaaclab/changelog.d/mh-usd-exchange-aarch64-only.rst @@ -0,0 +1,11 @@ +Fixed +^^^^^ + +* Fixed a broken ``pxr`` (OpenUSD) runtime on x86_64 caused by co-installing + ``usd-core`` and ``usd-exchange``. Both wheels vendor a complete ``pxr`` + package at different USD versions, so installing both left a mixed + installation that raised ``RuntimeError: extension class wrapper for base + class ... Tf_PyEnumWrapper has not been created yet`` on ``import pxr``. + ``usd-exchange`` is now installed only on ``aarch64``/``arm64`` (where + ``usd-core`` has no wheel); x86_64 uses ``usd-core`` as the sole ``pxr`` + provider. diff --git a/source/isaaclab/changelog.d/uv-root-dotenv.rst b/source/isaaclab/changelog.d/uv-root-dotenv.rst new file mode 100644 index 00000000000..ff32f72cd94 --- /dev/null +++ b/source/isaaclab/changelog.d/uv-root-dotenv.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added documentation for ``uv run`` workflows with repo-local Isaac Sim source or binary installs. diff --git a/source/isaaclab/isaaclab/cli/__init__.py b/source/isaaclab/isaaclab/cli/__init__.py index d33dd001935..748a140b7e0 100644 --- a/source/isaaclab/isaaclab/cli/__init__.py +++ b/source/isaaclab/isaaclab/cli/__init__.py @@ -105,7 +105,7 @@ def cli() -> None: " 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" + " visualizer[kit|rerun|viser] (default: all)\n" " On Linux/macOS, quote selectors containing brackets:\n" " --install 'rl[rsl-rl]'\n" "\n" @@ -123,7 +123,7 @@ def cli() -> None: "Examples:\n" " ./isaaclab.sh -i\n" " ./isaaclab.sh -i core\n" - " ./isaaclab.sh -i newton,'rl[rsl-rl]'\n" + " ./isaaclab.sh -i '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 fe975f076c6..ec7fe246d21 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -318,9 +318,9 @@ def _ensure_cuda_torch() -> None: # Base index for torch. base_index = "https://download.pytorch.org/whl" - # Choose pins per arch. - torch_ver = "2.10.0" - tv_ver = "0.25.0" + # Pinned versions (single source of truth: [tool.isaaclab.versions]). + torch_ver = _pinned_version("torch") + tv_ver = _pinned_version("torchvision") if is_arm(): cuda_ver = "130" @@ -367,9 +367,42 @@ def _ensure_cuda_torch() -> None: run_command(pip_cmd + ["install", "--index-url", index_url, f"torch=={torch_ver}", f"torchvision=={tv_ver}"]) +def _ensure_newton() -> None: + """Install the pinned Newton git build, replacing any index version. + + Isaac Sim bundles ``newton[sim]==1.2.0``, which satisfies the loose core bound in + the root pyproject, so the centralized install would otherwise keep the older + Newton. Isaac Lab owns the exact commit via ``[tool.uv].override-dependencies`` + (``uv sync`` honors it, ``pip``/``uv pip`` installs do not), so force it in here + from that single source. + """ + overrides = _load_root_pyproject().get("tool", {}).get("uv", {}).get("override-dependencies", []) + requirement = next((r for r in overrides if _requirement_name(r) == "newton"), None) + if not requirement: + raise KeyError("Newton git pin is missing from [tool.uv].override-dependencies in the root pyproject.toml.") + commit = _pinned_version("newton") + # Newton-matched schemas (isaacsim pins the older ==0.2.0); force it alongside newton. + schemas = next((r for r in overrides if _requirement_name(r) == "newton-usd-schemas"), None) + + python_exe = extract_python_exe() + pip_cmd = get_pip_command(python_exe) + using_uv = pip_cmd[0] == "uv" + + # git installs record the commit in freeze output; skip if it is already present. + frozen = run_command(pip_cmd + ["freeze"], capture_output=True, text=True, check=False) + if frozen.returncode == 0 and any( + _requirement_name(line) == "newton" and commit in line for line in frozen.stdout.splitlines() + ): + print_info(f"Newton git build ({commit[:10]}) already installed.") + return + + print_info(f"Installing pinned Newton git build ({commit[:10]})...") + uninstall_flags = ["-y"] if not using_uv else [] + run_command(pip_cmd + ["uninstall"] + uninstall_flags + ["newton"], check=False) + run_command(pip_cmd + ["install", requirement, *([schemas] if schemas else [])]) + + # Isaac Sim install settings. -ISAACSIM_VERSION_SPEC = ">=6.0.0" -ISAACSIM_EXTRAS = "all" NVIDIA_INDEX_URL = "https://pypi.nvidia.com" @@ -384,6 +417,119 @@ def _requirement_name(requirement: str) -> str: return re.split(r"\s|<|>|=|!|~|\[|@", requirement, maxsplit=1)[0] +# Distributions installed from the PyTorch index by :func:`_ensure_cuda_torch`; +# excluded from the centralized core-dependency install so they are not pulled +# from PyPI first. +_TORCH_DISTRIBUTIONS = {"torch", "torchvision", "torchaudio"} + + +def _is_isaaclab_requirement(requirement: str) -> bool: + """Return True for ``isaaclab*`` self-references (installed as editable submodules).""" + return _normalize_package_name(_requirement_name(requirement)).startswith("isaaclab") + + +def _load_root_pyproject() -> dict: + """Load the root development ``pyproject.toml`` (single source of dependency truth).""" + with (ISAACLAB_ROOT / "pyproject.toml").open("rb") as fd: + return tomllib.load(fd) + + +def _pinned_version(package: str) -> str: + """Return the pinned version for ``package`` from ``[tool.isaaclab.versions]``. + + This table is the single source of truth for externally-pinned versions; the + literal pins in the extras and uv constraints mirror it. + + Args: + package: Key in the ``[tool.isaaclab.versions]`` table (e.g. ``"torch"``). + """ + versions = _load_root_pyproject().get("tool", {}).get("isaaclab", {}).get("versions", {}) + version = versions.get(package) + if not version: + raise KeyError(f"'{package}' is missing from [tool.isaaclab.versions] in the root pyproject.toml.") + return version + + +def _isaacsim_requirement() -> str: + """Return the pinned ``isaacsim`` requirement from the root ``isaacsim`` extra.""" + optional = _load_root_pyproject().get("project", {}).get("optional-dependencies", {}) + requirement = next((r for r in optional.get("isaacsim", []) if _requirement_name(r) == "isaacsim"), None) + if not requirement: + raise KeyError( + "The 'isaacsim' extra is missing from [project.optional-dependencies] in the root pyproject.toml." + ) + return requirement + + +def _root_core_dependencies() -> list[str]: + """Return the third-party core requirements declared in the root pyproject. + + Workspace members (installed as editable submodules) and the torch stack + (installed by :func:`_ensure_cuda_torch`) are excluded. + """ + project = _load_root_pyproject().get("project", {}) + dependencies = [] + for requirement in project.get("dependencies", []): + if _is_isaaclab_requirement(requirement): + continue + if _normalize_package_name(_requirement_name(requirement)) in _TORCH_DISTRIBUTIONS: + continue + dependencies.append(requirement) + return dependencies + + +def _root_extra_dependencies(extra: str) -> list[str]: + """Return the third-party requirements for a root ``optional-dependencies`` group. + + Workspace member self-references are stripped (the editable submodules are + installed separately). + + Args: + extra: Name of the optional-dependency group in the root pyproject. + """ + optional = _load_root_pyproject().get("project", {}).get("optional-dependencies", {}) + if extra not in optional: + print_warning(f"Unknown root extra '{extra}'. Available: {', '.join(sorted(optional))}. Skipping.") + return [] + return [requirement for requirement in optional[extra] if not _is_isaaclab_requirement(requirement)] + + +def _install_root_extra(extra: str) -> None: + """Install the third-party dependencies of a root ``optional-dependencies`` group.""" + dependencies = _root_extra_dependencies(extra) + if not dependencies: + return + python_exe = extract_python_exe() + pip_cmd = get_pip_command(python_exe) + print_info(f"Installing '{extra}' extra dependencies from the root pyproject...") + run_command(pip_cmd + ["install"] + dependencies) + + +def _install_centralized_dependencies(pip_cmd: list[str], optional_submodules: list[str]) -> None: + """Install the centralized third-party dependencies for the current install. + + The editable sub-packages no longer declare dependencies, so the core + requirements come from the root pyproject; the runtime extras for any + requested optional submodules are installed on top. + + Args: + pip_cmd: Base pip command (e.g. ``["uv", "pip"]`` or ``["python", "-m", "pip"]``). + optional_submodules: Names of requested optional submodules whose root + extras should also be installed. + """ + core_dependencies = _root_core_dependencies() + if core_dependencies: + print_info("Installing core dependencies from the root pyproject...") + run_command(pip_cmd + ["install"] + core_dependencies) + # dict preserves order while de-duplicating extras shared across submodules. + extras: dict[str, None] = {} + for submodule_name in optional_submodules: + for extra in OPTIONAL_SUBMODULE_ROOT_EXTRAS.get(submodule_name, ()): + extras.setdefault(extra) + for extra in extras: + _install_root_extra(extra) + + 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 @@ -507,7 +653,7 @@ def _install_isaacsim() -> None: pip_cmd + [ "install", - f"isaacsim[{ISAACSIM_EXTRAS}]{ISAACSIM_VERSION_SPEC}", + _isaacsim_requirement(), "--extra-index-url", NVIDIA_INDEX_URL, ] @@ -542,6 +688,18 @@ def _install_isaacsim() -> None: "teleop": ("isaaclab_teleop",), } +# Root pyproject optional-dependency groups that carry the third-party runtime +# requirements for each optional submodule (the submodules themselves no longer +# declare dependencies). Derived from OPTIONAL_ISAACLAB_SUBMODULES rather than +# redefined: each ``isaaclab_`` source dir maps to the same-named root +# extra (so ``mimic`` pulls in the ``teleop`` stack as well, matching the +# editable-install behavior it replaces). The extra names are validated against +# the root pyproject by :func:`_root_extra_dependencies` at install time. +OPTIONAL_SUBMODULE_ROOT_EXTRAS: dict[str, tuple[str, ...]] = { + submodule: tuple(directory.removeprefix("isaaclab_") for directory in directories) + for submodule, directories in OPTIONAL_ISAACLAB_SUBMODULES.items() +} + # 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 @@ -638,12 +796,8 @@ def _install_contrib_extra_dependencies(selector: str) -> None: ) 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}]"]) + _install_root_extra(selector) def _install_ov_extra_dependencies(selector: str) -> None: @@ -660,10 +814,6 @@ def _install_ov_extra_dependencies(selector: str) -> None: ) return - 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 @@ -674,19 +824,20 @@ def _install_ov_extra_dependencies(selector: str) -> None: ) if "all" in selectors: selectors.update({"ovrtx", "ovphysx"}) + # The ov[ovrtx] selector maps to the root 'rtx' extra; ov[ovphysx] to 'ov'. if "ovrtx" in selectors: print_info("Installing OVRTX optional dependency...") - run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_ov[ovrtx]"]) + _install_root_extra("rtx") if "ovphysx" in selectors: print_info("Installing OVPhysX optional dependency...") - run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_ovphysx[ovphysx]"]) + _install_root_extra("ov") 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. + Each feature maps the CLI token to one or more root ``optional-dependencies`` + groups and installs their third-party requirements. Args: feature_name: One of :data:`VALID_EXTRA_FEATURES`. @@ -694,29 +845,32 @@ def _install_extra_feature(feature_name: str, selector: str = "") -> None: ``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], pyglet, PyOpenGL-accelerate, imgui-bundle, typing-extensions)..." - ) - 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]"]) + print_warning(f"'newton' does not support selectors (got '{selector}').") + # The Newton physics engine and its interactive viewer GUI (imgui-bundle, + # typing-extensions) are part of the base install; this token is a no-op. + print_info("Newton (engine + viewer) is part of the base install; nothing to install.") elif feature_name == "rl": extra = selector if selector else "all" + # rl[all] installs every RL framework extra; other selectors map by name + # (rsl_rl -> rsl-rl, skrl, sb3, rl-games). + frameworks = {"sb3", "skrl", "rl-games", "rsl-rl"} if extra == "all" else {extra.replace("_", "-")} print_info(f"Installing RL framework extras: {extra}...") - run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_rl[{extra}]"]) + for framework in sorted(frameworks): + _install_root_extra(framework) elif feature_name == "visualizer": extra = selector if selector else "all" + backends = {"newton", "rerun", "viser"} if extra == "all" else {extra} print_info(f"Installing visualizer extras: {extra}...") - run_command(pip_cmd + ["install", "--editable", f"{source_dir}/isaaclab_visualizers[{extra}]"]) + for backend in sorted(backends): + # 'kit' (Omniverse-provided) and 'newton' (part of the base install) + # have no extra to install. + if backend in {"kit", "newton"}: + continue + _install_root_extra(backend) elif feature_name == "ov": _install_ov_extra_dependencies(selector) else: @@ -915,15 +1069,15 @@ def command_install(install_type: str = "all") -> None: optional submodules and extra features. Valid tokens: - Optional submodules: ``mimic``, ``teleop`` - - Extra features: ``contrib[rlinf]``, ``newton``, ``rl[]``, + - Extra features: ``contrib[rlinf]``, ``rl[]``, ``visualizer[]``, ``ov[ovrtx|ovphysx|all]`` - Special: ``isaacsim`` Examples:: - ./isaaclab.sh -i newton,rl[rsl-rl] + ./isaaclab.sh -i rl[rsl-rl] ./isaaclab.sh -i mimic,visualizer[rerun] - ./isaaclab.sh -i teleop,rl[skrl],newton + ./isaaclab.sh -i teleop,rl[skrl],ov[ovrtx] """ # Install system dependencies first. @@ -945,6 +1099,8 @@ def command_install(install_type: str = "all") -> None: extra_features: list[tuple[str, str]] = [] # List of (submodule_name, selector) tuples for optional submodule extras. optional_submodule_extra_dependencies: list[tuple[str, str]] = [] + # Names of requested optional submodules (used to install their root extras). + requested_optional_submodules: list[str] = [] def append_submodules_once(package_dirs: tuple[str, ...]) -> None: for pkg_dir in package_dirs: @@ -958,6 +1114,7 @@ def append_submodules_once(package_dirs: tuple[str, ...]) -> None: if install_type == "all": for package_dirs in OPTIONAL_ISAACLAB_SUBMODULES.values(): append_submodules_once(package_dirs) + requested_optional_submodules = list(OPTIONAL_ISAACLAB_SUBMODULES) extra_features = [(name, "") for name in sorted(VALID_EXTRA_FEATURES - MANUAL_EXTRA_FEATURES)] elif install_type == "core": # Core only — no optional submodules, no extra features. @@ -979,6 +1136,7 @@ def append_submodules_once(package_dirs: tuple[str, ...]) -> None: install_isaacsim = True elif name in OPTIONAL_ISAACLAB_SUBMODULES: append_submodules_once(OPTIONAL_ISAACLAB_SUBMODULES[name]) + requested_optional_submodules.append(name) if selector: optional_submodule_extra_dependencies.append((name, selector)) elif name in VALID_EXTRA_FEATURES: @@ -1054,6 +1212,15 @@ def append_submodules_once(package_dirs: tuple[str, ...]) -> None: # Install all submodules (core set + any explicitly requested optional ones). _install_isaaclab_submodules(submodules_to_install) + # The submodules no longer declare third-party dependencies; install the + # centralized core requirements (and optional-submodule extras) from the + # root pyproject. torch is excluded — it is handled by _ensure_cuda_torch. + _install_centralized_dependencies(pip_cmd, requested_optional_submodules) + + # Isaac Sim's bundled newton==1.2.0 satisfies the loose core bound, so force the + # pinned Newton git build (the default physics engine) over it. + _ensure_newton() + # Install requested optional submodule dependency extras. if optional_submodule_extra_dependencies: print_info("Installing optional submodule dependencies...") diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index ab47be147f5..720379442cc 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -16,89 +16,20 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["kit", "robotics", "learning", "ai"] requires-python = ">=3.12" -dependencies = [ - # generic - "numpy>=2", - "torch>=2.10", - "onnx>=1.18.0", - "prettytable>=3.3.0", - # devices - "hidapi>=0.14.0", - # reinforcement learning - "gymnasium>=1.2.0", - # procedural-generation - "trimesh", - "pyglet>=2.1.6,<3", - # pytetwild ships only an x86_64 manylinux wheel and its sdist fails to build on - # aarch64 (CMake hardcodes -m64). Gate it on x86_64 so ARM64 docker builds are - # not blocked; tetrahedralize callers degrade gracefully via ImportError when missing. - # Pinned to ==0.2.3: >=0.3 unconditionally imports pyvista at package import time. - "pytetwild==0.2.3 ; platform_machine in 'x86_64 AMD64'", - # image processing - "transformers==4.57.6", - "einops", - "warp-lang==1.15.0.dev20260626", - "matplotlib>=3.10.3", - # keep consistent with isaac sim version - "pillow==12.1.1", - # required by omni.replicator.core S3 backend - "botocore", - # livestream (range chosen to coexist with isaacsim 6.0) - "starlette>=0.46.0,<0.50", - "omniverseclient==2.71.1.7015", - # cross-platform file locking - "filelock", - "lazy_loader>=0.4", - # IK controller (Linux x86_64 + ARM64), requires pinning to 3.1.0 and 0.8.5 to avoid breaking change - "pin ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", - "pin-pink==3.1.0 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", - "daqp==0.8.5 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", - # OpenUSD (kit-less mode) - "usd-core>=25.11,<26.0 ; platform_machine in 'x86_64 AMD64'", - "usd-exchange>=2.2 ; platform_machine in 'aarch64 arm64'", - # avoid broken hf-xet pre-release cached on NVIDIA Artifactory - "hf-xet>=1.4.1,<2.0.0 ; platform_machine in 'x86_64 AMD64 aarch64 arm64'", -] +# Third-party requirements are centralized in the root pyproject.toml +# (single source of truth for the uv workspace, the wheel builder, and the +# ./isaaclab.sh -i install CLI). +dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" +# Only the ``isaaclab`` entry point is exposed; training/play are subcommands +# (``isaaclab train`` / ``isaaclab play`` / ``isaaclab train_multigpu``) so they +# do not clash with ``train``/``play`` scripts from other installed packages. [project.scripts] isaaclab = "isaaclab.cli:cli" -play = "isaaclab.cli:play" -train = "isaaclab.cli:train" -train_multigpu = "isaaclab.cli:train_multigpu" - -[project.optional-dependencies] -test = [ - "pytest", - "pytest-mock", - "junitparser", - "flatdict>=4.1.0", - "flaky", - # coverage>=7.6.1 restored coverage.types.Tracer as a shim; required for - # numba compatibility (numba subclasses Tracer at import time) - "coverage>=7.6.1", -] -isaacsim = ["isaacsim[all,extscache]>=6.0.0"] -all = [ - "isaacsim[all,extscache]>=6.0.0", - "isaaclab_assets", - "isaaclab_contrib", - "isaaclab_experimental", - "isaaclab_mimic", - "isaaclab_newton[all]", - "isaaclab_ov", - "isaaclab_ovphysx", - "isaaclab_physx[newton]", - "isaaclab_ppisp", - "isaaclab_rl[all]", - "isaaclab_tasks", - "isaaclab_tasks_experimental", - "isaaclab_teleop", - "isaaclab_visualizers[all]", -] [tool.setuptools] include-package-data = true diff --git a/source/isaaclab/test/cli/test_install.py b/source/isaaclab/test/cli/test_install.py index 45444ce7531..c27245f955d 100644 --- a/source/isaaclab/test/cli/test_install.py +++ b/source/isaaclab/test/cli/test_install.py @@ -231,6 +231,67 @@ def test_uses_package_metadata_when_no_version_file(self, tmp_path): # --------------------------------------------------------------------------- +class TestEnsureNewton: + """Tests for :func:`~isaaclab.cli.commands.install._ensure_newton`. + + Isaac Sim bundles ``newton[sim]==1.2.0``; the install CLI must force the pinned + Newton git build (sourced from ``[tool.uv].override-dependencies``) over it. + """ + + @staticmethod + def _completed(stdout: str = "", returncode: int = 0) -> subprocess.CompletedProcess: + return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="") + + def test_installs_pinned_git_build_when_absent(self): + """When the pinned commit is not installed, uninstall newton then install the git build.""" + from isaaclab.cli.commands import install + + commit = install._pinned_version("newton") + calls = [] + + def fake_run(cmd, *args, **kwargs): + calls.append(cmd) + return self._completed(stdout="numpy==2.0.0\n") if cmd[-1] == "freeze" else self._completed() + + with ( + mock.patch.object(install, "extract_python_exe", return_value="python"), + mock.patch.object(install, "get_pip_command", return_value=["uv", "pip"]), + mock.patch.object(install, "run_command", side_effect=fake_run), + ): + install._ensure_newton() + + assert any("uninstall" in cmd for cmd in calls), "old Newton should be uninstalled first" + install_cmds = [cmd for cmd in calls if "install" in cmd] + assert install_cmds, "expected a pip install call" + install_args = install_cmds[-1] + assert any(arg.startswith("newton[sim]") and arg.endswith(commit) for arg in install_args) + assert any(arg.startswith("newton-usd-schemas") for arg in install_args), "schemas must be forced too" + + def test_skips_when_commit_already_installed(self): + """When freeze already reports the pinned commit, do not reinstall.""" + from isaaclab.cli.commands import install + + commit = install._pinned_version("newton") + calls = [] + + def fake_run(cmd, *args, **kwargs): + calls.append(cmd) + if cmd[-1] == "freeze": + stdout = f"newton @ git+https://github.com/newton-physics/newton.git@{commit}\n" + return self._completed(stdout=stdout) + return self._completed() + + with ( + mock.patch.object(install, "extract_python_exe", return_value="python"), + mock.patch.object(install, "get_pip_command", return_value=["uv", "pip"]), + mock.patch.object(install, "run_command", side_effect=fake_run), + ): + install._ensure_newton() + + assert not any("install" in cmd for cmd in calls), "should not install when commit already present" + assert not any("uninstall" in cmd for cmd in calls), "should not uninstall when commit already present" + + def test_no_shadowing_prebundled_torch_in_isaac_sim(): """A prebundled torch must not shadow the pip-installed torch. diff --git a/source/isaaclab/test/cli/test_install_command_parsing.py b/source/isaaclab/test/cli/test_install_command_parsing.py index 2db7370a433..8a6abe52313 100644 --- a/source/isaaclab/test/cli/test_install_command_parsing.py +++ b/source/isaaclab/test/cli/test_install_command_parsing.py @@ -160,6 +160,9 @@ def test_optional_submodules_not_in_core(self): f"{_INSTALL_MODULE}._install_isaaclab_submodules", f"{_INSTALL_MODULE}._install_extra_feature", f"{_INSTALL_MODULE}._install_optional_submodule_extra_dependencies", + # Centralized dependency installs read the root pyproject and shell out to pip. + f"{_INSTALL_MODULE}._root_core_dependencies", + f"{_INSTALL_MODULE}._install_root_extra", f"{_INSTALL_MODULE}._install_isaacsim", f"{_INSTALL_MODULE}._ensure_cuda_torch", f"{_INSTALL_MODULE}._maybe_preinstall_arm_nlopt", diff --git a/source/isaaclab/test/cli/test_install_commands.py b/source/isaaclab/test/cli/test_install_commands.py index 4c56de6a70b..fdf854f3e60 100644 --- a/source/isaaclab/test/cli/test_install_commands.py +++ b/source/isaaclab/test/cli/test_install_commands.py @@ -365,10 +365,10 @@ class TestEnsureCudaTorch: # ---- x86 scenarios ------------------------------------------------------- def test_x86_skips_install_when_correct_version_present(self, tmp_path): - """x86: torch 2.10.0+cu128 already installed → pip install is not called.""" + """x86: torch 2.11.0+cu128 already installed → pip install is not called.""" py = str(tmp_path / "python") pip_cmd = [py, "-m", "pip"] - pip_show_out = "Name: torch\nVersion: 2.10.0+cu128\n" + pip_show_out = "Name: torch\nVersion: 2.11.0+cu128\n" with ( mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), @@ -413,7 +413,7 @@ def test_x86_reinstalls_when_wrong_cuda_tag(self, tmp_path): def _run(cmd, **kwargs): calls.append(list(cmd)) - stdout = "Name: torch\nVersion: 2.10.0+cu130\n" if "show" in cmd else "" + stdout = "Name: torch\nVersion: 2.11.0+cu130\n" if "show" in cmd else "" return _cp(0, stdout) with ( @@ -454,10 +454,10 @@ def _run(cmd, **kwargs): assert "cu130" in combined def test_arm_skips_install_when_correct_version_present(self, tmp_path): - """ARM: torch 2.10.0+cu130 already installed → pip install is not called.""" + """ARM: torch 2.11.0+cu130 already installed → pip install is not called.""" py = str(tmp_path / "python") pip_cmd = [py, "-m", "pip"] - pip_show_out = "Name: torch\nVersion: 2.10.0+cu130\n" + pip_show_out = "Name: torch\nVersion: 2.11.0+cu130\n" with ( mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), @@ -477,7 +477,7 @@ def test_arm_reinstalls_when_wrong_cuda_tag(self, tmp_path): def _run(cmd, **kwargs): calls.append(list(cmd)) - stdout = "Name: torch\nVersion: 2.10.0+cu128\n" if "show" in cmd else "" + stdout = "Name: torch\nVersion: 2.11.0+cu128\n" if "show" in cmd else "" return _cp(0, stdout) with ( diff --git a/source/isaaclab/test/cli/test_source_package_metadata.py b/source/isaaclab/test/cli/test_source_package_metadata.py index 138d82211d4..5f2ff176899 100644 --- a/source/isaaclab/test/cli/test_source_package_metadata.py +++ b/source/isaaclab/test/cli/test_source_package_metadata.py @@ -25,7 +25,7 @@ def _repo_root() -> Path: def test_isaaclab_usd_core_pin_stays_on_isaacsim_compatible_usd25_abi(): """The kit-less USD package must stay on the Isaac Sim compatible USD 25 ABI.""" - with (_repo_root() / "source/isaaclab/pyproject.toml").open("rb") as f: + with (_repo_root() / "pyproject.toml").open("rb") as f: pyproject = tomllib.load(f) usd_core_dependencies = [ @@ -37,7 +37,7 @@ def test_isaaclab_usd_core_pin_stays_on_isaacsim_compatible_usd25_abi(): def test_isaaclab_standalone_usd_providers_are_platform_disjoint(): """Standalone USD packages must not overlap on platforms where both ship ``pxr``.""" - with (_repo_root() / "source/isaaclab/pyproject.toml").open("rb") as f: + with (_repo_root() / "pyproject.toml").open("rb") as f: pyproject = tomllib.load(f) usd_exchange_dependencies = [ diff --git a/source/isaaclab/test/cli/test_uv_run_pyproject.py b/source/isaaclab/test/cli/test_uv_run_pyproject.py index e208fb9e2a8..4731f80d10d 100644 --- a/source/isaaclab/test/cli/test_uv_run_pyproject.py +++ b/source/isaaclab/test/cli/test_uv_run_pyproject.py @@ -41,39 +41,101 @@ def test_uv_run_extra_names_match_documented_workflow(): 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.""" +def test_uv_run_exposes_centralized_feature_extras(): + """The root project centralizes optional third-party deps into named extras.""" optional_dependencies = _root_pyproject()["project"]["optional-dependencies"] + # Feature extras a user can activate with ``uv run --extra``. 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-ppisp", - "isaaclab-rl[all]", - "isaaclab-visualizers[all]", - ], + "test", + "sb3", + "skrl", + "rl-games", + "rsl-rl", + "viser", + "rerun", + "ov", + "rtx", + "mimic", + "teleop", + "rlinf", + "all", } + assert expected_extras <= set(optional_dependencies) - assert optional_dependencies == expected_extras - assert "isaacsim" not in optional_dependencies + # The Newton viewer GUI is part of the base install, so there is no ``newton`` extra. + assert "newton" not in optional_dependencies + + # Concrete third-party deps live in the extras (not subpackage self-references). + # OVPhysX and OVRTX are separate extras, selectable via ``ov[ovphysx]`` / ``ov[ovrtx]``. + assert any(dep.startswith("skrl") for dep in optional_dependencies["skrl"]) + assert any(dep.startswith("ovphysx") for dep in optional_dependencies["ov"]) + assert any(dep.startswith("ovrtx") for dep in optional_dependencies["rtx"]) + + +def test_version_single_source_matches_literal_pins(): + """``[tool.isaaclab.versions]`` is the single source for externally-pinned versions. + + TOML cannot interpolate, so the literal pins in ``[project.dependencies]``, + ``[project.optional-dependencies]``, and ``[tool.uv].override-dependencies`` must + mirror the table exactly. This test fails if any of them drift apart. + """ + pyproject = _root_pyproject() + versions = pyproject["tool"]["isaaclab"]["versions"] + dependencies = pyproject["project"]["dependencies"] + optional = pyproject["project"]["optional-dependencies"] + overrides = pyproject["tool"]["uv"]["override-dependencies"] + + # Isaac Sim extra mirrors the table. + assert optional["isaacsim"] == [f"isaacsim[all,extscache]=={versions['isaacsim']}"] + + # OV extras mirror the table (ovphysx exact pin in ``ov``, ovrtx range spec in ``rtx``). + assert f"ovphysx=={versions['ovphysx']}" in optional["ov"] + assert f"ovrtx{versions['ovrtx']}" in optional["rtx"] + + # uv torch-stack overrides mirror the table. + for package in ("torch", "torchvision", "torchaudio"): + assert f"{package}=={versions[package]}" in overrides + + # Newton git commit is pinned via a uv override; warp-lang is a core dependency. + assert any(dep.endswith(f"newton.git@{versions['newton']}") for dep in overrides) + assert f"warp-lang=={versions['warp']}" in dependencies + + +def test_uv_run_isaacsim_extra_is_conflict_forked(): + """Isaac Sim is an opt-in uv workspace extra, forked away from clashing extras. + + PhysX/Isaac Sim is never a base dependency, but it must be a real + ``optional-dependencies`` extra so ``uv run --extra isaacsim`` resolves. Its + exact pins clash with several other extras, so it is declared in + ``[tool.uv].conflicts`` (forked resolution) rather than co-resolved with them. + """ + pyproject = _root_pyproject() + project = pyproject["project"] + base_dependency_names = {re.split(r"[\s<>=!~\[;]", dep, maxsplit=1)[0] for dep in project["dependencies"]} + + # PhysX/Isaac Sim is opt-in, never installed by the bare ``uv run``. + assert "isaacsim" not in base_dependency_names + # ...but it is a workspace extra so ``uv run --extra isaacsim`` works. + assert "isaacsim" in project["optional-dependencies"] + assert any(dep.startswith("isaacsim[") for dep in project["optional-dependencies"]["isaacsim"]) + # The legacy wheel-only table is gone (isaacsim now lives in the extras). + assert "wheel-extras" not in pyproject.get("tool", {}).get("isaaclab", {}) + + # isaacsim is forked away from every extra whose pins clash with it. + conflict_groups = [{entry["extra"] for entry in group} for group in pyproject["tool"]["uv"]["conflicts"]] + for extra in ("teleop", "ov", "viser", "mimic", "all", "test"): + assert {"isaacsim", extra} in conflict_groups, f"isaacsim must declare a conflict with '{extra}'" def test_uv_run_base_dependencies_cover_newton_rsl_rl_training(): - """The documented bare ``uv run train`` command needs Newton and RSL-RL extras.""" + """The documented bare ``uv run isaaclab train`` command needs Newton and RSL-RL in core.""" dependencies = _root_pyproject()["project"]["dependencies"] - assert "isaaclab-newton[all]" in dependencies - assert "isaaclab-physx[newton]" in dependencies - assert "isaaclab-rl[rsl-rl]" in dependencies + # Newton is the default physics engine and RSL-RL the default training library, + # so both ship as core third-party requirements (not opt-in extras). + assert any(dep.startswith("newton[sim]") for dep in dependencies) + assert any(dep.startswith("rsl-rl-lib") for dep in dependencies) def test_uv_run_uses_managed_python(): diff --git a/source/isaaclab/test/cli/test_wheel_builder_metadata.py b/source/isaaclab/test/cli/test_wheel_builder_metadata.py index 055d2498732..cc878bf520f 100644 --- a/source/isaaclab/test/cli/test_wheel_builder_metadata.py +++ b/source/isaaclab/test/cli/test_wheel_builder_metadata.py @@ -3,10 +3,12 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Tests for wheel-builder package metadata.""" +"""Tests for wheel-builder package metadata generated from the root pyproject.""" from __future__ import annotations +import subprocess +import sys from pathlib import Path import pytest @@ -23,95 +25,62 @@ def _repo_root() -> Path: raise RuntimeError("Could not find Isaac Lab repository root.") -def _load_toml(relative_path: str) -> dict: - """Load a TOML file from the Isaac Lab repository root.""" - with (_repo_root() / relative_path).open("rb") as f: +def _root_rsl_rl_pin() -> str: + """Return the ``rsl-rl-lib`` pin declared by the root ``pyproject.toml`` core deps.""" + with (_repo_root() / "pyproject.toml").open("rb") as f: + data = tomllib.load(f) + for dependency in data["project"]["dependencies"]: + if dependency.startswith("rsl-rl-lib=="): + return dependency + raise AssertionError("Could not find rsl-rl-lib pin in the root pyproject.toml") + + +def _generate_wheel_pyproject(tmp_path: Path) -> dict: + """Run ``gen_pyproject.py`` against the root pyproject and return the parsed result.""" + repo_root = _repo_root() + output = tmp_path / "pyproject.toml" + subprocess.run( + [ + sys.executable, + str(repo_root / "tools/wheel_builder/gen_pyproject.py"), + str(repo_root / "pyproject.toml"), + str(output), + "3.0.0", + ], + check=True, + ) + with output.open("rb") as f: return tomllib.load(f) -def _single_dependency(dependencies: list[str], prefix: str, source: str) -> str: - """Return the only dependency in a list matching the provided prefix.""" - matches = [dependency for dependency in dependencies if dependency.startswith(prefix)] - assert len(matches) == 1, f"Expected one {prefix!r} dependency in {source}, got {matches}" - return matches[0] - - -def _wheel_builder_dependencies_by_extra() -> dict[str, list[str]]: - """Return the wheel-builder optional dependencies grouped by extra name.""" - packages = _load_toml("tools/wheel_builder/res/python_packages.toml") - optional_dependencies = packages["isaaclab"]["pyproject"]["optional-dependencies"]["all"] - return {name: dependencies for entry in optional_dependencies for name, dependencies in entry.items()} +def test_wheel_builder_drops_workspace_members(tmp_path): + """The generated wheel metadata must not depend on the bundled ``isaaclab*`` packages.""" + generated = _generate_wheel_pyproject(tmp_path) + dependencies = generated["project"]["dependencies"] + assert not [dep for dep in dependencies if dep.lower().startswith("isaaclab")] -def _rsl_rl_pin_from_pyproject() -> str: - """Return the ``rsl-rl-lib`` pin declared by ``source/isaaclab_rl/pyproject.toml``.""" - dependencies = _load_toml("source/isaaclab_rl/pyproject.toml")["project"]["optional-dependencies"]["rsl-rl"] - return _single_dependency(dependencies, "rsl-rl-lib==", "source/isaaclab_rl/pyproject.toml") +def test_wheel_builder_includes_isaacsim_extra(tmp_path): + """The ``isaacsim`` extra must ship in the generated wheel metadata.""" + generated = _generate_wheel_pyproject(tmp_path) + optional_dependencies = generated["project"]["optional-dependencies"] -def _newton_pin_from_pyproject(relative_path: str, extra_name: str) -> str: - """Return the ``newton[sim]`` direct URL pin declared by a package extra.""" - dependencies = _load_toml(relative_path)["project"]["optional-dependencies"][extra_name] - return _single_dependency( - dependencies, - "newton[sim] @ git+https://github.com/newton-physics/newton.git@", - f"{relative_path}[{extra_name}]", - ) - + assert "isaacsim" in optional_dependencies + assert any(dep.startswith("isaacsim[") for dep in optional_dependencies["isaacsim"]) -def _warp_pin_from_core_pyproject() -> str: - """Return the core ``warp-lang`` pin declared by ``source/isaaclab/pyproject.toml``.""" - dependencies = _load_toml("source/isaaclab/pyproject.toml")["project"]["dependencies"] - return _single_dependency(dependencies, "warp-lang==", "source/isaaclab/pyproject.toml") +def test_wheel_builder_rsl_rl_pin_matches_root_pyproject(tmp_path): + """The bundled wheel metadata must install the RSL-RL version declared at the root.""" + expected_pin = _root_rsl_rl_pin() + generated = _generate_wheel_pyproject(tmp_path) -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_pyproject() - dependencies_by_extra = _wheel_builder_dependencies_by_extra() + # RSL-RL is a core dependency (default training library) and also exposed as an extra. + core_pins = [dep for dep in generated["project"]["dependencies"] if dep.startswith("rsl-rl-lib==")] + assert core_pins == [expected_pin] + optional_dependencies = generated["project"]["optional-dependencies"] + # RSL-RL ships in its own ``rsl-rl`` extra and in the aggregate ``all`` extra. for extra_name in ("rsl-rl", "all"): - rsl_rl_pin = _single_dependency(dependencies_by_extra[extra_name], "rsl-rl-lib==", extra_name) - assert rsl_rl_pin == expected_pin - - -def test_wheel_builder_newton_pin_matches_source_packages(): - """The bundled wheel metadata must install the Newton revision used by source packages.""" - expected_pin = _newton_pin_from_pyproject("source/isaaclab_newton/pyproject.toml", "all") - source_pins = [ - _newton_pin_from_pyproject("source/isaaclab_physx/pyproject.toml", "newton"), - _newton_pin_from_pyproject("source/isaaclab_visualizers/pyproject.toml", "newton"), - _newton_pin_from_pyproject("source/isaaclab_visualizers/pyproject.toml", "rerun"), - _newton_pin_from_pyproject("source/isaaclab_visualizers/pyproject.toml", "viser"), - _newton_pin_from_pyproject("source/isaaclab_visualizers/pyproject.toml", "all"), - ] - assert source_pins == [expected_pin] * len(source_pins) - - wheel_newton_pin = _single_dependency( - _wheel_builder_dependencies_by_extra()["newton"], - "newton[sim] @ git+https://github.com/newton-physics/newton.git@", - "tools/wheel_builder/res/python_packages.toml[newton]", - ) - assert wheel_newton_pin == expected_pin - - -def test_wheel_builder_warp_pin_matches_core_package(): - """The bundled wheel metadata must keep Warp aligned with the core package pin.""" - expected_pin = _warp_pin_from_core_pyproject() - packages = _load_toml("tools/wheel_builder/res/python_packages.toml") - wheel_core_dependencies = packages["isaaclab"]["pyproject"]["dependencies"]["all"] - dependencies_by_extra = _wheel_builder_dependencies_by_extra() - - wheel_core_pin = _single_dependency( - wheel_core_dependencies, - "warp-lang==", - "tools/wheel_builder/res/python_packages.toml[dependencies]", - ) - wheel_newton_pin = _single_dependency( - dependencies_by_extra["newton"], - "warp-lang==", - "tools/wheel_builder/res/python_packages.toml[newton]", - ) - - assert wheel_core_pin == expected_pin - assert wheel_newton_pin == expected_pin + rsl_rl_pins = [dep for dep in optional_dependencies[extra_name] if dep.startswith("rsl-rl-lib==")] + assert rsl_rl_pins == [expected_pin] diff --git a/source/isaaclab/test/install_ci/utils.py b/source/isaaclab/test/install_ci/utils.py index 793219b6d8d..f2e3719392f 100644 --- a/source/isaaclab/test/install_ci/utils.py +++ b/source/isaaclab/test/install_ci/utils.py @@ -198,6 +198,19 @@ def cuda_torch_index_url() -> str: return "https://download.pytorch.org/whl/cu128" +def pinned_torch_specs() -> list[str]: + """Return the pinned ``torch``/``torchvision`` install specs from the repo pyproject. + + Reads ``[tool.isaaclab.versions]`` (the single source of truth) so these + install commands track the same versions as the docs and the install CLI. + """ + import tomllib + + with (find_isaaclab_root() / "pyproject.toml").open("rb") as fd: + versions = tomllib.load(fd)["tool"]["isaaclab"]["versions"] + return [f"torch=={versions['torch']}", f"torchvision=={versions['torchvision']}"] + + def aarch64_isaacsim_env() -> dict[str, str]: """Return env vars required to import ``isaacsim`` from a ``uv pip`` install on aarch64. diff --git a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py index 7f1355840f3..e69edb88fda 100644 --- a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py +++ b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py @@ -10,7 +10,8 @@ - uv pip install [all,isaacsim] --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow - uv pip install --reinstall-package torch --reinstall-package torchvision - torch==2.10.0 torchvision==0.25.0 --index-url + torch== torchvision== --index-url + (versions read from [tool.isaaclab.versions] in the root pyproject.) (cu128 on x86_64, cu130 on aarch64; per docs/source/setup/installation/pip_installation.rst. Reinstall AFTER the wheel install: unsafe-best-match re-resolves torch from PyPI to CPU.) - (aarch64 only) export LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1 @@ -24,7 +25,7 @@ import shutil import pytest -from utils import UV_Mixin, aarch64_isaacsim_env, cuda_torch_index_url +from utils import UV_Mixin, aarch64_isaacsim_env, cuda_torch_index_url, pinned_torch_specs _TRAIN_CMD = [ "train", @@ -94,8 +95,9 @@ def test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole(self, isaaclab_roo ) # 2. uv pip install --reinstall-package torch --reinstall-package torchvision - # torch==2.10.0 torchvision==0.25.0 --index-url - # cu128 on x86_64, cu130 on aarch64 (e.g. GB10 / DGX Spark with CUDA capability 12.x). + # torch== torchvision== --index-url + # (versions from [tool.isaaclab.versions]; cu128 on x86_64, cu130 on aarch64, + # e.g. GB10 / DGX Spark with CUDA capability 12.x). # --reinstall-package forces uv to swap the CPU torch installed above with the CUDA build. result = self.run_in_uv_env( [ @@ -106,8 +108,7 @@ def test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole(self, isaaclab_roo "torch", "--reinstall-package", "torchvision", - "torch==2.10.0", - "torchvision==0.25.0", + *pinned_torch_specs(), "--index-url", cuda_torch_index_url(), ], diff --git a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_isaacsim_imports_simulation_context.py b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_isaacsim_imports_simulation_context.py index 4b3851e0962..0d2d1361001 100644 --- a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_isaacsim_imports_simulation_context.py +++ b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_isaacsim_imports_simulation_context.py @@ -10,7 +10,8 @@ - uv pip install [isaacsim] --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow - uv pip install --reinstall-package torch --reinstall-package torchvision - torch==2.10.0 torchvision==0.25.0 --index-url + torch== torchvision== --index-url + (versions read from [tool.isaaclab.versions] in the root pyproject.) (cu128 on x86_64, cu130 on aarch64; per docs/source/setup/installation/pip_installation.rst. Reinstall AFTER the wheel install: unsafe-best-match re-resolves torch from PyPI to CPU.) - (aarch64 only) export LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1 @@ -24,7 +25,7 @@ import shutil import pytest -from utils import UV_Mixin, aarch64_isaacsim_env, cuda_torch_index_url +from utils import UV_Mixin, aarch64_isaacsim_env, cuda_torch_index_url, pinned_torch_specs @pytest.mark.install_path_uv_pip @@ -82,8 +83,7 @@ def _install_wheel(self, isaaclab_root, wheel): "torch", "--reinstall-package", "torchvision", - "torch==2.10.0", - "torchvision==0.25.0", + *pinned_torch_specs(), "--index-url", cuda_torch_index_url(), ], diff --git a/source/isaaclab_contrib/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_contrib/changelog.d/centralize-deps-root-pyproject.skip new file mode 100644 index 00000000000..ed67a1a5272 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/centralize-deps-root-pyproject.skip @@ -0,0 +1,4 @@ +# No changelog entry / version bump: third-party dependency declarations +# were relocated to the root pyproject.toml (single source of truth). The +# published isaaclab wheel is monolithic and unaffected; the user-facing change +# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_contrib/pyproject.toml b/source/isaaclab_contrib/pyproject.toml index dff0341c6dd..7aa1c3deb42 100644 --- a/source/isaaclab_contrib/pyproject.toml +++ b/source/isaaclab_contrib/pyproject.toml @@ -16,26 +16,13 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["kit", "robotics", "assets", "isaaclab"] requires-python = ">=3.12" +# Third-party requirements are centralized in the root pyproject.toml. dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" -[project.optional-dependencies] -rlinf = [ - "ray[default]>=2.47.0", - "av>=12.3.0", - "numpydantic>=1.7.0", - "albumentations>=1.4.18", - "decord2", - "dm_tree>=0.1.8", - "diffusers>=0.35.0", - "timm>=1.0.14", - "peft>=0.17.0", - "pandas", -] - [tool.setuptools] include-package-data = true diff --git a/source/isaaclab_mimic/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_mimic/changelog.d/centralize-deps-root-pyproject.skip new file mode 100644 index 00000000000..ed67a1a5272 --- /dev/null +++ b/source/isaaclab_mimic/changelog.d/centralize-deps-root-pyproject.skip @@ -0,0 +1,4 @@ +# No changelog entry / version bump: third-party dependency declarations +# were relocated to the root pyproject.toml (single source of truth). The +# published isaaclab wheel is monolithic and unaffected; the user-facing change +# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_mimic/pyproject.toml b/source/isaaclab_mimic/pyproject.toml index 6cefbe49ad4..b1384927c36 100644 --- a/source/isaaclab_mimic/pyproject.toml +++ b/source/isaaclab_mimic/pyproject.toml @@ -16,14 +16,9 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["extension", "template", "isaaclab"] requires-python = ">=3.12" -dependencies = [ - # jupyter notebook - "ipywidgets>=8.1.5", - # data collection - "h5py>=3.15.0", - # data augmentation (Linux only) - "robomimic @ git+https://github.com/ARISE-Initiative/robomimic.git@v0.4.0 ; sys_platform == 'linux'", -] +# Third-party requirements are centralized in the root pyproject.toml +# (installed via the ``mimic`` extra). +dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" diff --git a/source/isaaclab_newton/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_newton/changelog.d/centralize-deps-root-pyproject.skip new file mode 100644 index 00000000000..ed67a1a5272 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/centralize-deps-root-pyproject.skip @@ -0,0 +1,4 @@ +# No changelog entry / version bump: third-party dependency declarations +# were relocated to the root pyproject.toml (single source of truth). The +# published isaaclab wheel is monolithic and unaffected; the user-facing change +# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index 383cb156a86..f00d113b8d7 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -16,21 +16,13 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["robotics", "simulation", "newton"] requires-python = ">=3.12" +# Third-party requirements are centralized in the root pyproject.toml. dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" -[project.optional-dependencies] -all = [ - "prettytable>=3.3.0", - "PyOpenGL-accelerate>=3.1.0", - "pyglet>=2.1.6,<3", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", - "newton-usd-schemas>=0.3.1", -] - [tool.setuptools] include-package-data = true diff --git a/source/isaaclab_ov/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_ov/changelog.d/centralize-deps-root-pyproject.skip new file mode 100644 index 00000000000..ed67a1a5272 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/centralize-deps-root-pyproject.skip @@ -0,0 +1,4 @@ +# No changelog entry / version bump: third-party dependency declarations +# were relocated to the root pyproject.toml (single source of truth). The +# published isaaclab wheel is monolithic and unaffected; the user-facing change +# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_ov/pyproject.toml b/source/isaaclab_ov/pyproject.toml index 0c250169eb8..c3636a024d4 100644 --- a/source/isaaclab_ov/pyproject.toml +++ b/source/isaaclab_ov/pyproject.toml @@ -16,16 +16,13 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["robotics", "simulation", "rendering", "ovrtx", "omniverse"] requires-python = ">=3.12" +# Third-party requirements are centralized in the root pyproject.toml. dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" -[project.optional-dependencies] -ovrtx = ["ovrtx>=0.3.0,<0.4.0"] -all = ["ovrtx>=0.3.0,<0.4.0"] - [tool.setuptools] include-package-data = true diff --git a/source/isaaclab_ovphysx/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_ovphysx/changelog.d/centralize-deps-root-pyproject.skip new file mode 100644 index 00000000000..ed67a1a5272 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/centralize-deps-root-pyproject.skip @@ -0,0 +1,4 @@ +# No changelog entry / version bump: third-party dependency declarations +# were relocated to the root pyproject.toml (single source of truth). The +# published isaaclab wheel is monolithic and unaffected; the user-facing change +# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index 8d85e515440..033901bf6de 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -16,15 +16,13 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["robotics", "simulation", "ovphysx", "tensorbindingsapi"] requires-python = ">=3.12" +# Third-party requirements are centralized in the root pyproject.toml. dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" -[project.optional-dependencies] -ovphysx = ["ovphysx==0.5.2+head.f62c22207c"] - [tool.setuptools] include-package-data = true diff --git a/source/isaaclab_physx/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_physx/changelog.d/centralize-deps-root-pyproject.skip new file mode 100644 index 00000000000..ed67a1a5272 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/centralize-deps-root-pyproject.skip @@ -0,0 +1,4 @@ +# No changelog entry / version bump: third-party dependency declarations +# were relocated to the root pyproject.toml (single source of truth). The +# published isaaclab wheel is monolithic and unaffected; the user-facing change +# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index 4b193304613..4d58743154b 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -16,18 +16,13 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["robotics", "simulation", "physx"] requires-python = ">=3.12" +# Third-party requirements are centralized in the root pyproject.toml. dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" -[project.optional-dependencies] -newton = [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", - "newton-usd-schemas>=0.3.1", -] - [tool.setuptools] include-package-data = true diff --git a/source/isaaclab_rl/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_rl/changelog.d/centralize-deps-root-pyproject.skip new file mode 100644 index 00000000000..ed67a1a5272 --- /dev/null +++ b/source/isaaclab_rl/changelog.d/centralize-deps-root-pyproject.skip @@ -0,0 +1,4 @@ +# No changelog entry / version bump: third-party dependency declarations +# were relocated to the root pyproject.toml (single source of truth). The +# published isaaclab wheel is monolithic and unaffected; the user-facing change +# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_rl/pyproject.toml b/source/isaaclab_rl/pyproject.toml index ae351b10958..8cad5ba11dc 100644 --- a/source/isaaclab_rl/pyproject.toml +++ b/source/isaaclab_rl/pyproject.toml @@ -16,48 +16,13 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["robotics", "rl", "wrappers", "learning"] requires-python = ">=3.12" -dependencies = [ - "numpy", - "torch>=2.10", - "torchvision>=0.25.0", - "protobuf>=4.25.8,!=5.26.0", - # configuration management - "hydra-core", - # data collection - "h5py>=3.15.0", - # basic logger - "tensorboard", - # video recording - "moviepy", - "packaging<24", - "tqdm>=4.67.1", -] +# Third-party requirements are centralized in the root pyproject.toml. +dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" -[project.optional-dependencies] -sb3 = ["stable-baselines3>=2.6", "tqdm", "rich"] -skrl = ["skrl>=2.1.0"] -rl-games = [ - "aiohttp==3.13.3", - "rl-games @ git+https://github.com/isaac-sim/rl_games.git@python3.11", - "gym", - "standard-distutils", -] -rsl-rl = ["rsl-rl-lib==5.4.1", "onnxscript>=0.5"] -all = [ - "stable-baselines3>=2.6", "tqdm", "rich", - "skrl>=2.1.0", - "aiohttp==3.13.3", - "rl-games @ git+https://github.com/isaac-sim/rl_games.git@python3.11", - "gym", - "standard-distutils", - "rsl-rl-lib==5.4.1", - "onnxscript>=0.5", -] - [tool.setuptools] include-package-data = true diff --git a/source/isaaclab_tasks/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_tasks/changelog.d/centralize-deps-root-pyproject.skip new file mode 100644 index 00000000000..ed67a1a5272 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/centralize-deps-root-pyproject.skip @@ -0,0 +1,4 @@ +# No changelog entry / version bump: third-party dependency declarations +# were relocated to the root pyproject.toml (single source of truth). The +# published isaaclab wheel is monolithic and unaffected; the user-facing change +# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index 47db04587cf..8f8f7bfda75 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -16,13 +16,8 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["robotics", "rl", "il", "learning"] requires-python = ">=3.12" -dependencies = [ - "numpy>=2", - "torch>=2.10", - "torchvision>=0.25.0", - "protobuf>=4.25.8,!=5.26.0", - "tensorboard", -] +# Third-party requirements are centralized in the root pyproject.toml. +dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" diff --git a/source/isaaclab_tasks/test/core/test_train_scripts_deterministic.py b/source/isaaclab_tasks/test/core/test_train_scripts_deterministic.py index 677e698ec78..aa2d29c5f8a 100644 --- a/source/isaaclab_tasks/test/core/test_train_scripts_deterministic.py +++ b/source/isaaclab_tasks/test/core/test_train_scripts_deterministic.py @@ -43,7 +43,7 @@ def _module_string_constant(tree: ast.AST, name: str) -> str: def _source_skrl_min_version() -> Version: - with (REPO_ROOT / "source/isaaclab_rl/pyproject.toml").open("rb") as f: + with (REPO_ROOT / "pyproject.toml").open("rb") as f: data = tomllib.load(f) for dependency in data["project"]["optional-dependencies"]["skrl"]: @@ -58,7 +58,7 @@ def _source_skrl_min_version() -> Version: if lower_bounds: return max(lower_bounds) - raise AssertionError("Could not find skrl lower bound in source/isaaclab_rl/pyproject.toml") + raise AssertionError("Could not find skrl lower bound in pyproject.toml") def _called_name(call: ast.Call) -> str | None: diff --git a/source/isaaclab_tasks_experimental/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_tasks_experimental/changelog.d/centralize-deps-root-pyproject.skip new file mode 100644 index 00000000000..ed67a1a5272 --- /dev/null +++ b/source/isaaclab_tasks_experimental/changelog.d/centralize-deps-root-pyproject.skip @@ -0,0 +1,4 @@ +# No changelog entry / version bump: third-party dependency declarations +# were relocated to the root pyproject.toml (single source of truth). The +# published isaaclab wheel is monolithic and unaffected; the user-facing change +# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_tasks_experimental/pyproject.toml b/source/isaaclab_tasks_experimental/pyproject.toml index 7f48fe4af2f..dc8e8e22956 100644 --- a/source/isaaclab_tasks_experimental/pyproject.toml +++ b/source/isaaclab_tasks_experimental/pyproject.toml @@ -16,9 +16,8 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["robotics", "rl", "il", "learning"] requires-python = ">=3.12" -dependencies = [ - "isaaclab_tasks", -] +# Third-party requirements are centralized in the root pyproject.toml. +dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" diff --git a/source/isaaclab_teleop/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_teleop/changelog.d/centralize-deps-root-pyproject.skip new file mode 100644 index 00000000000..ed67a1a5272 --- /dev/null +++ b/source/isaaclab_teleop/changelog.d/centralize-deps-root-pyproject.skip @@ -0,0 +1,4 @@ +# No changelog entry / version bump: third-party dependency declarations +# were relocated to the root pyproject.toml (single source of truth). The +# published isaaclab wheel is monolithic and unaffected; the user-facing change +# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_teleop/pyproject.toml b/source/isaaclab_teleop/pyproject.toml index 2a2ec6f86d8..2a00a139722 100644 --- a/source/isaaclab_teleop/pyproject.toml +++ b/source/isaaclab_teleop/pyproject.toml @@ -16,11 +16,9 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["kit", "robotics", "teleoperation", "xr", "isaaclab"] requires-python = ">=3.12" -dependencies = [ - # IsaacTeleop is only available on Linux x86_64 - "isaacteleop[retargeters,ui,cloudxr]~=1.3.0 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64'", - "dex-retargeting==0.5.0 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64'", -] +# Third-party requirements are centralized in the root pyproject.toml +# (installed via the ``teleop`` extra). +dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" diff --git a/source/isaaclab_visualizers/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_visualizers/changelog.d/centralize-deps-root-pyproject.skip new file mode 100644 index 00000000000..ed67a1a5272 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/centralize-deps-root-pyproject.skip @@ -0,0 +1,4 @@ +# No changelog entry / version bump: third-party dependency declarations +# were relocated to the root pyproject.toml (single source of truth). The +# published isaaclab wheel is monolithic and unaffected; the user-facing change +# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_visualizers/pyproject.toml b/source/isaaclab_visualizers/pyproject.toml index d875dfe116b..7fe9e246786 100644 --- a/source/isaaclab_visualizers/pyproject.toml +++ b/source/isaaclab_visualizers/pyproject.toml @@ -16,51 +16,13 @@ authors = [{name = "Isaac Lab Project Developers"}] maintainers = [{name = "Isaac Lab Project Developers"}] keywords = ["robotics", "simulation", "visualization"] requires-python = ">=3.12" -dependencies = [ - "isaaclab", - "numpy", -] +# Third-party requirements are centralized in the root pyproject.toml. +dependencies = [] [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" -[project.optional-dependencies] -kit = [] -newton = [ - "warp-lang", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", - "newton-usd-schemas>=0.3.1", - "PyOpenGL-accelerate", - "pyglet>=2.1.6,<3", - "imgui-bundle>=1.92.5", - "typing-extensions>=4.15.0", -] -rerun = [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", - "newton-usd-schemas>=0.3.1", - "rerun-sdk>=0.29.0", - "pyarrow==22.0.0", -] -viser = [ - "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", - "newton-usd-schemas>=0.3.1", - "viser>=1.0.16", -] -all = [ - "imgui-bundle>=1.92.5", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", - "newton-usd-schemas>=0.3.1", - "PyOpenGL-accelerate", - "pyglet>=2.1.6,<3", - # Match rerun-sdk's supported Arrow stack and avoid resolver drift across environments. - "pyarrow==22.0.0", - "rerun-sdk>=0.29.0", - "typing-extensions>=4.15.0", - "viser>=1.0.16", - "warp-lang", -] - [tool.setuptools] include-package-data = true diff --git a/tools/wheel_builder/build.sh b/tools/wheel_builder/build.sh index 030cf537489..1c9f22685b5 100755 --- a/tools/wheel_builder/build.sh +++ b/tools/wheel_builder/build.sh @@ -85,8 +85,8 @@ find "$BUILD_DIR/src" -name "*.pyc" -delete 2>/dev/null || true cp "$SELF_DIR/res/__init__.py" "$BUILD_DIR/src/isaaclab/" cp "$SELF_DIR/res/__main__.py" "$BUILD_DIR/src/isaaclab/" -# 3. Generate pyproject.toml with dependencies from python_packages.toml -python3 "$SELF_DIR/gen_pyproject.py" "$SELF_DIR/res/python_packages.toml" "$BUILD_DIR/pyproject.toml" "$WHEEL_VERSION" +# 3. Generate pyproject.toml with dependencies from the root pyproject.toml +python3 "$SELF_DIR/gen_pyproject.py" "$SELF_DIR/../../pyproject.toml" "$BUILD_DIR/pyproject.toml" "$WHEEL_VERSION" # 4. Build the wheel cd "$BUILD_DIR" diff --git a/tools/wheel_builder/gen_pyproject.py b/tools/wheel_builder/gen_pyproject.py index a8cf3fdd8d5..b8a619597ec 100644 --- a/tools/wheel_builder/gen_pyproject.py +++ b/tools/wheel_builder/gen_pyproject.py @@ -3,41 +3,97 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Generate pyproject.toml for the isaaclab wheel from python_packages.toml.""" +"""Generate pyproject.toml for the isaaclab wheel from the root pyproject.toml. +The published wheel bundles every ``isaaclab*`` sub-package as a top-level +module, so the workspace self-references in the root ``[project.dependencies]`` +and ``[project.optional-dependencies]`` are dropped here; only third-party +requirements end up in the wheel metadata. +""" + +import re import sys import tomllib if len(sys.argv) != 4: - print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) sys.exit(1) -packages_toml_path = sys.argv[1] +root_pyproject_path = sys.argv[1] output_path = sys.argv[2] version = sys.argv[3] -with open(packages_toml_path, "rb") as f: - data = tomllib.load(f) -pkg = data["isaaclab"] - -# Collect dependencies (deduplicated, preserving order) -raw_deps = pkg["pyproject"]["dependencies"]["all"] -seen = set() -deps = [] -for d in raw_deps: - # Normalize for dedup: lowercase package name (before any version specifier) - key = d.split(">")[0].split("<")[0].split("=")[0].split("!")[0].split("[")[0].strip().lower() - if key not in seen: - seen.add(key) - deps.append(d) - -# Collect optional dependencies +with open(root_pyproject_path, "rb") as f: + root = tomllib.load(f) +project = root["project"] + + +def _requirement_name(requirement: str) -> str: + """Extract the normalized distribution name from a requirement string.""" + name = re.split(r"\s|<|>|=|!|~|\[|@|;", requirement, maxsplit=1)[0].strip() + return re.sub(r"[-_.]+", "-", name).lower() + + +def _is_workspace_member(requirement: str) -> bool: + """Return True for ``isaaclab*`` self-references (bundled into the wheel).""" + return _requirement_name(requirement).startswith("isaaclab") + + +_project_name = _requirement_name(project["name"]) +_optional = project.get("optional-dependencies", {}) +_self_ref_pattern = re.compile(r"^\s*([A-Za-z0-9._-]+)\s*\[([^\]]+)\]\s*$") + + +def _self_ref_extras(requirement: str) -> list[str] | None: + """Return the referenced extra names for a bare self-reference. + + A self-reference looks like ``isaaclab-dev[sb3,skrl]`` (the root project's own + name with extras and no version specifier or marker). Returns ``None`` for any + other requirement, including third-party extras such as ``ray[default]>=2``. + """ + match = _self_ref_pattern.match(requirement) + if match is None or _requirement_name(match.group(1)) != _project_name: + return None + return [extra.strip() for extra in match.group(2).split(",")] + + +def _expand_self_refs(requirements: list[str], seen: set[str] | None = None) -> list[str]: + """Inline self-referential extras into their concrete requirements.""" + seen = set() if seen is None else seen + expanded = [] + for requirement in requirements: + extras = _self_ref_extras(requirement) + if extras is None: + expanded.append(requirement) + continue + for extra in extras: + if extra in seen: + continue + seen.add(extra) + expanded.extend(_expand_self_refs(_optional.get(extra, []), seen)) + return expanded + + +def _dedup(requirements: list[str]) -> list[str]: + """Drop duplicate requirements by distribution name, preserving order.""" + seen = set() + result = [] + for requirement in requirements: + key = _requirement_name(requirement) + if key not in seen: + seen.add(key) + result.append(requirement) + return result + + +# Required dependencies: third-party only (strip workspace members), deduped. +deps = _dedup([d for d in _expand_self_refs(project["dependencies"]) if not _is_workspace_member(d)]) + +# Optional dependencies: per extra, strip workspace members and dedup. opt_deps = {} -for entry in pkg["pyproject"]["optional-dependencies"]["all"]: - # Each entry is a dict like {"name": ["dep1", "dep2"]} - for name, dep_list in entry.items(): - opt_deps[name] = dep_list +for name, dep_list in project.get("optional-dependencies", {}).items(): + opt_deps[name] = _dedup([d for d in _expand_self_refs(dep_list) if not _is_workspace_member(d)]) # Write pyproject.toml lines = [] diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml deleted file mode 100644 index a5d22fd8346..00000000000 --- a/tools/wheel_builder/res/python_packages.toml +++ /dev/null @@ -1,128 +0,0 @@ -[isaaclab] -# pyproject.toml -pyproject.description = "Isaac Lab" -pyproject.keywords = ["nvidia", "omniverse", "isaacsim", "isaaclab"] -pyproject.dependencies.all = [ - # ================================================================================ - # https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab/setup.py - # ================================================================================ - # generic - "numpy>=2", - "torch>=2.10", - "onnx>=1.18.0", # 1.16.2 throws access violation on Windows - "prettytable==3.3.0", - "toml", - # devices - "hidapi==0.14.0.post2", - # reinforcement learning - "gymnasium==1.2.0", - # procedural-generation - "trimesh", - "pyglet>=2.1.6", - # image processing - "transformers==4.57.6", - "einops", # needed for transformers, doesn't always auto-install - "warp-lang==1.15.0.dev20260626", - "matplotlib>=3.10.3", - # make sure this is consistent with isaac sim version - "pillow==12.1.1", - "botocore", - # livestream - # 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", - "pytest-mock", - "junitparser", - "flatdict==4.0.0", - "flaky", - "packaging", - # visualizers - "imgui-bundle==1.92.601", - "rerun-sdk>=0.29.0", - "pyarrow==22.0.0", - # 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'", - "pin-pink>=2.3.6 ; platform_system == 'Linux'", - # ================================================================================ - # https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab_rl/setup.py - # ================================================================================ - # generic - "numpy>=2", - "torch>=2.10", - "torchvision>=0.25.0", # ensure compatibility with torch 2.10 - "protobuf>=4.25.8,!=5.26.0", - # configuration management - "hydra-core", - # data collection - "h5py", - # basic logger - "tensorboard", - # video recording - "moviepy", - "packaging<24", - "tqdm==4.67.1", - # ================================================================================ - # https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab_tasks/setup.py - # ================================================================================ - # generic - "numpy>=2", - "torch>=2.10", - "torchvision>=0.25.0", # ensure compatibility with torch 2.10 - "protobuf>=4.25.8,!=5.26.0", - # basic logger - "tensorboard", - "numba>=0.63.1", -] -pyproject.optional-dependencies.all = [ - # Isaac Sim - { "isaacsim" = ["isaacsim[all,extscache]==6.0.0.1"] }, - # ================================================================================ - # https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab_newton/setup.py - # ================================================================================ - { "newton" = [ - "warp-lang==1.15.0.dev20260626", - "newton[sim] @ git+https://github.com/newton-physics/newton.git@c7ae7c7648cd0717df39e5c94b95d5a02c997320", - "newton-usd-schemas>=0.3.1", - "PyOpenGL-accelerate==3.1.10" - ] }, - # ================================================================================ - # https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab_rl/setup.py - # ================================================================================ - # RL libraries - { "sb3" = ["stable-baselines3>=2.6", "tqdm", "rich"] }, - { "skrl" = ["skrl>=2.1.0"] }, - { "rsl-rl" = ["rsl-rl-lib==5.4.1", "onnxscript>=0.5"] }, - { "rsl_rl" = ["rsl-rl-lib==5.4.1", "onnxscript>=0.5"] }, - # ================================================================================ - # https://github.com/isaac-sim/IsaacLab/blob/main/source/isaaclab_visualizers/pyproject.toml - # ================================================================================ - # 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) — use with ``isaacsim`` for full installs; Newton comes from - # ``isaacsim-core``. Do not add git Newton here (conflicts with Isaac Sim pins). - { "all" = [ - "stable-baselines3>=2.6", - "tqdm", - "rich", - "skrl>=2.1.0", - "rsl-rl-lib==5.4.1", - "onnxscript>=0.5", - ] }, -] -# inventory (relative to the `source_folder` config) -inventory.includes.all = [ - # Isaac Lab - "apps", - "source", - # python package/module - [ "../source/python_packages/isaaclab/__init__.py", "." ], - [ "../source/python_packages/isaaclab/__main__.py", "." ], -] From d3dd1863f1995ab4b0701f1edde9b920f6dd05dc Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Tue, 7 Jul 2026 23:04:44 -0400 Subject: [PATCH 107/139] Drop redundant `_renderer` suffix from renderer presets (#6400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Renderer presets repeated the category that the `renderer=` selector already names. This PR drops the redundant `_renderer` suffix while keeping the old spellings working as deprecated aliases: | Old | New | |---|---| | `ovrtx_renderer` | `ovrtx` | | `isaacsim_rtx_renderer` | `isaacsim_rtx` | `newton_renderer` is intentionally **left unchanged** (no alias): the bare `newton` spelling is deliberately avoided across the codebase — it's a live physics deprecation alias (`newton` → `newton_mjwarp`) and is guarded by `test_newton_solver_preset_names.py` — so renaming the renderer to `newton` would reintroduce that ambiguity. ## How it works The rename reuses the existing per-target legacy-alias mechanism (the same one that powers `newton` → `newton_mjwarp`): the aliases live on `PresetTarget.RENDERER.legacy_aliases`, so `renderer=ovrtx_renderer` / `presets=...,ovrtx_renderer` still resolve — they just emit a `FutureWarning` pointing at the new name. The aliases will be removed in a future release. Nice side effect: the preset name `ovrtx` now matches its underlying `renderer_type` value. ## Changes - **`presets.py`** — renamed the `MultiBackendRendererCfg` fields and updated `set_isaac_rtx_global_settings`. - **`preset_target.py`** — added the alias map + docstring to `PresetTarget.RENDERER`. - **`sim_launcher.py`** — help text and runtime-compatibility error messages now use the suffix-less names. - **Docs / benchmark scripts / shadow-hand task docstrings** — updated example strings (re-padded the affected RST grid tables in `environments.rst`). - **Tests** — updated `test_runtime_compatibility.py`, `test_preset_kit_decision.py`, `test_shadow_hand_camera_presets.py` to the canonical names, and added a parametrized alias regression test in `test_hydra.py`. - Changelog fragments for `isaaclab` and `isaaclab_tasks`. ## Notes - The golden-image test infra (`rendering_test_utils.py` + ~190 golden files) builds renderer labels as `f"{renderer}_renderer"`, which are used both as `presets=` tokens and as on-disk filenames. Those old spellings still resolve via the new alias, so it was left untouched (renaming 190 golden files is out of scope). ## Testing Verified the alias-resolution logic and ran `pre-commit` on all changed files (all hooks pass). The full pytest suite requires a GPU/Isaac Sim environment not available locally — CI will exercise it. --- docs/source/features/hydra.rst | 4 +-- docs/source/how-to/record_video.rst | 8 ++--- .../overview/core-concepts/sensors/camera.rst | 2 +- .../overview/core-concepts/visualization.rst | 14 ++++---- docs/source/overview/environments.rst | 32 +++++++++---------- docs/source/setup/quickstart_details.rst | 16 +++++----- scripts/benchmarks/benchmark_hydra_resolve.py | 2 +- scripts/benchmarks/utils.py | 2 +- .../changelog.d/renderer-preset-alias.rst | 6 ++++ source/isaaclab/isaaclab/app/sim_launcher.py | 6 ++-- .../isaaclab/test/benchmark/schema.py | 2 +- source/isaaclab/test/benchmark/test_schema.py | 4 +-- .../changelog.d/renderer-preset-alias.rst | 16 ++++++++++ .../shadow_hand/shadow_hand_camera_env_cfg.py | 4 +-- .../isaaclab_tasks/utils/preset_target.py | 13 ++++++-- .../isaaclab_tasks/utils/presets.py | 6 ++-- source/isaaclab_tasks/test/core/test_hydra.py | 21 ++++++++++++ .../test/core/test_preset_kit_decision.py | 6 ++-- .../test/core/test_runtime_compatibility.py | 28 ++++++++-------- .../core/test_shadow_hand_camera_presets.py | 6 ++-- 20 files changed, 125 insertions(+), 73 deletions(-) create mode 100644 source/isaaclab/changelog.d/renderer-preset-alias.rst create mode 100644 source/isaaclab_tasks/changelog.d/renderer-preset-alias.rst diff --git a/docs/source/features/hydra.rst b/docs/source/features/hydra.rst index a4c448c536f..3ad8e5b3897 100644 --- a/docs/source/features/hydra.rst +++ b/docs/source/features/hydra.rst @@ -408,11 +408,11 @@ to make intent explicit on the command line. * - Name - Renderer - * - ``default`` / ``isaacsim_rtx_renderer`` + * - ``default`` / ``isaacsim_rtx`` - Isaac Sim RTX renderer (used when no ``renderer=`` or ``presets=`` is given) * - ``newton_renderer`` - Newton Warp renderer - * - ``ovrtx_renderer`` + * - ``ovrtx`` - OV RTX renderer * - ``rtx`` - Automatic RTX renderer selection (Isaac Sim RTX when running with Isaac Sim, and OVRTX for kit-less) diff --git a/docs/source/how-to/record_video.rst b/docs/source/how-to/record_video.rst index 2490ab81328..2787d5ac504 100644 --- a/docs/source/how-to/record_video.rst +++ b/docs/source/how-to/record_video.rst @@ -91,9 +91,9 @@ of active visualizers. Set ``VideoRecorderCfg.backend_source = "renderer"`` to ignore active visualizers and choose from the physics/renderer stack instead. In that mode, PhysX physics (``physics=physx``) or Isaac RTX -(``renderer=isaacsim_rtx_renderer``) selects the Kit path. Newton physics (``physics=newton_mjwarp``) or +(``renderer=isaacsim_rtx``) selects the Kit path. Newton physics (``physics=newton_mjwarp``) or the Newton Warp renderer (``renderer=newton_renderer``) selects the Newton GL path when no Kit -signal is present. OVRTX (``renderer=ovrtx_renderer`` from ``isaaclab_ov``) can pair with IsaacSim +signal is present. OVRTX (``renderer=ovrtx`` from ``isaaclab_ov``) can pair with IsaacSim or Newton physics; in that case the video backend is selected via the physics preset. If both Kit and Newton GL signals are present, the Kit path is chosen. @@ -157,13 +157,13 @@ Summary * - Stack example (``physics=`` / ``renderer=``) - Video backend - Capture mechanism - * - ``physics=physx`` or ``renderer=isaacsim_rtx_renderer`` + * - ``physics=physx`` or ``renderer=isaacsim_rtx`` - Kit (``"kit"``) - ``/OmniverseKit_Persp`` + Replicator RGB * - ``physics=newton_mjwarp`` or ``renderer=newton_renderer`` (no Kit signals) - Newton GL (``"newton_gl"``) - ``newton.viewer.ViewerGL`` on the SDP Newton model - * - ``physics=newton_mjwarp`` + ``renderer=ovrtx_renderer`` (OVRTX + Newton physics) + * - ``physics=newton_mjwarp`` + ``renderer=ovrtx`` (OVRTX + Newton physics) - Newton GL (``"newton_gl"``) - ``newton.viewer.ViewerGL`` on the SDP Newton model * - ``--visualizer kit`` with default ``backend_source`` diff --git a/docs/source/overview/core-concepts/sensors/camera.rst b/docs/source/overview/core-concepts/sensors/camera.rst index 47ef069d107..cc1c4576ee0 100644 --- a/docs/source/overview/core-concepts/sensors/camera.rst +++ b/docs/source/overview/core-concepts/sensors/camera.rst @@ -148,7 +148,7 @@ The active preset is selected at launch via ``physics=``, ``renderer=``, or ``pr python train.py task=Isaac-Cartpole-Camera-Direct renderer=newton_renderer # Use OVRTX renderer - python train.py task=Isaac-Cartpole-Camera-Direct renderer=ovrtx_renderer + python train.py task=Isaac-Cartpole-Camera-Direct renderer=ovrtx # Use default (Isaac RTX) python train.py task=Isaac-Cartpole-Camera-Direct diff --git a/docs/source/overview/core-concepts/visualization.rst b/docs/source/overview/core-concepts/visualization.rst index d16a23c928c..e91b46dd057 100644 --- a/docs/source/overview/core-concepts/visualization.rst +++ b/docs/source/overview/core-concepts/visualization.rst @@ -10,7 +10,7 @@ visualizers are meant for fast, interactive feedback. Most visualizers can be combined with any physics engine or rendering backend. The exception is the Kit visualizer with kit-less OV backends: ``--visualizer kit`` cannot be used with ``presets=ovphysx`` or -``ovrtx_renderer`` in the same process. Use ``--visualizer newton``, +``ovrtx`` in the same process. Use ``--visualizer newton``, ``--visualizer rerun``, ``--visualizer viser``, or omit ``--visualizer`` for headless execution. @@ -281,19 +281,19 @@ set ``VideoRecorderCfg.backend_source = "renderer"`` in the task configuration. * - Renderer preset - ``--visualizer kit --video`` - ``--visualizer newton --video`` - * - ``isaacsim_rtx_renderer`` + * - ``isaacsim_rtx`` - ✅ Kit RTX captures video *(default, no change)* - ✅ Newton GL captures video *(overrides RTX backend)* * - ``newton_renderer`` - ✅ Kit RTX captures video *(overrides Newton backend)* - ✅ Newton GL captures video *(default, no change)* - * - ``ovrtx_renderer`` + * - ``ovrtx`` - ❌ **Raises an error** — see note below - ✅ Newton GL captures video; ovrtx provides camera sensor data .. note:: - ``--visualizer kit`` combined with ``ovrtx_renderer`` raises a ``ValueError`` at startup. + ``--visualizer kit`` combined with ``ovrtx`` raises a ``ValueError`` at startup. Both Kit (Isaac Sim) and ovrtx ship conflicting RTX hydra libraries compiled against different USD namespaces (``pxrInternal_v0_25_11`` vs ``ovInternal_v0_25_11``), which causes a dynamic-linker crash when loaded into the same process. @@ -313,7 +313,7 @@ set ``VideoRecorderCfg.backend_source = "renderer"`` in the task configuration. --max_iterations=5 \ --num_envs=1024 \ --benchmark_backend=summary \ - physics=newton_mjwarp renderer=ovrtx_renderer presets=rgb + physics=newton_mjwarp renderer=ovrtx presets=rgb **Record video with the Isaac RTX renderer preset using the Newton video backend** @@ -329,7 +329,7 @@ set ``VideoRecorderCfg.backend_source = "renderer"`` in the task configuration. --max_iterations=5 \ --num_envs=1024 \ --benchmark_backend=summary \ - physics=physx renderer=isaacsim_rtx_renderer presets=rgb + physics=physx renderer=isaacsim_rtx presets=rgb **Record video with the Isaac RTX renderer preset using the Kit video backend** @@ -345,7 +345,7 @@ set ``VideoRecorderCfg.backend_source = "renderer"`` in the task configuration. --max_iterations=5 \ --num_envs=1024 \ --benchmark_backend=summary \ - physics=physx renderer=isaacsim_rtx_renderer presets=rgb + physics=physx renderer=isaacsim_rtx presets=rgb Visualizer Backends diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index fc7fdb0951e..611824dfd2d 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -68,7 +68,7 @@ modes. The **Presets** column in each table below is divided into three labeled (e.g. ``physx``, ``newton_mjwarp``, ``newton_kamino``, ``ovphysx``, ``newton_mjwarp_vbd``) * **renderer=** — renderer-backend name passed as ``renderer=NAME`` - (e.g. ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx``) + (e.g. ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx``) * **presets=** — environment-specific (domain) preset name passed as ``presets=NAME[,NAME,...]`` (e.g. ``rgb``, ``depth``, ``single_camera``, ``duo_camera``) @@ -118,9 +118,9 @@ Classic environments that are based on IsaacGymEnvs implementation of MuJoCo-sty | | | running with ``--enable_cameras``. | ``newton_mjwarp``, | | | | | ``ovphysx`` (direct only) | | | | | **renderer=** | - | | | | ``isaacsim_rtx_renderer``, | + | | | | ``isaacsim_rtx``, | | | | | ``newton_renderer``, | - | | | | ``ovrtx_renderer`` | + | | | | ``ovrtx`` | | | | | **presets=** ``rgb``, | | | | | ``depth``, ``albedo``, | | | | | ``semantic_segmentation``, | @@ -206,9 +206,9 @@ for the lift-cube environment: | |cube-shadow| | |cube-shadow-vis-link| | In-hand reorientation of a cube using Shadow hand using perceptive inputs. | **physics=** ``physx``, | | | | Requires running with ``--enable_cameras``. | ``newton_mjwarp`` | | | | | **renderer=** | - | | | | ``isaacsim_rtx_renderer``, | + | | | | ``isaacsim_rtx``, | | | | | ``newton_renderer``, | - | | | | ``ovrtx_renderer`` | + | | | | ``ovrtx`` | | | | | **presets=** ``rgb``, | | | | | ``depth``, ``albedo``, | | | | | ``full``, | @@ -236,9 +236,9 @@ for the lift-cube environment: | |kuka-allegro-lift| | |ka-lift-cam-link| | Camera (vision) variant of the lift task, adding single- and dual-camera | **physics=** ``physx``, | | | | observations via ``presets=single_camera`` / ``presets=duo_camera``. | ``newton_mjwarp`` | | | | | **renderer=** | - | | | | ``isaacsim_rtx_renderer``, | + | | | | ``isaacsim_rtx``, | | | | | ``newton_renderer``, | - | | | | ``ovrtx_renderer`` | + | | | | ``ovrtx`` | | | | | **presets=** | | | | | ``single_camera``, | | | | | ``duo_camera``, | @@ -252,9 +252,9 @@ for the lift-cube environment: | |kuka-allegro-reorient| | |ka-reorient-cam-link| | Camera (vision) variant of the reorient task, adding single- and | **physics=** ``physx``, | | | | dual-camera observations via ``presets=single_camera`` / | ``newton_mjwarp`` | | | | ``presets=duo_camera``. | **renderer=** | - | | | | ``isaacsim_rtx_renderer``, | + | | | | ``isaacsim_rtx``, | | | | | ``newton_renderer``, | - | | | | ``ovrtx_renderer`` | + | | | | ``ovrtx`` | | | | | **presets=** | | | | | ``single_camera``, | | | | | ``duo_camera``, | @@ -910,14 +910,14 @@ inferencing, including reading from an already trained checkpoint and disabling - Manager Based - **rl_games** (PPO, FEATURE), **rsl_rl** (PPO, FEATURE) - | **physics=** ``newton_kamino``, ``newton_mjwarp``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` + | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` | **presets=** ``albedo``, ``depth``, ``resnet18``, ``rgb``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl``, ``theia_tiny`` * - Isaac-Cartpole-Camera-Direct - - Direct - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) - | **physics=** ``newton_kamino``, ``newton_mjwarp``, ``ovphysx``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` + | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` | **presets=** ``albedo``, ``depth``, ``rgb``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl`` * - IsaacContrib-Cartpole-Camera-Showcase-Direct - @@ -1104,7 +1104,7 @@ inferencing, including reading from an already trained checkpoint and disabling - - Manager Based - **rsl_rl** (PPO) - - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` + - | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` | **presets=** ``albedo128``, ``albedo256``, ``albedo64``, ``cube``, ``depth128``, ``depth256``, ``depth64``, ``duo_camera``, ``raycaster_depth128``, ``raycaster_depth256``, ``raycaster_depth64``, ``rgb128``, ``rgb256``, ``rgb64``, ``semantic_segmentation128``, ``semantic_segmentation256``, ``semantic_segmentation64``, ``shapes``, ``simple_shading_constant_diffuse128``, ``simple_shading_constant_diffuse256``, ``simple_shading_constant_diffuse64``, ``simple_shading_diffuse_mdl128``, ``simple_shading_diffuse_mdl256``, ``simple_shading_diffuse_mdl64``, ``simple_shading_full_mdl128``, ``simple_shading_full_mdl256``, ``simple_shading_full_mdl64``, ``single_camera`` * - Isaac-Lift-KukaAllegro-Play - @@ -1281,14 +1281,14 @@ inferencing, including reading from an already trained checkpoint and disabling - Direct - **rl_games** (PPO), **rsl_rl** (PPO) - | **physics=** ``newton_mjwarp``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` + | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` | **presets=** ``albedo``, ``depth``, ``full``, ``rgb``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl`` * - Isaac-Reorient-Cube-Shadow-Camera-Direct-Play - - Direct - **rl_games** (PPO), **rsl_rl** (PPO) - | **physics=** ``newton_mjwarp``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` + | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` | **presets=** ``albedo``, ``depth``, ``full``, ``rgb``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl`` * - Isaac-Reorient-Cube-Shadow-Direct - @@ -1314,7 +1314,7 @@ inferencing, including reading from an already trained checkpoint and disabling - - Manager Based - **rsl_rl** (PPO) - - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` + - | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` | **presets=** ``albedo128``, ``albedo256``, ``albedo64``, ``cube``, ``depth128``, ``depth256``, ``depth64``, ``duo_camera``, ``raycaster_depth128``, ``raycaster_depth256``, ``raycaster_depth64``, ``rgb128``, ``rgb256``, ``rgb64``, ``semantic_segmentation128``, ``semantic_segmentation256``, ``semantic_segmentation64``, ``shapes``, ``simple_shading_constant_diffuse128``, ``simple_shading_constant_diffuse256``, ``simple_shading_constant_diffuse64``, ``simple_shading_diffuse_mdl128``, ``simple_shading_diffuse_mdl256``, ``simple_shading_diffuse_mdl64``, ``simple_shading_full_mdl128``, ``simple_shading_full_mdl256``, ``simple_shading_full_mdl64``, ``single_camera`` * - Isaac-Reorient-KukaAllegro-Play - @@ -1386,7 +1386,7 @@ inferencing, including reading from an already trained checkpoint and disabling - Manager Based - - | **physics=** ``newton_mjwarp``, ``physx`` - | **renderer=** ``isaacsim_rtx_renderer``, ``newton_renderer``, ``ovrtx_renderer``, ``rtx`` + | **renderer=** ``isaacsim_rtx``, ``newton_renderer``, ``ovrtx``, ``rtx`` * - IsaacContrib-Stack-Cube-Galbot-Right-Arm-Suction-RmpFlow - - Manager Based diff --git a/docs/source/setup/quickstart_details.rst b/docs/source/setup/quickstart_details.rst index b1ed191865c..87ebe19d9f0 100644 --- a/docs/source/setup/quickstart_details.rst +++ b/docs/source/setup/quickstart_details.rst @@ -53,7 +53,7 @@ options (observation modes, camera configs, etc.). They fold into Hydra override ./isaaclab.sh train --rl_library rsl_rl \ --task=Isaac-Reorient-Cube-Shadow-Camera-Benchmark-Direct \ --enable_cameras --num_envs=16 --max_iterations=10 \ - physics=newton_mjwarp renderer=ovrtx_renderer presets=simple_shading_diffuse_mdl + physics=newton_mjwarp renderer=ovrtx presets=simple_shading_diffuse_mdl .. tab-item:: :icon:`fa-brands fa-windows` Windows :sync: windows @@ -81,9 +81,9 @@ Available Presets **Renderer backends** (``renderer=NAME``): -- ``isaacsim_rtx_renderer`` — Isaac Sim RTX (default with Isaac Sim) +- ``isaacsim_rtx`` — Isaac Sim RTX (default with Isaac Sim) - ``newton_renderer`` — Newton Warp renderer -- ``ovrtx_renderer`` — OV RTX renderer (kit-less) +- ``ovrtx`` — OV RTX renderer (kit-less) - ``rtx`` — Automatic RTX renderer selection Automatic RTX selection is available only when the camera exposes the renderer @@ -132,11 +132,11 @@ Common combinations: physics=newton_mjwarp renderer=newton_renderer presets=rgb physics=newton_mjwarp renderer=newton_renderer presets=depth physics=newton_mjwarp renderer=rtx presets=rgb - physics=physx renderer=isaacsim_rtx_renderer presets=rgb - physics=physx renderer=isaacsim_rtx_renderer presets=depth - physics=physx renderer=isaacsim_rtx_renderer presets=albedo - physics=newton_mjwarp renderer=ovrtx_renderer presets=rgb - physics=newton_mjwarp renderer=ovrtx_renderer presets=simple_shading_diffuse_mdl + physics=physx renderer=isaacsim_rtx presets=rgb + physics=physx renderer=isaacsim_rtx presets=depth + physics=physx renderer=isaacsim_rtx presets=albedo + physics=newton_mjwarp renderer=ovrtx presets=rgb + physics=newton_mjwarp renderer=ovrtx presets=simple_shading_diffuse_mdl Legacy ``presets=newton_mjwarp,newton_renderer,rgb`` form still works; prefer typed selectors for clarity. See :doc:`/source/features/hydra` for the full preset system. diff --git a/scripts/benchmarks/benchmark_hydra_resolve.py b/scripts/benchmarks/benchmark_hydra_resolve.py index 63922c5b0b0..ff50c849ae4 100644 --- a/scripts/benchmarks/benchmark_hydra_resolve.py +++ b/scripts/benchmarks/benchmark_hydra_resolve.py @@ -70,7 +70,7 @@ class Case: "cartpole_camera_newton_ovrtx", "Isaac-Cartpole-Camera-Direct", "rl_games_cfg_entry_point", - ("presets=newton_mjwarp,ovrtx_renderer",), + ("presets=newton_mjwarp,ovrtx",), ), Case("anymal_rough_scalar", "IsaacContrib-Velocity-Rough-AnymalC", None, ("env.scene.num_envs=256",)), ) diff --git a/scripts/benchmarks/utils.py b/scripts/benchmarks/utils.py index 05effa52417..18b911910bd 100644 --- a/scripts/benchmarks/utils.py +++ b/scripts/benchmarks/utils.py @@ -125,7 +125,7 @@ def get_preset_string(hydra_args: list[str]) -> str: """Extract the active preset string from CLI hydra args or an environment variable. Checks (in order): - 1. ``presets=...`` in *hydra_args* (e.g. ``presets=physx,ovrtx_renderer,rgb``) + 1. ``presets=...`` in *hydra_args* (e.g. ``presets=physx,ovrtx,rgb``) 2. ``ISAACLAB_BENCHMARK_PRESET`` environment variable 3. Falls back to ``"default"`` """ diff --git a/source/isaaclab/changelog.d/renderer-preset-alias.rst b/source/isaaclab/changelog.d/renderer-preset-alias.rst new file mode 100644 index 00000000000..183fe0cc2eb --- /dev/null +++ b/source/isaaclab/changelog.d/renderer-preset-alias.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Updated the ``launch_simulation`` runtime-compatibility error and help text to + reference the suffix-less renderer presets ``ovrtx`` and ``isaacsim_rtx`` + instead of ``ovrtx_renderer`` and ``isaacsim_rtx_renderer``. diff --git a/source/isaaclab/isaaclab/app/sim_launcher.py b/source/isaaclab/isaaclab/app/sim_launcher.py index c1f82afc1dd..27e7b0932eb 100644 --- a/source/isaaclab/isaaclab/app/sim_launcher.py +++ b/source/isaaclab/isaaclab/app/sim_launcher.py @@ -352,11 +352,11 @@ def _validate_runtime(scan: Scan, launcher_args: argparse.Namespace | dict | Non "\n" "To fix this, pick one of the following supported combinations:\n" " * Keep Isaac Sim / Kit and switch the renderer:\n" - " presets=isaacsim_rtx_renderer\n" + " presets=isaacsim_rtx\n" " (uses `IsaacRtxRendererCfg`, the Kit-compatible renderer.)\n" " * Keep the OVRTX renderer and switch to a kitless physics backend\n" " (and avoid `--visualizer kit`):\n" - " presets=newton_mjwarp,ovrtx_renderer\n" + " presets=newton_mjwarp,ovrtx\n" ) @@ -424,7 +424,7 @@ def launch_simulation( # with a targeted hint (_validate_runtime covers the broader ovrtx-vs-Kit cases). if "kit" in visualizer_types and config_scan.has_ovrtx: raise ValueError( - "[launch_simulation] '--visualizer kit' is incompatible with 'ovrtx_renderer'. " + "[launch_simulation] '--visualizer kit' is incompatible with 'ovrtx'. " "Both Kit (Isaac Sim) and ovrtx ship conflicting RTX hydra libraries " "(librtx.hydra.so, liblegacy.hydra.so) compiled against different USD namespaces, " "which causes a dynamic-linker crash when loaded into the same process. " diff --git a/source/isaaclab/isaaclab/test/benchmark/schema.py b/source/isaaclab/isaaclab/test/benchmark/schema.py index 84e6e07f0f2..4d0e0b6ea3d 100644 --- a/source/isaaclab/isaaclab/test/benchmark/schema.py +++ b/source/isaaclab/isaaclab/test/benchmark/schema.py @@ -135,7 +135,7 @@ class RunConfig: rendering_backend: Rendering backend, or ``"none"`` for headless runs with no camera sensors. presets: Active Hydra preset tokens applied to the run (e.g. - ``["rgb", "ovrtx_renderer"]``). Open-ended so sensor data types, + ``["rgb", "ovrtx"]``). Open-ended so sensor data types, resolutions, and any other domain presets are captured without a closed enum; ``physics_backend`` / ``rendering_backend`` surface the two primary grouping dimensions as typed fields. diff --git a/source/isaaclab/test/benchmark/test_schema.py b/source/isaaclab/test/benchmark/test_schema.py index fe028f75598..12e6d6437f4 100644 --- a/source/isaaclab/test/benchmark/test_schema.py +++ b/source/isaaclab/test/benchmark/test_schema.py @@ -300,13 +300,13 @@ def test_extra_field_round_trips(tmp_path): def test_run_config_presets_round_trip(tmp_path): """RunConfig.presets is an open-ended token list; defaults to [] and round-trips.""" assert RunConfig(physics_backend="physx").presets == [] - cfg = RunConfig(physics_backend="newton_mjwarp", rendering_backend="ovrtx", presets=["rgb", "ovrtx_renderer"]) + cfg = RunConfig(physics_backend="newton_mjwarp", rendering_backend="ovrtx", presets=["rgb", "ovrtx"]) base = _minimal_training_bundle() bundle = dataclasses.replace(base, run=dataclasses.replace(base.run, config=cfg)) path = os.path.join(tmp_path, "training.json") write_bundle_file(bundle, path) with open(path) as f: data = json.load(f) - assert data["run"]["config"]["presets"] == ["rgb", "ovrtx_renderer"] + assert data["run"]["config"]["presets"] == ["rgb", "ovrtx"] assert "sensor_dtype" not in data["run"]["config"] assert "sensor_resolution" not in data["run"]["config"] diff --git a/source/isaaclab_tasks/changelog.d/renderer-preset-alias.rst b/source/isaaclab_tasks/changelog.d/renderer-preset-alias.rst new file mode 100644 index 00000000000..ea73d93176d --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/renderer-preset-alias.rst @@ -0,0 +1,16 @@ +Changed +^^^^^^^ + +* Renamed the renderer presets ``ovrtx_renderer`` -> ``ovrtx`` and + ``isaacsim_rtx_renderer`` -> ``isaacsim_rtx`` to drop the redundant + ``_renderer`` suffix (the ``renderer=`` selector already names the category). + The old ``ovrtx_renderer`` / ``isaacsim_rtx_renderer`` spellings still resolve + as deprecated aliases and emit a :class:`FutureWarning`; migrate to the + suffix-less names, which will become the only accepted form in a future + release. + +Deprecated +^^^^^^^^^^ + +* Deprecated the renderer preset names ``ovrtx_renderer`` and + ``isaacsim_rtx_renderer`` in favor of ``ovrtx`` and ``isaacsim_rtx``. diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env_cfg.py index 243cc7cbfad..3ecb37b503d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_camera_env_cfg.py @@ -99,7 +99,7 @@ class ShadowHandTiledCameraCfg(PresetCfg): presets=depth # depth rendering, default renderer presets=depth,newton_renderer # depth rendering with Newton renderer - presets=depth,ovrtx_renderer # depth rendering with OVRTX renderer + presets=depth,ovrtx # depth rendering with OVRTX renderer """ semantic_segmentation: _ShadowHandBaseTiledCameraCfg = _ShadowHandBaseTiledCameraCfg( @@ -166,7 +166,7 @@ class ShadowHandCameraBenchmarkEnvCfg(ShadowHandCameraEnvCfg): The renderer backend and camera data types can still be selected via ``presets``:: presets = newton_renderer # benchmark with Newton renderer - presets = ovrtx_renderer # benchmark with OVRTX renderer + presets = ovrtx # benchmark with OVRTX renderer presets = rgb # benchmark RGB rendering only presets = depth, newton_renderer # benchmark depth rendering with Newton """ diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/preset_target.py b/source/isaaclab_tasks/isaaclab_tasks/utils/preset_target.py index 89878541cf2..5a9ca25c42b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/preset_target.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/preset_target.py @@ -68,8 +68,17 @@ class PresetTarget(enum.Enum): future release. """ - RENDERER = ("renderer", (RendererCfg,)) - """Camera-sensor renderers -- ``renderer=NAME`` selector.""" + RENDERER = ("renderer", (RendererCfg,), {"ovrtx_renderer": "ovrtx", "isaacsim_rtx_renderer": "isaacsim_rtx"}) + """Camera-sensor renderers -- ``renderer=NAME`` selector. + + Legacy aliases ``ovrtx_renderer`` -> ``ovrtx`` and + ``isaacsim_rtx_renderer`` -> ``isaacsim_rtx`` exist because the renderer + presets dropped their redundant ``_renderer`` suffix (the ``renderer=`` + selector already names the category). 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. + """ DOMAIN = ("presets",) """Free-form env-specific presets -- ``presets=NAME[,...]`` selector (catch-all). diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py b/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py index a362752900d..efa77ff12ae 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/presets.py @@ -25,8 +25,8 @@ class MultiBackendRendererCfg(PresetCfg): default: IsaacRtxRendererCfg = IsaacRtxRendererCfg() rtx: _AutoRtxRendererCfg = _AutoRtxRendererCfg() newton_renderer: NewtonWarpRendererCfg = NewtonWarpRendererCfg() - ovrtx_renderer: OVRTXRendererCfg = OVRTXRendererCfg() - isaacsim_rtx_renderer = default + ovrtx: OVRTXRendererCfg = OVRTXRendererCfg() + isaacsim_rtx = default def set_isaac_rtx_global_settings(renderer_cfg: Any, **settings: Any) -> None: @@ -40,7 +40,7 @@ def _visit(cfg: Any) -> None: if getattr(cfg, "renderer_type", None) == "isaac_rtx" and hasattr(cfg, "global_settings"): for key, value in settings.items(): setattr(cfg.global_settings, key, value) - for attr_name in ("default", "isaacsim_rtx_renderer"): + for attr_name in ("default", "isaacsim_rtx"): _visit(getattr(cfg, attr_name, None)) _visit(renderer_cfg) diff --git a/source/isaaclab_tasks/test/core/test_hydra.py b/source/isaaclab_tasks/test/core/test_hydra.py index b6afde637e8..062d709d9ce 100644 --- a/source/isaaclab_tasks/test/core/test_hydra.py +++ b/source/isaaclab_tasks/test/core/test_hydra.py @@ -366,6 +366,27 @@ class _SolverPresetsCfg(PresetCfg): assert cfg.kamino is cfg.newton_kamino +@pytest.mark.parametrize( + "legacy_name,canonical_name", + [ + ("ovrtx_renderer", "ovrtx"), + ("isaacsim_rtx_renderer", "isaacsim_rtx"), + ], +) +def test_legacy_renderer_suffix_attribute_alias_warns(legacy_name, canonical_name): + """The suffixed renderer preset names alias to their ``_renderer``-less fields during deprecation.""" + + @configclass + class _RendererPresetsCfg(PresetCfg): + default: PhysxCfg = PhysxCfg() + ovrtx: PhysxCfg = PhysxCfg() + isaacsim_rtx: PhysxCfg = PhysxCfg() + + cfg = _RendererPresetsCfg() + with pytest.warns(FutureWarning, match=f"Preset '{legacy_name}' is deprecated"): + assert getattr(cfg, legacy_name) is getattr(cfg, canonical_name) + + def test_legacy_alias_suppressed_when_legacy_name_is_real_field(): """An env that legitimately defines ``newton`` should not warn or be remapped.""" diff --git a/source/isaaclab_tasks/test/core/test_preset_kit_decision.py b/source/isaaclab_tasks/test/core/test_preset_kit_decision.py index e546adeb8e4..826787c6783 100644 --- a/source/isaaclab_tasks/test/core/test_preset_kit_decision.py +++ b/source/isaaclab_tasks/test/core/test_preset_kit_decision.py @@ -5,7 +5,7 @@ """Tests for preset resolution and Kit decision logic. -These tests verify that given presets (e.g. ``presets=newton_mjwarp,ovrtx_renderer``), +These tests verify that given presets (e.g. ``presets=newton_mjwarp,ovrtx``), the config-based logic correctly decides whether Isaac Sim Kit is needed. No Kit/GPU required — safe for CI and beginners. """ @@ -69,7 +69,7 @@ def test_rtx_is_renderer_selector(): 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") + env_cfg = _resolve_with_presets("newton_mjwarp,ovrtx") needs_kit = scan(env_cfg).needs_kit assert needs_kit is False @@ -142,6 +142,6 @@ def test_preset_default_needs_kit(): def test_preset_mjwarp_isaac_rtx_needs_kit(): """Newton + Isaac RTX renderer requires Kit (RTX runs in Kit).""" - env_cfg = _resolve_with_presets("newton_mjwarp,isaacsim_rtx_renderer") + env_cfg = _resolve_with_presets("newton_mjwarp,isaacsim_rtx") needs_kit = scan(env_cfg).needs_kit assert needs_kit is True diff --git a/source/isaaclab_tasks/test/core/test_runtime_compatibility.py b/source/isaaclab_tasks/test/core/test_runtime_compatibility.py index 7a1fa3140a8..8c29980a1f0 100644 --- a/source/isaaclab_tasks/test/core/test_runtime_compatibility.py +++ b/source/isaaclab_tasks/test/core/test_runtime_compatibility.py @@ -8,7 +8,7 @@ The OVRTX renderer is kitless and cannot run together with Isaac Sim / Kit runtimes (``PhysxCfg`` physics or the Kit visualizer). These tests verify that invalid combinations selected via ``presets=...`` (or ``--visualizer kit``) raise -a clear error pointing the user at the correct ``isaacsim_rtx_renderer`` preset. +a clear error pointing the user at the correct ``isaacsim_rtx`` preset. No Kit/GPU required — safe for CI and beginners. """ @@ -55,19 +55,19 @@ def _resolve_with_presets(presets: str): def test_default_physx_plus_ovrtx_raises(): """Default physics is PhysxCfg; pairing it with the OVRTX renderer must raise.""" - env_cfg = _resolve_with_presets("ovrtx_renderer") + env_cfg = _resolve_with_presets("ovrtx") with pytest.raises(ValueError, match=r"OVRTX renderer.*Isaac Sim / Kit"): validate_runtime_compatibility(env_cfg) def test_explicit_physx_plus_ovrtx_raises(): - """Explicit physx preset + ovrtx_renderer is the canonical invalid combination.""" - env_cfg = _resolve_with_presets("physx,ovrtx_renderer") + """Explicit physx preset + ovrtx is the canonical invalid combination.""" + env_cfg = _resolve_with_presets("physx,ovrtx") with pytest.raises(ValueError) as excinfo: validate_runtime_compatibility(env_cfg) msg = str(excinfo.value) assert "PhysxCfg" in msg - assert "isaacsim_rtx_renderer" in msg + assert "isaacsim_rtx" in msg def test_kit_visualizer_plus_ovrtx_raises(): @@ -76,18 +76,18 @@ def test_kit_visualizer_plus_ovrtx_raises(): Use Newton physics so the only Kit-side runtime is the visualizer; this isolates the visualizer-vs-renderer check from the physics-vs-renderer one. """ - env_cfg = _resolve_with_presets("newton,ovrtx_renderer") + env_cfg = _resolve_with_presets("newton,ovrtx") launcher_args = argparse.Namespace(visualizer="kit") with pytest.raises(ValueError) as excinfo: validate_runtime_compatibility(env_cfg, launcher_args) msg = str(excinfo.value) assert "Kit visualizer" in msg - assert "isaacsim_rtx_renderer" in msg + assert "isaacsim_rtx" in msg def test_kit_visualizer_dict_args_plus_ovrtx_raises(): """The dict form of launcher args (used by Hydra) must also be inspected.""" - env_cfg = _resolve_with_presets("newton,ovrtx_renderer") + env_cfg = _resolve_with_presets("newton,ovrtx") with pytest.raises(ValueError, match=r"Kit visualizer"): validate_runtime_compatibility(env_cfg, {"visualizer": "kit,newton"}) @@ -99,7 +99,7 @@ def test_kit_visualizer_dict_args_plus_ovrtx_raises(): def test_ovphysx_plus_kit_visualizer_raises(): """OvPhysX cannot share a process with the Kit visualizer.""" - env_cfg = _resolve_with_presets("ovphysx,isaacsim_rtx_renderer") + env_cfg = _resolve_with_presets("ovphysx,isaacsim_rtx") launcher_args = argparse.Namespace(visualizer="kit") with pytest.raises(ValueError) as excinfo: validate_runtime_compatibility(env_cfg, launcher_args) @@ -110,7 +110,7 @@ def test_ovphysx_plus_kit_visualizer_raises(): def test_ovphysx_dict_args_plus_kit_visualizer_raises(): """The dict launcher-args form must also reject OvPhysX with Kit visualization.""" - env_cfg = _resolve_with_presets("ovphysx,isaacsim_rtx_renderer") + env_cfg = _resolve_with_presets("ovphysx,isaacsim_rtx") with pytest.raises(ValueError, match=r"OvPhysX.*Kit visualizer"): validate_runtime_compatibility(env_cfg, {"visualizer": "kit,newton"}) @@ -122,13 +122,13 @@ def test_ovphysx_dict_args_plus_kit_visualizer_raises(): def test_newton_plus_ovrtx_is_valid(): """Newton physics + OVRTX renderer is the supported kitless combination.""" - env_cfg = _resolve_with_presets("newton,ovrtx_renderer") + env_cfg = _resolve_with_presets("newton,ovrtx") validate_runtime_compatibility(env_cfg) def test_physx_plus_isaacsim_rtx_is_valid(): """PhysX physics + Isaac RTX renderer is the supported Kit combination.""" - env_cfg = _resolve_with_presets("physx,isaacsim_rtx_renderer") + env_cfg = _resolve_with_presets("physx,isaacsim_rtx") validate_runtime_compatibility(env_cfg) @@ -192,11 +192,11 @@ def test_livestream_rtx_injects_kit_before_auto_rtx_resolution(monkeypatch: pyte def test_newton_plus_isaacsim_rtx_is_valid(): """Newton + Isaac RTX renderer is supported (RTX runs in Kit, Newton syncs to USD).""" - env_cfg = _resolve_with_presets("newton,isaacsim_rtx_renderer") + env_cfg = _resolve_with_presets("newton,isaacsim_rtx") validate_runtime_compatibility(env_cfg) def test_kit_visualizer_with_isaacsim_rtx_is_valid(): """``--visualizer kit`` is fine as long as no OVRTX renderer is configured.""" - env_cfg = _resolve_with_presets("newton,isaacsim_rtx_renderer") + env_cfg = _resolve_with_presets("newton,isaacsim_rtx") validate_runtime_compatibility(env_cfg, argparse.Namespace(visualizer="kit")) diff --git a/source/isaaclab_tasks/test/core/test_shadow_hand_camera_presets.py b/source/isaaclab_tasks/test/core/test_shadow_hand_camera_presets.py index efcf08d5be6..828c06fc0d1 100644 --- a/source/isaaclab_tasks/test/core/test_shadow_hand_camera_presets.py +++ b/source/isaaclab_tasks/test/core/test_shadow_hand_camera_presets.py @@ -235,9 +235,9 @@ def test_all_camera_presets_present(shadow_hand_camera_presets): _RENDERER_PRESETS = [ # preset_name, expected_class ("default", IsaacRtxRendererCfg), - ("isaacsim_rtx_renderer", IsaacRtxRendererCfg), + ("isaacsim_rtx", IsaacRtxRendererCfg), ("newton_renderer", NewtonWarpRendererCfg), - pytest.param("ovrtx_renderer", OVRTXRendererCfg, marks=pytest.mark.skip(reason="OVRTX testing disabled")), + pytest.param("ovrtx", OVRTXRendererCfg, marks=pytest.mark.skip(reason="OVRTX testing disabled")), ] @@ -267,7 +267,7 @@ def test_rtx_preset_has_auto_renderer_type(shadow_hand_camera_presets): def test_all_renderer_presets_present(shadow_hand_camera_presets): """Every preset in MultiBackendRendererCfg is discoverable.""" renderer_presets = shadow_hand_camera_presets["tiled_camera.renderer_cfg"] - expected_names = {"default", "rtx", "isaacsim_rtx_renderer", "newton_renderer", "ovrtx_renderer"} + expected_names = {"default", "rtx", "isaacsim_rtx", "newton_renderer", "ovrtx"} missing = expected_names - set(renderer_presets.keys()) assert not missing, f"Renderer presets missing from collected presets: {missing}" From 41287701c21f651d8b81b7f93145010b5bd7a752 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:02:42 +0000 Subject: [PATCH 108/139] [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: 9.0.0 → 10.0.0 - isaaclab_newton: 1.6.0 → 1.6.1 - isaaclab_ovphysx: 6.1.1 → 6.2.0 - isaaclab_physx: 2.7.0 → 2.7.1 - isaaclab_tasks: 8.1.6 → 8.1.7 - isaaclab_visualizers: 1.1.0 → 1.1.1 --- .../articulation-reordering-p1.skip | 0 .../centralize-deps-root-pyproject.rst | 30 ---- .../changelog.d/fabric-local-poses.rst | 31 ---- .../fix-sensor-freshness-cache.rst | 5 - .../changelog.d/intent-test-markers.rst | 8 - ...saacsim-extra-newton-viewer-base.minor.rst | 19 --- ...h-fix-newton-mgpu-capture-device-codex.rst | 5 - .../jichuanh-mgpu-pin-kit-resources.rst | 14 -- .../mh-cli-subcommand-entrypoints.major.rst | 8 - .../mh-isaacsim-install-single-source.rst | 7 - .../mh-usd-exchange-aarch64-only.rst | 11 -- .../changelog.d/renderer-preset-alias.rst | 6 - .../isaaclab/changelog.d/uv-root-dotenv.rst | 4 - .../changelog.d/xform-space-writer.rst | 35 ----- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 148 ++++++++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../centralize-deps-root-pyproject.skip | 4 - .../centralize-deps-root-pyproject.skip | 4 - .../changelog.d/xform-space-writer.skip | 1 - .../centralize-deps-root-pyproject.skip | 4 - .../changelog.d/fabric-local-poses.rst | 19 --- ...h-fix-newton-mgpu-capture-device-codex.rst | 5 - .../changelog.d/pin-newton-c7ae7c7.rst | 20 --- .../changelog.d/xform-space-writer.rst | 13 -- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 53 +++++++ source/isaaclab_newton/pyproject.toml | 2 +- .../centralize-deps-root-pyproject.skip | 4 - ...ner-feat-ovphysx_shape_materials.minor.rst | 9 -- .../centralize-deps-root-pyproject.skip | 4 - .../changelog.d/fabric-local-poses.rst | 19 --- .../changelog.d/xform-space-writer.rst | 13 -- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 44 ++++++ source/isaaclab_ovphysx/pyproject.toml | 2 +- .../centralize-deps-root-pyproject.skip | 4 - .../changelog.d/fabric-local-poses.rst | 36 ----- .../changelog.d/pin-newton-c7ae7c7.rst | 6 - .../changelog.d/xform-space-writer.rst | 36 ----- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 81 ++++++++++ source/isaaclab_physx/pyproject.toml | 2 +- .../centralize-deps-root-pyproject.skip | 4 - .../centralize-deps-root-pyproject.skip | 4 - ...-fix-newton-mgpu-capture-device-codex.skip | 1 - .../changelog.d/renderer-preset-alias.rst | 16 -- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 21 +++ source/isaaclab_tasks/pyproject.toml | 2 +- .../centralize-deps-root-pyproject.skip | 4 - .../centralize-deps-root-pyproject.skip | 4 - .../changelog.d/pin-newton-c7ae7c7.rst | 6 - .../config/extension.toml | 2 +- .../isaaclab_visualizers/docs/CHANGELOG.rst | 11 ++ source/isaaclab_visualizers/pyproject.toml | 2 +- 56 files changed, 370 insertions(+), 435 deletions(-) delete mode 100644 source/isaaclab/changelog.d/articulation-reordering-p1.skip delete mode 100644 source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst delete mode 100644 source/isaaclab/changelog.d/fabric-local-poses.rst delete mode 100644 source/isaaclab/changelog.d/fix-sensor-freshness-cache.rst delete mode 100644 source/isaaclab/changelog.d/intent-test-markers.rst delete mode 100644 source/isaaclab/changelog.d/isaacsim-extra-newton-viewer-base.minor.rst delete mode 100644 source/isaaclab/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst delete mode 100644 source/isaaclab/changelog.d/jichuanh-mgpu-pin-kit-resources.rst delete mode 100644 source/isaaclab/changelog.d/mh-cli-subcommand-entrypoints.major.rst delete mode 100644 source/isaaclab/changelog.d/mh-isaacsim-install-single-source.rst delete mode 100644 source/isaaclab/changelog.d/mh-usd-exchange-aarch64-only.rst delete mode 100644 source/isaaclab/changelog.d/renderer-preset-alias.rst delete mode 100644 source/isaaclab/changelog.d/uv-root-dotenv.rst delete mode 100644 source/isaaclab/changelog.d/xform-space-writer.rst delete mode 100644 source/isaaclab_contrib/changelog.d/centralize-deps-root-pyproject.skip delete mode 100644 source/isaaclab_mimic/changelog.d/centralize-deps-root-pyproject.skip delete mode 100644 source/isaaclab_mimic/changelog.d/xform-space-writer.skip delete mode 100644 source/isaaclab_newton/changelog.d/centralize-deps-root-pyproject.skip delete mode 100644 source/isaaclab_newton/changelog.d/fabric-local-poses.rst delete mode 100644 source/isaaclab_newton/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst delete mode 100644 source/isaaclab_newton/changelog.d/pin-newton-c7ae7c7.rst delete mode 100644 source/isaaclab_newton/changelog.d/xform-space-writer.rst delete mode 100644 source/isaaclab_ov/changelog.d/centralize-deps-root-pyproject.skip delete mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_shape_materials.minor.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/centralize-deps-root-pyproject.skip delete mode 100644 source/isaaclab_ovphysx/changelog.d/fabric-local-poses.rst delete mode 100644 source/isaaclab_ovphysx/changelog.d/xform-space-writer.rst delete mode 100644 source/isaaclab_physx/changelog.d/centralize-deps-root-pyproject.skip delete mode 100644 source/isaaclab_physx/changelog.d/fabric-local-poses.rst delete mode 100644 source/isaaclab_physx/changelog.d/pin-newton-c7ae7c7.rst delete mode 100644 source/isaaclab_physx/changelog.d/xform-space-writer.rst delete mode 100644 source/isaaclab_rl/changelog.d/centralize-deps-root-pyproject.skip delete mode 100644 source/isaaclab_tasks/changelog.d/centralize-deps-root-pyproject.skip delete mode 100644 source/isaaclab_tasks/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.skip delete mode 100644 source/isaaclab_tasks/changelog.d/renderer-preset-alias.rst delete mode 100644 source/isaaclab_teleop/changelog.d/centralize-deps-root-pyproject.skip delete mode 100644 source/isaaclab_visualizers/changelog.d/centralize-deps-root-pyproject.skip delete mode 100644 source/isaaclab_visualizers/changelog.d/pin-newton-c7ae7c7.rst diff --git a/source/isaaclab/changelog.d/articulation-reordering-p1.skip b/source/isaaclab/changelog.d/articulation-reordering-p1.skip deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst b/source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst deleted file mode 100644 index ecab6b97e58..00000000000 --- a/source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst +++ /dev/null @@ -1,30 +0,0 @@ -Changed -^^^^^^^ - -* Centralized all Isaac Lab third-party dependencies (required and optional) - into the root ``pyproject.toml`` as the single source of truth. The wheel - builder (``tools/wheel_builder/gen_pyproject.py``) and the ``./isaaclab.sh -i`` - install CLI now read the root project's ``dependencies`` and - ``optional-dependencies`` instead of per-sub-package declarations and - ``tools/wheel_builder/res/python_packages.toml`` (removed). Sub-package - ``pyproject.toml`` files no longer declare dependencies. The ``./isaaclab.sh -i`` - token syntax is unchanged. -* **Changed:** Newton (``newton[sim]``) is now a core dependency installed in - every environment as the default physics engine, rather than an opt-in extra. - The Newton interactive viewer GUI is also part of the base install, so the - ``newton`` optional extra has been removed; the ``newton`` install token / - ``--extra newton`` is now a no-op kept for backward compatibility. -* ``./isaaclab.sh -i`` now force-installs the pinned Newton git build (from - ``[tool.uv].override-dependencies``) over the older ``newton[sim]`` bundled by - Isaac Sim, so environments get the Newton version Isaac Lab targets. -* Added the ``[tool.isaaclab.versions]`` table to the root ``pyproject.toml`` as - the single source of truth for externally-pinned versions (Isaac Sim, the - torch stack, and the OV renderer/physics wheels). The install CLI, docs, and - CI read these values; a unit test enforces that the literal pins in the extras - and ``[tool.uv].override-dependencies`` stay in sync with the table. -* **Changed:** The aggregate ``all`` extra now contains only packages that can - co-resolve with ``isaacsim`` (the documented ``[all,isaacsim]`` install). - ``ov`` (OVRTX / OvPhysX), ``viser``, and the mimic USD-to-URDF converter - (``nvidia-srl-usd-to-urdf``) are no longer pulled in by ``all`` because their - pins conflict with isaacsim's; install them explicitly with ``--extra ov`` / - ``--extra viser`` / ``--extra mimic`` when not using isaacsim. diff --git a/source/isaaclab/changelog.d/fabric-local-poses.rst b/source/isaaclab/changelog.d/fabric-local-poses.rst deleted file mode 100644 index 37a4f4ea639..00000000000 --- a/source/isaaclab/changelog.d/fabric-local-poses.rst +++ /dev/null @@ -1,31 +0,0 @@ -Added -^^^^^ - -* Added explicit local/world scale getters - :meth:`~isaaclab.sim.views.BaseFrameView.get_local_scales` and - :meth:`~isaaclab.sim.views.BaseFrameView.get_world_scales` to the FrameView - API, implemented for :class:`~isaaclab.sim.views.UsdFrameView`. Scale - writes go through the writer scope (see the ``xform-space-writer`` - fragment). - -* Added :func:`~isaaclab.utils.warp.fabric.decompose_indexed_fabric_transforms`, - :func:`~isaaclab.utils.warp.fabric.compose_indexed_fabric_transforms`, - :func:`~isaaclab.utils.warp.fabric.update_indexed_local_matrix_from_world`, and - :func:`~isaaclab.utils.warp.fabric.update_indexed_world_matrix_from_local` - Warp kernels operating on :class:`wp.indexedfabricarray` for reading and - writing Fabric ``Matrix4d`` attributes (``omni:fabric:worldMatrix`` / - ``omni:fabric:localMatrix``). - -Notes -^^^^^ - -* :meth:`~isaaclab.sim.views.BaseFrameView.get_scales` and - :meth:`~isaaclab.sim.views.BaseFrameView.set_scales` remain supported as - convenience helpers (not deprecated). For reads where the space matters, - prefer the explicit ``get_local_scales`` (operates on ``xformOp:scale``) or - ``get_world_scales`` (composed world-space scale). For writes that also - update poses, prefer batching inside one scope: - ``with view.xform_world_space_writer() as w: w.set_poses(...); w.set_scales(...)`` - (or ``xform_local_space_writer``). - :class:`~isaaclab.sim.views.UsdFrameView` preserves prior behavior by - defaulting :meth:`get_scales` / :meth:`set_scales` to local scales. diff --git a/source/isaaclab/changelog.d/fix-sensor-freshness-cache.rst b/source/isaaclab/changelog.d/fix-sensor-freshness-cache.rst deleted file mode 100644 index d9dfd915aa6..00000000000 --- a/source/isaaclab/changelog.d/fix-sensor-freshness-cache.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed repeated sensor data reads re-running backend updates when the cached - data was already fresh. diff --git a/source/isaaclab/changelog.d/intent-test-markers.rst b/source/isaaclab/changelog.d/intent-test-markers.rst deleted file mode 100644 index c4a8794af32..00000000000 --- a/source/isaaclab/changelog.d/intent-test-markers.rst +++ /dev/null @@ -1,8 +0,0 @@ -Added -^^^^^ - -* Added pytest *level* markers (``unit`` and ``integration``) and a ``rendering`` *flavor* marker - registered in the repository-root ``pyproject.toml`` and applied per file via a module-level - ``pytestmark``, so tests can be filtered by kind (e.g. ``pytest -m unit``, ``pytest -m "not unit"``, - or ``pytest -m "integration and not rendering"``). The repository-root ``conftest.py`` records - each test's markers into the JUnit XML report for CI to categorize uploaded results. diff --git a/source/isaaclab/changelog.d/isaacsim-extra-newton-viewer-base.minor.rst b/source/isaaclab/changelog.d/isaacsim-extra-newton-viewer-base.minor.rst deleted file mode 100644 index 40d049fbeb4..00000000000 --- a/source/isaaclab/changelog.d/isaacsim-extra-newton-viewer-base.minor.rst +++ /dev/null @@ -1,19 +0,0 @@ -Added -^^^^^ - -* Added an ``isaacsim`` extra to the root ``pyproject.toml`` so the PhysX - backend can be pulled in directly under uv, e.g. - ``uv run --extra isaacsim isaaclab train --task Isaac-Cartpole-Direct presets=physx``. - Isaac Sim narrows ``newton`` to its own pinned version, while the base install - otherwise tracks the latest ``newton[sim]>=1.2.0`` from the package index. - -Changed -^^^^^^^ - -* The Newton interactive viewer GUI (``imgui-bundle``, ``typing-extensions``) is - now part of the base install, so the viewer's HUD controls work without - ``--extra newton``. The ``newton`` extra / ``-i newton`` token is retained as a - backwards-compatible alias. -* The Isaac Sim version is now declared once in the root ``pyproject.toml`` - ``isaacsim`` extra and read from there by the documentation build and the - license-check CI workflow, instead of being hard-coded in each location. diff --git a/source/isaaclab/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst b/source/isaaclab/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst deleted file mode 100644 index ba2c7b78ec0..00000000000 --- a/source/isaaclab/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed PyTorch and Warp selecting different CUDA devices during simulation - initialization. diff --git a/source/isaaclab/changelog.d/jichuanh-mgpu-pin-kit-resources.rst b/source/isaaclab/changelog.d/jichuanh-mgpu-pin-kit-resources.rst deleted file mode 100644 index d2f13c6efca..00000000000 --- a/source/isaaclab/changelog.d/jichuanh-mgpu-pin-kit-resources.rst +++ /dev/null @@ -1,14 +0,0 @@ -Changed -^^^^^^^ - -* Changed the Isaac Lab Kit experiences to use one renderer GPU by default. To - enable single-process multi-GPU rendering, pass the ``renderer.multiGpu`` - settings explicitly through :class:`~isaaclab.app.AppLauncher`'s ``kit_args``. - -Added -^^^^^ - -* Added ``ISAACLAB_FABRIC_USE_GPU_INTEROP`` to override the corresponding PhysX - Fabric Kit setting without changing renderer multi-GPU behavior. The multi-GPU - CI override is a temporary workaround to remove after the underlying Kit/PhysX - problem is fixed. diff --git a/source/isaaclab/changelog.d/mh-cli-subcommand-entrypoints.major.rst b/source/isaaclab/changelog.d/mh-cli-subcommand-entrypoints.major.rst deleted file mode 100644 index 95ad94c9eaf..00000000000 --- a/source/isaaclab/changelog.d/mh-cli-subcommand-entrypoints.major.rst +++ /dev/null @@ -1,8 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** Removed the standalone ``train``, ``play``, and ``train_multigpu`` - console scripts. These are now subcommands of the ``isaaclab`` entry point - (``isaaclab train`` / ``isaaclab play`` / ``isaaclab train_multigpu``, e.g. - ``uv run isaaclab train ...``) so they no longer clash with ``train``/``play`` - commands provided by other installed packages. diff --git a/source/isaaclab/changelog.d/mh-isaacsim-install-single-source.rst b/source/isaaclab/changelog.d/mh-isaacsim-install-single-source.rst deleted file mode 100644 index dad00ed77d3..00000000000 --- a/source/isaaclab/changelog.d/mh-isaacsim-install-single-source.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed ``./isaaclab.sh -i`` (PhysX path) to install the Isaac Sim requirement - pinned in the root ``pyproject.toml`` ``isaacsim`` extra, instead of a - hard-coded ``isaacsim[all]>=6.0.0``. The install CLI now shares the single - source of truth used by the docs, CI, and ``uv run --extra isaacsim``. diff --git a/source/isaaclab/changelog.d/mh-usd-exchange-aarch64-only.rst b/source/isaaclab/changelog.d/mh-usd-exchange-aarch64-only.rst deleted file mode 100644 index 2f66a38115c..00000000000 --- a/source/isaaclab/changelog.d/mh-usd-exchange-aarch64-only.rst +++ /dev/null @@ -1,11 +0,0 @@ -Fixed -^^^^^ - -* Fixed a broken ``pxr`` (OpenUSD) runtime on x86_64 caused by co-installing - ``usd-core`` and ``usd-exchange``. Both wheels vendor a complete ``pxr`` - package at different USD versions, so installing both left a mixed - installation that raised ``RuntimeError: extension class wrapper for base - class ... Tf_PyEnumWrapper has not been created yet`` on ``import pxr``. - ``usd-exchange`` is now installed only on ``aarch64``/``arm64`` (where - ``usd-core`` has no wheel); x86_64 uses ``usd-core`` as the sole ``pxr`` - provider. diff --git a/source/isaaclab/changelog.d/renderer-preset-alias.rst b/source/isaaclab/changelog.d/renderer-preset-alias.rst deleted file mode 100644 index 183fe0cc2eb..00000000000 --- a/source/isaaclab/changelog.d/renderer-preset-alias.rst +++ /dev/null @@ -1,6 +0,0 @@ -Changed -^^^^^^^ - -* Updated the ``launch_simulation`` runtime-compatibility error and help text to - reference the suffix-less renderer presets ``ovrtx`` and ``isaacsim_rtx`` - instead of ``ovrtx_renderer`` and ``isaacsim_rtx_renderer``. diff --git a/source/isaaclab/changelog.d/uv-root-dotenv.rst b/source/isaaclab/changelog.d/uv-root-dotenv.rst deleted file mode 100644 index ff32f72cd94..00000000000 --- a/source/isaaclab/changelog.d/uv-root-dotenv.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* Added documentation for ``uv run`` workflows with repo-local Isaac Sim source or binary installs. diff --git a/source/isaaclab/changelog.d/xform-space-writer.rst b/source/isaaclab/changelog.d/xform-space-writer.rst deleted file mode 100644 index f3b2986beb6..00000000000 --- a/source/isaaclab/changelog.d/xform-space-writer.rst +++ /dev/null @@ -1,35 +0,0 @@ -Added -^^^^^ - -* Added :class:`~isaaclab.sim.views.FrameViewSpaceWriterBase`, the new context-managed - write API for ``FrameView``-managed prim transforms. Open with - ``view.xform_world_space_writer()`` or ``view.xform_local_space_writer()`` and call - :meth:`~isaaclab.sim.views.FrameViewSpaceWriterBase.set_poses` / - :meth:`~isaaclab.sim.views.FrameViewSpaceWriterBase.set_scales` inside the scope; - the writer's ``__exit__`` derives the opposite-space matrices once and - synchronizes once. Only one writer scope may be active per view at a - time. View-level getters - (:meth:`~isaaclab.sim.views.BaseFrameView.get_world_poses` etc.) raise - :class:`RuntimeError` while a writer scope is active. - -* Added the two concrete tag classes - :class:`~isaaclab.sim.views.FrameViewWorldSpaceWriter` and - :class:`~isaaclab.sim.views.FrameViewLocalSpaceWriter` returned by - :meth:`~isaaclab.sim.views.BaseFrameView.xform_world_space_writer` / - :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer`. - -Notes -^^^^^ - -* :meth:`~isaaclab.sim.views.BaseFrameView.set_world_poses`, - :meth:`~isaaclab.sim.views.BaseFrameView.set_local_poses`, and - :meth:`~isaaclab.sim.views.BaseFrameView.set_scales` remain supported as - convenience helpers (not deprecated). Each opens a single-statement writer - scope internally, so updating poses and scales through separate calls derives - the opposite-space matrices and synchronizes twice. For best performance, - update both inside one scope: - ``with view.xform_world_space_writer() as w: w.set_poses(...); w.set_scales(...)`` - (or :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer`). - The bundled examples use the writer scope to get this benefit; callers may - keep the convenience helpers when code simplicity matters more than shaving - a redundant derive/sync. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index ec207c8f819..bdd13d8e8f5 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 = "9.0.0" +version = "10.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 818078ed1c5..da3a6579359 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,154 @@ Changelog --------- +10.0.0 (2026-07-08) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added explicit local/world scale getters + :meth:`~isaaclab.sim.views.BaseFrameView.get_local_scales` and + :meth:`~isaaclab.sim.views.BaseFrameView.get_world_scales` to the FrameView + API, implemented for :class:`~isaaclab.sim.views.UsdFrameView`. Scale + writes go through the writer scope (see the ``xform-space-writer`` + fragment). + +* Added :func:`~isaaclab.utils.warp.fabric.decompose_indexed_fabric_transforms`, + :func:`~isaaclab.utils.warp.fabric.compose_indexed_fabric_transforms`, + :func:`~isaaclab.utils.warp.fabric.update_indexed_local_matrix_from_world`, and + :func:`~isaaclab.utils.warp.fabric.update_indexed_world_matrix_from_local` + Warp kernels operating on :class:`wp.indexedfabricarray` for reading and + writing Fabric ``Matrix4d`` attributes (``omni:fabric:worldMatrix`` / + ``omni:fabric:localMatrix``). +* Added :class:`~isaaclab.sim.views.FrameViewSpaceWriterBase`, the new context-managed + write API for ``FrameView``-managed prim transforms. Open with + ``view.xform_world_space_writer()`` or ``view.xform_local_space_writer()`` and call + :meth:`~isaaclab.sim.views.FrameViewSpaceWriterBase.set_poses` / + :meth:`~isaaclab.sim.views.FrameViewSpaceWriterBase.set_scales` inside the scope; + the writer's ``__exit__`` derives the opposite-space matrices once and + synchronizes once. Only one writer scope may be active per view at a + time. View-level getters + (:meth:`~isaaclab.sim.views.BaseFrameView.get_world_poses` etc.) raise + :class:`RuntimeError` while a writer scope is active. + +* Added the two concrete tag classes + :class:`~isaaclab.sim.views.FrameViewWorldSpaceWriter` and + :class:`~isaaclab.sim.views.FrameViewLocalSpaceWriter` returned by + :meth:`~isaaclab.sim.views.BaseFrameView.xform_world_space_writer` / + :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer`. +* Added pytest *level* markers (``unit`` and ``integration``) and a ``rendering`` *flavor* marker + registered in the repository-root ``pyproject.toml`` and applied per file via a module-level + ``pytestmark``, so tests can be filtered by kind (e.g. ``pytest -m unit``, ``pytest -m "not unit"``, + or ``pytest -m "integration and not rendering"``). The repository-root ``conftest.py`` records + each test's markers into the JUnit XML report for CI to categorize uploaded results. +* Added ``ISAACLAB_FABRIC_USE_GPU_INTEROP`` to override the corresponding PhysX + Fabric Kit setting without changing renderer multi-GPU behavior. The multi-GPU + CI override is a temporary workaround to remove after the underlying Kit/PhysX + problem is fixed. +* Added an ``isaacsim`` extra to the root ``pyproject.toml`` so the PhysX + backend can be pulled in directly under uv, e.g. + ``uv run --extra isaacsim isaaclab train --task Isaac-Cartpole-Direct presets=physx``. + Isaac Sim narrows ``newton`` to its own pinned version, while the base install + otherwise tracks the latest ``newton[sim]>=1.2.0`` from the package index. +* Added documentation for ``uv run`` workflows with repo-local Isaac Sim source or binary installs. + +Changed +^^^^^^^ + +* Changed the Isaac Lab Kit experiences to use one renderer GPU by default. To + enable single-process multi-GPU rendering, pass the ``renderer.multiGpu`` + settings explicitly through :class:`~isaaclab.app.AppLauncher`'s ``kit_args``. +* Centralized all Isaac Lab third-party dependencies (required and optional) + into the root ``pyproject.toml`` as the single source of truth. The wheel + builder (``tools/wheel_builder/gen_pyproject.py``) and the ``./isaaclab.sh -i`` + install CLI now read the root project's ``dependencies`` and + ``optional-dependencies`` instead of per-sub-package declarations and + ``tools/wheel_builder/res/python_packages.toml`` (removed). Sub-package + ``pyproject.toml`` files no longer declare dependencies. The ``./isaaclab.sh -i`` + token syntax is unchanged. +* **Changed:** Newton (``newton[sim]``) is now a core dependency installed in + every environment as the default physics engine, rather than an opt-in extra. + The Newton interactive viewer GUI is also part of the base install, so the + ``newton`` optional extra has been removed; the ``newton`` install token / + ``--extra newton`` is now a no-op kept for backward compatibility. +* ``./isaaclab.sh -i`` now force-installs the pinned Newton git build (from + ``[tool.uv].override-dependencies``) over the older ``newton[sim]`` bundled by + Isaac Sim, so environments get the Newton version Isaac Lab targets. +* Added the ``[tool.isaaclab.versions]`` table to the root ``pyproject.toml`` as + the single source of truth for externally-pinned versions (Isaac Sim, the + torch stack, and the OV renderer/physics wheels). The install CLI, docs, and + CI read these values; a unit test enforces that the literal pins in the extras + and ``[tool.uv].override-dependencies`` stay in sync with the table. +* **Changed:** The aggregate ``all`` extra now contains only packages that can + co-resolve with ``isaacsim`` (the documented ``[all,isaacsim]`` install). + ``ov`` (OVRTX / OvPhysX), ``viser``, and the mimic USD-to-URDF converter + (``nvidia-srl-usd-to-urdf``) are no longer pulled in by ``all`` because their + pins conflict with isaacsim's; install them explicitly with ``--extra ov`` / + ``--extra viser`` / ``--extra mimic`` when not using isaacsim. +* The Newton interactive viewer GUI (``imgui-bundle``, ``typing-extensions``) is + now part of the base install, so the viewer's HUD controls work without + ``--extra newton``. The ``newton`` extra / ``-i newton`` token is retained as a + backwards-compatible alias. +* The Isaac Sim version is now declared once in the root ``pyproject.toml`` + ``isaacsim`` extra and read from there by the documentation build and the + license-check CI workflow, instead of being hard-coded in each location. +* **Breaking:** Removed the standalone ``train``, ``play``, and ``train_multigpu`` + console scripts. These are now subcommands of the ``isaaclab`` entry point + (``isaaclab train`` / ``isaaclab play`` / ``isaaclab train_multigpu``, e.g. + ``uv run isaaclab train ...``) so they no longer clash with ``train``/``play`` + commands provided by other installed packages. +* Updated the ``launch_simulation`` runtime-compatibility error and help text to + reference the suffix-less renderer presets ``ovrtx`` and ``isaacsim_rtx`` + instead of ``ovrtx_renderer`` and ``isaacsim_rtx_renderer``. + +Fixed +^^^^^ + +* Fixed repeated sensor data reads re-running backend updates when the cached + data was already fresh. +* Fixed PyTorch and Warp selecting different CUDA devices during simulation + initialization. +* Fixed ``./isaaclab.sh -i`` (PhysX path) to install the Isaac Sim requirement + pinned in the root ``pyproject.toml`` ``isaacsim`` extra, instead of a + hard-coded ``isaacsim[all]>=6.0.0``. The install CLI now shares the single + source of truth used by the docs, CI, and ``uv run --extra isaacsim``. +* Fixed a broken ``pxr`` (OpenUSD) runtime on x86_64 caused by co-installing + ``usd-core`` and ``usd-exchange``. Both wheels vendor a complete ``pxr`` + package at different USD versions, so installing both left a mixed + installation that raised ``RuntimeError: extension class wrapper for base + class ... Tf_PyEnumWrapper has not been created yet`` on ``import pxr``. + ``usd-exchange`` is now installed only on ``aarch64``/``arm64`` (where + ``usd-core`` has no wheel); x86_64 uses ``usd-core`` as the sole ``pxr`` + provider. + +Notes +^^^^^ + +* :meth:`~isaaclab.sim.views.BaseFrameView.get_scales` and + :meth:`~isaaclab.sim.views.BaseFrameView.set_scales` remain supported as + convenience helpers (not deprecated). For reads where the space matters, + prefer the explicit ``get_local_scales`` (operates on ``xformOp:scale``) or + ``get_world_scales`` (composed world-space scale). For writes that also + update poses, prefer batching inside one scope: + ``with view.xform_world_space_writer() as w: w.set_poses(...); w.set_scales(...)`` + (or ``xform_local_space_writer``). + :class:`~isaaclab.sim.views.UsdFrameView` preserves prior behavior by + defaulting :meth:`get_scales` / :meth:`set_scales` to local scales. +* :meth:`~isaaclab.sim.views.BaseFrameView.set_world_poses`, + :meth:`~isaaclab.sim.views.BaseFrameView.set_local_poses`, and + :meth:`~isaaclab.sim.views.BaseFrameView.set_scales` remain supported as + convenience helpers (not deprecated). Each opens a single-statement writer + scope internally, so updating poses and scales through separate calls derives + the opposite-space matrices and synchronizes twice. For best performance, + update both inside one scope: + ``with view.xform_world_space_writer() as w: w.set_poses(...); w.set_scales(...)`` + (or :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer`). + The bundled examples use the writer scope to get this benefit; callers may + keep the convenience helpers when code simplicity matters more than shaving + a redundant derive/sync. + + 9.0.0 (2026-07-07) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 720379442cc..339bbc1e434 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "9.0.0" +version = "10.0.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_contrib/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_contrib/changelog.d/centralize-deps-root-pyproject.skip deleted file mode 100644 index ed67a1a5272..00000000000 --- a/source/isaaclab_contrib/changelog.d/centralize-deps-root-pyproject.skip +++ /dev/null @@ -1,4 +0,0 @@ -# No changelog entry / version bump: third-party dependency declarations -# were relocated to the root pyproject.toml (single source of truth). The -# published isaaclab wheel is monolithic and unaffected; the user-facing change -# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_mimic/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_mimic/changelog.d/centralize-deps-root-pyproject.skip deleted file mode 100644 index ed67a1a5272..00000000000 --- a/source/isaaclab_mimic/changelog.d/centralize-deps-root-pyproject.skip +++ /dev/null @@ -1,4 +0,0 @@ -# No changelog entry / version bump: third-party dependency declarations -# were relocated to the root pyproject.toml (single source of truth). The -# published isaaclab wheel is monolithic and unaffected; the user-facing change -# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_mimic/changelog.d/xform-space-writer.skip b/source/isaaclab_mimic/changelog.d/xform-space-writer.skip deleted file mode 100644 index 8556272d818..00000000000 --- a/source/isaaclab_mimic/changelog.d/xform-space-writer.skip +++ /dev/null @@ -1 +0,0 @@ -no user-facing change: internal migration of one set_world_poses call site to the new FrameViewSpaceWriter context API diff --git a/source/isaaclab_newton/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_newton/changelog.d/centralize-deps-root-pyproject.skip deleted file mode 100644 index ed67a1a5272..00000000000 --- a/source/isaaclab_newton/changelog.d/centralize-deps-root-pyproject.skip +++ /dev/null @@ -1,4 +0,0 @@ -# No changelog entry / version bump: third-party dependency declarations -# were relocated to the root pyproject.toml (single source of truth). The -# published isaaclab wheel is monolithic and unaffected; the user-facing change -# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_newton/changelog.d/fabric-local-poses.rst b/source/isaaclab_newton/changelog.d/fabric-local-poses.rst deleted file mode 100644 index c5c0b0f3c7d..00000000000 --- a/source/isaaclab_newton/changelog.d/fabric-local-poses.rst +++ /dev/null @@ -1,19 +0,0 @@ -Added -^^^^^ - -* Added :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.get_local_scales` - and :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.get_world_scales` - for reading transform (xform) scales. Scale writes go through the writer - scope (see the ``xform-space-writer`` fragment). These transform-scale - APIs are intentionally separate from Newton collision shape geometry - sizes. - -Deprecated -^^^^^^^^^^ - -* Deprecated :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.get_scales` - and :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.set_scales` in favor - of the explicit transform-scale getters ``get_world_scales`` / - ``get_local_scales`` (and the writer scope's ``set_scales``). The - deprecated methods still work but emit a ``DeprecationWarning`` and - preserve Newton's legacy collision shape geometry-scale behavior. diff --git a/source/isaaclab_newton/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst b/source/isaaclab_newton/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst deleted file mode 100644 index c53da690a8d..00000000000 --- a/source/isaaclab_newton/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed Newton physics failing to initialize on non-default CUDA devices - (``cuda:1`` and higher). diff --git a/source/isaaclab_newton/changelog.d/pin-newton-c7ae7c7.rst b/source/isaaclab_newton/changelog.d/pin-newton-c7ae7c7.rst deleted file mode 100644 index e49d3acc135..00000000000 --- a/source/isaaclab_newton/changelog.d/pin-newton-c7ae7c7.rst +++ /dev/null @@ -1,20 +0,0 @@ -Changed -^^^^^^^ - -* Changed the ``newton[sim]`` dependency pin to Newton commit - ``c7ae7c7648cd0717df39e5c94b95d5a02c997320``, which includes the experimental - coupled solver framework. - -Added -^^^^^ - -* Added the ``newton-usd-schemas`` dependency, required by Newton's USD parsing - since the new pin. - -Fixed -^^^^^ - -* Fixed the cloner label renaming after Newton's removal of - ``ModelBuilder.equality_constraint_label`` by dropping the equality - constraint fallback; equality constraint labels are renamed through the - generic custom attribute handling. diff --git a/source/isaaclab_newton/changelog.d/xform-space-writer.rst b/source/isaaclab_newton/changelog.d/xform-space-writer.rst deleted file mode 100644 index e04d5325e6a..00000000000 --- a/source/isaaclab_newton/changelog.d/xform-space-writer.rst +++ /dev/null @@ -1,13 +0,0 @@ -Changed -^^^^^^^ - -* :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` now ships - pass-through ``FrameViewWorldSpaceWriter`` / ``FrameViewLocalSpaceWriter`` - implementations so writes follow the new - :meth:`~isaaclab.sim.views.BaseFrameView.xform_world_space_writer` / - :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer` context API. - ``set_world_poses`` / ``set_local_poses`` shims still work (one-time - ``DeprecationWarning`` per class). The legacy ``set_scales`` / - ``get_scales`` paths continue to operate on Newton collision-shape - geometry sizes -- they are not routed through the writer because the - writer's ``set_scales`` writes the transform-scale state. diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 4894b26e39b..a63ae3bac58 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 = "1.6.0" +version = "1.6.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 d44e6d25e88..736b55e5db3 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,59 @@ Changelog --------- +1.6.1 (2026-07-08) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.get_local_scales` + and :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.get_world_scales` + for reading transform (xform) scales. Scale writes go through the writer + scope (see the ``xform-space-writer`` fragment). These transform-scale + APIs are intentionally separate from Newton collision shape geometry + sizes. +* Added the ``newton-usd-schemas`` dependency, required by Newton's USD parsing + since the new pin. + +Changed +^^^^^^^ + +* :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` now ships + pass-through ``FrameViewWorldSpaceWriter`` / ``FrameViewLocalSpaceWriter`` + implementations so writes follow the new + :meth:`~isaaclab.sim.views.BaseFrameView.xform_world_space_writer` / + :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer` context API. + ``set_world_poses`` / ``set_local_poses`` shims still work (one-time + ``DeprecationWarning`` per class). The legacy ``set_scales`` / + ``get_scales`` paths continue to operate on Newton collision-shape + geometry sizes -- they are not routed through the writer because the + writer's ``set_scales`` writes the transform-scale state. +* Changed the ``newton[sim]`` dependency pin to Newton commit + ``c7ae7c7648cd0717df39e5c94b95d5a02c997320``, which includes the experimental + coupled solver framework. + +Deprecated +^^^^^^^^^^ + +* Deprecated :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.get_scales` + and :meth:`~isaaclab_newton.sim.views.NewtonSiteFrameView.set_scales` in favor + of the explicit transform-scale getters ``get_world_scales`` / + ``get_local_scales`` (and the writer scope's ``set_scales``). The + deprecated methods still work but emit a ``DeprecationWarning`` and + preserve Newton's legacy collision shape geometry-scale behavior. + +Fixed +^^^^^ + +* Fixed the cloner label renaming after Newton's removal of + ``ModelBuilder.equality_constraint_label`` by dropping the equality + constraint fallback; equality constraint labels are renamed through the + generic custom attribute handling. +* Fixed Newton physics failing to initialize on non-default CUDA devices + (``cuda:1`` and higher). + + 1.6.0 (2026-07-04) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index f00d113b8d7..c147d127024 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_newton" -version = "1.6.0" +version = "1.6.1" description = "Extension providing IsaacLab with Newton specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ov/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_ov/changelog.d/centralize-deps-root-pyproject.skip deleted file mode 100644 index ed67a1a5272..00000000000 --- a/source/isaaclab_ov/changelog.d/centralize-deps-root-pyproject.skip +++ /dev/null @@ -1,4 +0,0 @@ -# No changelog entry / version bump: third-party dependency declarations -# were relocated to the root pyproject.toml (single source of truth). The -# published isaaclab wheel is monolithic and unaffected; the user-facing change -# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_shape_materials.minor.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_shape_materials.minor.rst deleted file mode 100644 index b9881b3cf95..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_shape_materials.minor.rst +++ /dev/null @@ -1,9 +0,0 @@ -Added -^^^^^ - -* Added :data:`~isaaclab_ovphysx.tensor_types.SHAPE_FRICTION_AND_RESTITUTION` and - :data:`~isaaclab_ovphysx.tensor_types.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION` aliases for - the per-collision-shape material tensor types (static friction, dynamic friction, - restitution) exposed by the ovphysx wheel. Read and write them through - :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`, e.g. - ``root_view.get_attribute(tensor_types.SHAPE_FRICTION_AND_RESTITUTION)``. diff --git a/source/isaaclab_ovphysx/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_ovphysx/changelog.d/centralize-deps-root-pyproject.skip deleted file mode 100644 index ed67a1a5272..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/centralize-deps-root-pyproject.skip +++ /dev/null @@ -1,4 +0,0 @@ -# No changelog entry / version bump: third-party dependency declarations -# were relocated to the root pyproject.toml (single source of truth). The -# published isaaclab wheel is monolithic and unaffected; the user-facing change -# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_ovphysx/changelog.d/fabric-local-poses.rst b/source/isaaclab_ovphysx/changelog.d/fabric-local-poses.rst deleted file mode 100644 index fdf33f0b7d9..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/fabric-local-poses.rst +++ /dev/null @@ -1,19 +0,0 @@ -Added -^^^^^ - -* Added :meth:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView.get_local_scales` - and :meth:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView.get_world_scales`, - which delegate to the internal :class:`~isaaclab.sim.views.UsdFrameView`. - Scale writes go through the writer scope (see the ``xform-space-writer`` - fragment). - -Deprecated -^^^^^^^^^^ - -* Deprecated :meth:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView.get_scales` and - :meth:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView.set_scales`. For reads, - use the explicit ``get_local_scales`` (operates on ``xformOp:scale``) or - ``get_world_scales``. For writes, use the writer scope's - ``set_scales``. The deprecated methods still work but emit a - ``DeprecationWarning`` and default to local scales, preserving prior - behavior. diff --git a/source/isaaclab_ovphysx/changelog.d/xform-space-writer.rst b/source/isaaclab_ovphysx/changelog.d/xform-space-writer.rst deleted file mode 100644 index 3561232a311..00000000000 --- a/source/isaaclab_ovphysx/changelog.d/xform-space-writer.rst +++ /dev/null @@ -1,13 +0,0 @@ -Changed -^^^^^^^ - -* :class:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView` now ships - pass-through ``FrameViewWorldSpaceWriter`` / ``FrameViewLocalSpaceWriter`` - implementations so writes follow the new - :meth:`~isaaclab.sim.views.BaseFrameView.xform_world_space_writer` / - :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer` context API. - ``set_world_poses`` / ``set_local_poses`` shims still work (one-time - ``DeprecationWarning`` per class). Scale writes inside the writer scope - delegate to the internal :class:`~isaaclab.sim.views.UsdFrameView` and - land in the USD stage (no propagation to OVPhysX-side collision-shape - scales). diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index 17482a81446..c717d11efe2 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 = "6.1.1" +version = "6.2.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 1f62c943719..c7aa43dd5c3 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,50 @@ Changelog --------- +6.2.0 (2026-07-08) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :meth:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView.get_local_scales` + and :meth:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView.get_world_scales`, + which delegate to the internal :class:`~isaaclab.sim.views.UsdFrameView`. + Scale writes go through the writer scope (see the ``xform-space-writer`` + fragment). +* Added :data:`~isaaclab_ovphysx.tensor_types.SHAPE_FRICTION_AND_RESTITUTION` and + :data:`~isaaclab_ovphysx.tensor_types.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION` aliases for + the per-collision-shape material tensor types (static friction, dynamic friction, + restitution) exposed by the ovphysx wheel. Read and write them through + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`, e.g. + ``root_view.get_attribute(tensor_types.SHAPE_FRICTION_AND_RESTITUTION)``. + +Changed +^^^^^^^ + +* :class:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView` now ships + pass-through ``FrameViewWorldSpaceWriter`` / ``FrameViewLocalSpaceWriter`` + implementations so writes follow the new + :meth:`~isaaclab.sim.views.BaseFrameView.xform_world_space_writer` / + :meth:`~isaaclab.sim.views.BaseFrameView.xform_local_space_writer` context API. + ``set_world_poses`` / ``set_local_poses`` shims still work (one-time + ``DeprecationWarning`` per class). Scale writes inside the writer scope + delegate to the internal :class:`~isaaclab.sim.views.UsdFrameView` and + land in the USD stage (no propagation to OVPhysX-side collision-shape + scales). + +Deprecated +^^^^^^^^^^ + +* Deprecated :meth:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView.get_scales` and + :meth:`~isaaclab_ovphysx.sim.views.OvPhysxFrameView.set_scales`. For reads, + use the explicit ``get_local_scales`` (operates on ``xformOp:scale``) or + ``get_world_scales``. For writes, use the writer scope's + ``set_scales``. The deprecated methods still work but emit a + ``DeprecationWarning`` and default to local scales, preserving prior + behavior. + + 6.1.1 (2026-07-07) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index 033901bf6de..09eb71b2053 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ovphysx" -version = "6.1.1" +version = "6.2.0" description = "Extension providing IsaacLab with ovphysx/TensorBindingsAPI specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_physx/changelog.d/centralize-deps-root-pyproject.skip deleted file mode 100644 index ed67a1a5272..00000000000 --- a/source/isaaclab_physx/changelog.d/centralize-deps-root-pyproject.skip +++ /dev/null @@ -1,4 +0,0 @@ -# No changelog entry / version bump: third-party dependency declarations -# were relocated to the root pyproject.toml (single source of truth). The -# published isaaclab wheel is monolithic and unaffected; the user-facing change -# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_physx/changelog.d/fabric-local-poses.rst b/source/isaaclab_physx/changelog.d/fabric-local-poses.rst deleted file mode 100644 index 94c48cf8369..00000000000 --- a/source/isaaclab_physx/changelog.d/fabric-local-poses.rst +++ /dev/null @@ -1,36 +0,0 @@ -Added -^^^^^ - -* Added :func:`~isaaclab.utils.warp.fabric.decompose_indexed_fabric_transforms` - and :func:`~isaaclab.utils.warp.fabric.compose_indexed_fabric_transforms` - Warp kernels. They mirror the existing - ``decompose_fabric_transformation_matrix_to_warp_arrays`` / - ``compose_fabric_transformation_matrix_from_warp_arrays`` kernels but - operate on :class:`wp.indexedfabricarray`, so the view-to-fabric mapping - is baked into the array and the kernel just dereferences - ``ifa[view_index]`` instead of taking a separate ``mapping`` argument. - -* Added :func:`~isaaclab.utils.warp.fabric.update_indexed_local_matrix_from_world` - and :func:`~isaaclab.utils.warp.fabric.update_indexed_world_matrix_from_local` - Warp kernels that propagate ``local = world * inv(parent)`` and - ``world = local * parent`` directly on Fabric storage matrices. - -* Added Fabric-accelerated local-pose read/write paths to - :class:`~isaaclab_physx.sim.views.FabricFrameView`. Local-pose - operations now use :class:`wp.indexedfabricarray` to read and write - ``omni:fabric:localMatrix`` directly on the GPU, propagating between - parent world matrices and child local/world matrices via Warp kernels - without round-tripping through USD. - -* Added topology-change recovery via automatic ``PrepareForReuse`` detection - and per-selection index rebuild. - -Deprecated -^^^^^^^^^^ - -* Deprecated ``get_scales`` / ``set_scales`` on ``FabricFrameView``. For - reads, use the explicit ``get_local_scales`` (operates on - ``localMatrix``) or ``get_world_scales`` (composed world-space scale). - For writes, use the writer scope's ``set_scales``. The deprecated - methods still work but emit a ``DeprecationWarning``; ``FabricFrameView`` - defaults to world (preserving prior behavior). diff --git a/source/isaaclab_physx/changelog.d/pin-newton-c7ae7c7.rst b/source/isaaclab_physx/changelog.d/pin-newton-c7ae7c7.rst deleted file mode 100644 index 36a9ce0bed3..00000000000 --- a/source/isaaclab_physx/changelog.d/pin-newton-c7ae7c7.rst +++ /dev/null @@ -1,6 +0,0 @@ -Changed -^^^^^^^ - -* Changed the ``newton[sim]`` dependency pin of the ``newton`` extra to Newton - commit ``c7ae7c7648cd0717df39e5c94b95d5a02c997320`` and added the - ``newton-usd-schemas`` dependency required by Newton's USD parsing. diff --git a/source/isaaclab_physx/changelog.d/xform-space-writer.rst b/source/isaaclab_physx/changelog.d/xform-space-writer.rst deleted file mode 100644 index c87325bf69a..00000000000 --- a/source/isaaclab_physx/changelog.d/xform-space-writer.rst +++ /dev/null @@ -1,36 +0,0 @@ -Changed -^^^^^^^ - -* :class:`~isaaclab_physx.sim.views.FabricFrameView` now writes Fabric - ``omni:fabric:worldMatrix`` and ``omni:fabric:localMatrix`` through the - new context-managed - :class:`~isaaclab.sim.views.FrameViewSpaceWriterBase` scope. Each scope: - - - eagerly writes both the primary matrix (world or local, per the - chosen space) and derives the opposite-space matrix in a single Warp - kernel on ``__exit__``; - - calls ``wp.synchronize()`` once on ``__exit__``; - - pauses :meth:`IFabricHierarchy.track_local_xform_changes` and - :meth:`track_world_xform_changes` while the scope is active and - restores their prior state on exit, so Fabric Hierarchy's - ``update_world_xforms()`` on the next tick has no recorded changes - to replay for these prims. The Fabric Scene Delegate (FSD) reads - ``omni:fabric:worldMatrix`` from Fabric storage directly and - observes the writes. - - runs the opposite-space derive + ``wp.synchronize()`` on exit even - when the scope unwinds via exception (including ``KeyboardInterrupt`` - in interactive notebooks), as a best-effort to keep ``worldMatrix`` - and ``localMatrix`` mutually consistent prim-by-prim. The partial - write itself is not rolled back -- callers needing transactional - semantics should snapshot the matrices themselves before entering - the scope. - - Two persistent selections back the two access modes: ``_sel_ro`` - (``worldMatrix=RO, localMatrix=RO``, steady state) and ``_sel_rw`` - (``worldMatrix=RW, localMatrix=RW``, used inside a writer scope). - Both are built once during ``_initialize_fabric`` and kept for the - view's lifetime; the writer flips a single ``_is_rw`` flag on - enter/exit and neither selection is rebuilt on the flip. The RO - steady state tells Fabric Hierarchy's next ``update_world_xforms()`` - tick that no attribute is user-authored, so it leaves the pair - alone. diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 3e148db6c69..eeece12ee2a 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 = "2.7.0" +version = "2.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 c4f44204ed2..b614788da24 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,87 @@ Changelog --------- +2.7.1 (2026-07-08) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :func:`~isaaclab.utils.warp.fabric.decompose_indexed_fabric_transforms` + and :func:`~isaaclab.utils.warp.fabric.compose_indexed_fabric_transforms` + Warp kernels. They mirror the existing + ``decompose_fabric_transformation_matrix_to_warp_arrays`` / + ``compose_fabric_transformation_matrix_from_warp_arrays`` kernels but + operate on :class:`wp.indexedfabricarray`, so the view-to-fabric mapping + is baked into the array and the kernel just dereferences + ``ifa[view_index]`` instead of taking a separate ``mapping`` argument. + +* Added :func:`~isaaclab.utils.warp.fabric.update_indexed_local_matrix_from_world` + and :func:`~isaaclab.utils.warp.fabric.update_indexed_world_matrix_from_local` + Warp kernels that propagate ``local = world * inv(parent)`` and + ``world = local * parent`` directly on Fabric storage matrices. + +* Added Fabric-accelerated local-pose read/write paths to + :class:`~isaaclab_physx.sim.views.FabricFrameView`. Local-pose + operations now use :class:`wp.indexedfabricarray` to read and write + ``omni:fabric:localMatrix`` directly on the GPU, propagating between + parent world matrices and child local/world matrices via Warp kernels + without round-tripping through USD. + +* Added topology-change recovery via automatic ``PrepareForReuse`` detection + and per-selection index rebuild. + +Changed +^^^^^^^ + +* :class:`~isaaclab_physx.sim.views.FabricFrameView` now writes Fabric + ``omni:fabric:worldMatrix`` and ``omni:fabric:localMatrix`` through the + new context-managed + :class:`~isaaclab.sim.views.FrameViewSpaceWriterBase` scope. Each scope: + + - eagerly writes both the primary matrix (world or local, per the + chosen space) and derives the opposite-space matrix in a single Warp + kernel on ``__exit__``; + - calls ``wp.synchronize()`` once on ``__exit__``; + - pauses :meth:`IFabricHierarchy.track_local_xform_changes` and + :meth:`track_world_xform_changes` while the scope is active and + restores their prior state on exit, so Fabric Hierarchy's + ``update_world_xforms()`` on the next tick has no recorded changes + to replay for these prims. The Fabric Scene Delegate (FSD) reads + ``omni:fabric:worldMatrix`` from Fabric storage directly and + observes the writes. + - runs the opposite-space derive + ``wp.synchronize()`` on exit even + when the scope unwinds via exception (including ``KeyboardInterrupt`` + in interactive notebooks), as a best-effort to keep ``worldMatrix`` + and ``localMatrix`` mutually consistent prim-by-prim. The partial + write itself is not rolled back -- callers needing transactional + semantics should snapshot the matrices themselves before entering + the scope. + + Two persistent selections back the two access modes: ``_sel_ro`` + (``worldMatrix=RO, localMatrix=RO``, steady state) and ``_sel_rw`` + (``worldMatrix=RW, localMatrix=RW``, used inside a writer scope). + Both are built once during ``_initialize_fabric`` and kept for the + view's lifetime; the writer flips a single ``_is_rw`` flag on + enter/exit and neither selection is rebuilt on the flip. The RO + steady state tells Fabric Hierarchy's next ``update_world_xforms()`` + tick that no attribute is user-authored, so it leaves the pair + alone. +* Changed the ``newton[sim]`` dependency pin of the ``newton`` extra to Newton + commit ``c7ae7c7648cd0717df39e5c94b95d5a02c997320`` and added the + ``newton-usd-schemas`` dependency required by Newton's USD parsing. + +Deprecated +^^^^^^^^^^ + +* Deprecated ``get_scales`` / ``set_scales`` on ``FabricFrameView``. For + reads, use the explicit ``get_local_scales`` (operates on + ``localMatrix``) or ``get_world_scales`` (composed world-space scale). + For writes, use the writer scope's ``set_scales``. The deprecated + methods still work but emit a ``DeprecationWarning``; ``FabricFrameView`` + defaults to world (preserving prior behavior). + + 2.7.0 (2026-07-04) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index 4d58743154b..aead78e0fe9 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "2.7.0" +version = "2.7.1" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_rl/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_rl/changelog.d/centralize-deps-root-pyproject.skip deleted file mode 100644 index ed67a1a5272..00000000000 --- a/source/isaaclab_rl/changelog.d/centralize-deps-root-pyproject.skip +++ /dev/null @@ -1,4 +0,0 @@ -# No changelog entry / version bump: third-party dependency declarations -# were relocated to the root pyproject.toml (single source of truth). The -# published isaaclab wheel is monolithic and unaffected; the user-facing change -# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_tasks/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_tasks/changelog.d/centralize-deps-root-pyproject.skip deleted file mode 100644 index ed67a1a5272..00000000000 --- a/source/isaaclab_tasks/changelog.d/centralize-deps-root-pyproject.skip +++ /dev/null @@ -1,4 +0,0 @@ -# No changelog entry / version bump: third-party dependency declarations -# were relocated to the root pyproject.toml (single source of truth). The -# published isaaclab wheel is monolithic and unaffected; the user-facing change -# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_tasks/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.skip b/source/isaaclab_tasks/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.skip deleted file mode 100644 index f03e45edd95..00000000000 --- a/source/isaaclab_tasks/changelog.d/jichuanh-fix-newton-mgpu-capture-device-codex.skip +++ /dev/null @@ -1 +0,0 @@ -Test-only updates for shared CUDA device-selection coverage. diff --git a/source/isaaclab_tasks/changelog.d/renderer-preset-alias.rst b/source/isaaclab_tasks/changelog.d/renderer-preset-alias.rst deleted file mode 100644 index ea73d93176d..00000000000 --- a/source/isaaclab_tasks/changelog.d/renderer-preset-alias.rst +++ /dev/null @@ -1,16 +0,0 @@ -Changed -^^^^^^^ - -* Renamed the renderer presets ``ovrtx_renderer`` -> ``ovrtx`` and - ``isaacsim_rtx_renderer`` -> ``isaacsim_rtx`` to drop the redundant - ``_renderer`` suffix (the ``renderer=`` selector already names the category). - The old ``ovrtx_renderer`` / ``isaacsim_rtx_renderer`` spellings still resolve - as deprecated aliases and emit a :class:`FutureWarning`; migrate to the - suffix-less names, which will become the only accepted form in a future - release. - -Deprecated -^^^^^^^^^^ - -* Deprecated the renderer preset names ``ovrtx_renderer`` and - ``isaacsim_rtx_renderer`` in favor of ``ovrtx`` and ``isaacsim_rtx``. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 2fdf98600c1..38364a5a339 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 = "8.1.6" +version = "8.1.7" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 7ad8be7ffe9..052c864b85d 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,27 @@ Changelog --------- +8.1.7 (2026-07-08) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Renamed the renderer presets ``ovrtx_renderer`` -> ``ovrtx`` and + ``isaacsim_rtx_renderer`` -> ``isaacsim_rtx`` to drop the redundant + ``_renderer`` suffix (the ``renderer=`` selector already names the category). + The old ``ovrtx_renderer`` / ``isaacsim_rtx_renderer`` spellings still resolve + as deprecated aliases and emit a :class:`FutureWarning`; migrate to the + suffix-less names, which will become the only accepted form in a future + release. + +Deprecated +^^^^^^^^^^ + +* Deprecated the renderer preset names ``ovrtx_renderer`` and + ``isaacsim_rtx_renderer`` in favor of ``ovrtx`` and ``isaacsim_rtx``. + + 8.1.6 (2026-07-07) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index 8f8f7bfda75..8923189bb23 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.1.6" +version = "8.1.7" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_teleop/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_teleop/changelog.d/centralize-deps-root-pyproject.skip deleted file mode 100644 index ed67a1a5272..00000000000 --- a/source/isaaclab_teleop/changelog.d/centralize-deps-root-pyproject.skip +++ /dev/null @@ -1,4 +0,0 @@ -# No changelog entry / version bump: third-party dependency declarations -# were relocated to the root pyproject.toml (single source of truth). The -# published isaaclab wheel is monolithic and unaffected; the user-facing change -# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_visualizers/changelog.d/centralize-deps-root-pyproject.skip b/source/isaaclab_visualizers/changelog.d/centralize-deps-root-pyproject.skip deleted file mode 100644 index ed67a1a5272..00000000000 --- a/source/isaaclab_visualizers/changelog.d/centralize-deps-root-pyproject.skip +++ /dev/null @@ -1,4 +0,0 @@ -# No changelog entry / version bump: third-party dependency declarations -# were relocated to the root pyproject.toml (single source of truth). The -# published isaaclab wheel is monolithic and unaffected; the user-facing change -# is recorded in source/isaaclab/changelog.d/centralize-deps-root-pyproject.rst. diff --git a/source/isaaclab_visualizers/changelog.d/pin-newton-c7ae7c7.rst b/source/isaaclab_visualizers/changelog.d/pin-newton-c7ae7c7.rst deleted file mode 100644 index 8395d1b459e..00000000000 --- a/source/isaaclab_visualizers/changelog.d/pin-newton-c7ae7c7.rst +++ /dev/null @@ -1,6 +0,0 @@ -Changed -^^^^^^^ - -* Changed the ``newton[sim]`` dependency pin of the visualizer extras to Newton - commit ``c7ae7c7648cd0717df39e5c94b95d5a02c997320`` and added the - ``newton-usd-schemas`` dependency required by Newton's USD parsing. diff --git a/source/isaaclab_visualizers/config/extension.toml b/source/isaaclab_visualizers/config/extension.toml index a91c87dbc9a..9d3ae59b6d3 100644 --- a/source/isaaclab_visualizers/config/extension.toml +++ b/source/isaaclab_visualizers/config/extension.toml @@ -1,7 +1,7 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "1.1.0" +version = "1.1.1" # Description category = "isaaclab" diff --git a/source/isaaclab_visualizers/docs/CHANGELOG.rst b/source/isaaclab_visualizers/docs/CHANGELOG.rst index 5f1f33733c0..7936ca4402f 100644 --- a/source/isaaclab_visualizers/docs/CHANGELOG.rst +++ b/source/isaaclab_visualizers/docs/CHANGELOG.rst @@ -1,6 +1,17 @@ Changelog --------- +1.1.1 (2026-07-08) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed the ``newton[sim]`` dependency pin of the visualizer extras to Newton + commit ``c7ae7c7648cd0717df39e5c94b95d5a02c997320`` and added the + ``newton-usd-schemas`` dependency required by Newton's USD parsing. + + 1.1.0 (2026-07-02) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_visualizers/pyproject.toml b/source/isaaclab_visualizers/pyproject.toml index 7fe9e246786..7b8383b80b2 100644 --- a/source/isaaclab_visualizers/pyproject.toml +++ b/source/isaaclab_visualizers/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab-visualizers" -version = "1.1.0" +version = "1.1.1" description = "Visualizer backends for Isaac Lab (Kit, Newton, Rerun, Viser)." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From 9ce409700281f2a0154b6c1b40c003926bf9cd3b Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Wed, 8 Jul 2026 09:39:58 +0200 Subject: [PATCH 109/139] Enable OVPhysX rigid-object-collection material test via OvPhysxView (#6241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked on #6227** (OVPhysX `RigidObjectCollection` → `OvPhysxView`), which is stacked on #6224 (`OvPhysxView`). Review/merge those first. Sibling of #6239 (articulation) and #6240 (rigid object). ## What The ovphysx wheel now exposes per-collision-shape rigid-body material as the `RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION` tensor type. This closes the gap that kept the collection's `test_set_material_properties` `xfail`ed (it was an empty `NotImplementedError` stub written against the PhysX-only `root_view.get/set_material_properties()` API). - Add the `tensor_types` alias `RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION`, guarded with `try/except AttributeError` (shared with the sibling material PRs; documented there). - Implement `test_set_material_properties`, mirroring the PhysX backend but reading/writing the material through `OvPhysxView.get_attribute` / `set_attribute`. The collection's fused binding is body-major flat, so data `[num_envs, num_cubes, 3]` is mapped to/from the view layout with `reshape_data_to_view_3d` and its inverse. ## Verification OVPhysX runs the PhysX solver. The material round-trip passes on both `cpu` and `cuda:0` for `num_envs` 1/3 × `num_cubes` 1/2. The changelog fragment is a `.skip` because the only user-facing addition (the alias) is documented by the sibling material PRs; this PR is test enablement. --- ...physx_rigidobjectcollection_materials.skip | 3 ++ .../assets/test_rigid_object_collection.py | 53 +++++++++++++------ 2 files changed, 39 insertions(+), 17 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_materials.skip diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_materials.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_materials.skip new file mode 100644 index 00000000000..e059193ed81 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_materials.skip @@ -0,0 +1,3 @@ +Test-only: enable the rigid-object-collection shape-material test against the +new ovphysx material binding. The public ``RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION`` +alias it relies on is documented by the sibling material PRs (#6239 / #6240). diff --git a/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py b/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py index 0657b30ad33..61ebf0ef1d1 100644 --- a/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py +++ b/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py @@ -32,6 +32,7 @@ # CI jobs that need OVPhysX coverage install it explicitly. pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") +from isaaclab_ovphysx import tensor_types as TT # noqa: E402 from isaaclab_ovphysx.assets import RigidObjectCollection # noqa: E402 from isaaclab_ovphysx.physics import OvPhysxCfg # noqa: E402 @@ -100,20 +101,6 @@ def _ovphysx_sim_context(device: str, **kwargs): 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 an OvPhysxView over fused " - "per-tensor-type bindings 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, @@ -766,13 +753,45 @@ def test_reset_object_collection(num_envs, num_cubes, device): 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"]) def test_set_material_properties(num_envs, num_cubes, device): - """Test getting and setting material properties of rigid object.""" - raise NotImplementedError(_MATERIAL_GAP_REASON) + """Test getting and setting per-shape material properties of a rigid object collection. + + OVPhysX exposes per-collision-shape material as the + ``rigid_body_shape_friction_and_restitution`` tensor binding, addressed through the + fused :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`. The binding is body-major flat, + so data ``[num_envs, num_cubes, 3]`` is mapped to/from the view layout with + :meth:`~isaaclab_ovphysx.assets.RigidObjectCollection.reshape_data_to_view_3d` and its + inverse. (The PhysX backend uses ``root_view.get/set_material_properties``.) + """ + with _ovphysx_sim_context(device=device, add_ground_plane=True, auto_add_lighting=True) as sim: + object_collection, _ = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) + + # Play sim + sim.reset() + + # Random material per object: (static_friction, dynamic_friction, restitution), on the sim device. + static_friction = torch.empty(num_envs, num_cubes, 1, device=device).uniform_(0.4, 0.8) + dynamic_friction = torch.empty(num_envs, num_cubes, 1, device=device).uniform_(0.4, 0.8) + restitution = torch.empty(num_envs, num_cubes, 1, device=device).uniform_(0.0, 0.2) + materials = torch.cat([static_friction, dynamic_friction, restitution], dim=-1) # [num_envs, num_cubes, 3] + + # Map data -> body-major view layout, write through the view. + view_materials = object_collection.reshape_data_to_view_3d(wp.from_torch(materials, dtype=wp.float32), 3) + object_collection.root_view.set_attribute(TT.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION, view_materials) + + # Simulate physics + sim.step() + object_collection.update(sim.cfg.dt) + + # Read back and map view -> data layout (inverse of reshape_data_to_view_3d), then verify. + view_check = wp.to_torch( + object_collection.root_view.get_attribute(TT.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION) + ) + materials_check = view_check.reshape(num_cubes, num_envs, 3).transpose(0, 1).contiguous() + torch.testing.assert_close(materials_check, materials) @pytest.mark.parametrize("num_envs", [1, 3]) From a22f83e08ba164a9787e68a88d5e580b4324bc28 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Wed, 8 Jul 2026 11:57:20 +0200 Subject: [PATCH 110/139] Enable OVPhysX rigid-object shape-material tests via OvPhysxView (#6240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked on #6226** (OVPhysX `RigidObject` → `OvPhysxView`), which is stacked on #6224 (`OvPhysxView`). Review/merge those first — the diff here collapses to a single commit once they land. Sibling of #6239 (the articulation equivalent). ## What The ovphysx wheel now exposes per-collision-shape rigid-body material as the `RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION` tensor type (shape `[N, S, 3]` = static friction, dynamic friction, restitution). This closes the gap that kept **five** rigid-object material tests `xfail`ed — they were empty `NotImplementedError` stubs written against the PhysX-only `root_view.get/set_material_properties()` / `max_shapes` view API. - Add the `tensor_types` alias `RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION`, guarded with `try/except AttributeError` so the module stays import-safe against older wheels. - Implement, mirroring the PhysX backend but reading/writing the material through `OvPhysxView.get_attribute` / `set_attribute`: - `test_rigid_body_set_material_properties` and `test_set_material_properties_via_view` — set/get round-trip. - `test_rigid_body_no_friction`, `test_rigid_body_with_static_friction`, `test_rigid_body_with_restitution` — physical-behavior tests. - Ground planes use the solver-common `RigidBodyMaterialBaseCfg`; masses come from `data.body_mass`. The no-friction test settles the cube and uses zero restitution so the resting contact is clean before asserting the sliding velocity is preserved. ## Verification OVPhysX runs the PhysX solver, so the physical tests use the PhysX reference tolerances. All five pass on both `cpu` and `cuda:0` for `num_cubes` 1 and 2. Full `test_rigid_object.py` is green (53 passed on cpu, no regressions). --- ...at-ovphysx_rigidobject_materials.minor.rst | 8 + .../test/assets/test_rigid_object.py | 232 ++++++++++++++++-- 2 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject_materials.minor.rst diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject_materials.minor.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject_materials.minor.rst new file mode 100644 index 00000000000..44b08e48b72 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobject_materials.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added :data:`~isaaclab_ovphysx.tensor_types.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION` alias for + the per-collision-shape rigid-body material tensor type (static friction, dynamic friction, + restitution) exposed by the ovphysx wheel. Read and write it through + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`, e.g. + ``root_view.get_attribute(tensor_types.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION)``. diff --git a/source/isaaclab_ovphysx/test/assets/test_rigid_object.py b/source/isaaclab_ovphysx/test/assets/test_rigid_object.py index 165f89ee191..229f235e902 100644 --- a/source/isaaclab_ovphysx/test/assets/test_rigid_object.py +++ b/source/isaaclab_ovphysx/test/assets/test_rigid_object.py @@ -36,12 +36,14 @@ # CI jobs that need OVPhysX coverage install it explicitly. pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") +from isaaclab_ovphysx import tensor_types as TT # noqa: E402 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.sim.spawners import materials # 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, @@ -163,16 +165,30 @@ def generate_cubes_scene( # --------------------------------------------------------------------------- -# Material-property gap (xfail reason shared by 5 tests below) +# Per-shape material helpers (friction / restitution) +# +# OVPhysX exposes per-collision-shape material as the +# ``rigid_body_shape_friction_and_restitution`` tensor binding (shape ``[N, S, 3]`` = +# static friction, dynamic friction, restitution), addressed through the OvPhysxView. +# (The PhysX backend instead uses ``root_view.get/set_material_properties`` / ``max_shapes``.) # --------------------------------------------------------------------------- -_MATERIAL_GAP_REASON = ( - "Requires RIGID_BODY_MATERIAL TensorType (or a view-helper) on the ovphysx " - "wheel side. RigidObject.root_view is an OvPhysxView over the per-tensor-type " - "bindings 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 _num_shapes(cube_object) -> int: + """Per-body collision-shape count, from the material binding's ``[N, S, 3]`` shape.""" + return cube_object.root_view.binding_for(TT.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION).shape[1] + + +def _write_shape_material(cube_object, materials_nS3: torch.Tensor) -> None: + """Write a full ``[N, S, 3]`` (static friction, dynamic friction, restitution) tensor via the view.""" + cube_object.root_view.set_attribute( + TT.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION, wp.from_torch(materials_nS3.contiguous(), dtype=wp.float32) + ) + + +def _read_shape_material(cube_object) -> torch.Tensor: + """Read the per-shape material as a torch tensor ``[N, S, 3]``.""" + return wp.to_torch(cube_object.root_view.get_attribute(TT.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION)) @pytest.mark.parametrize("num_cubes", [1, 2]) @@ -626,33 +642,104 @@ def test_reset_rigid_object(num_cubes, device): 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"]) def test_rigid_body_set_material_properties(num_cubes, device): - """Test getting and setting material properties of rigid object.""" - raise NotImplementedError(_MATERIAL_GAP_REASON) + """Test getting and setting per-shape material properties of a rigid object.""" + with _ovphysx_sim_context(device=device, add_ground_plane=True, auto_add_lighting=True) as sim: + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) + + # Play sim + sim.reset() + + # Random material per shape: (static_friction, dynamic_friction, restitution), on the sim device. + num_shapes = _num_shapes(cube_object) + static_friction = torch.empty(num_cubes, num_shapes, 1, device=device).uniform_(0.4, 0.8) + dynamic_friction = torch.empty(num_cubes, num_shapes, 1, device=device).uniform_(0.4, 0.8) + restitution = torch.empty(num_cubes, num_shapes, 1, device=device).uniform_(0.0, 0.2) + materials = torch.cat([static_friction, dynamic_friction, restitution], dim=-1) + + # Add friction/restitution to the cubes through the view. + _write_shape_material(cube_object, materials) + + # Simulate physics + sim.step() + cube_object.update(sim.cfg.dt) + + # Read back and verify the round-trip. + torch.testing.assert_close(_read_shape_material(cube_object), materials) -@pytest.mark.xfail(reason=_MATERIAL_GAP_REASON, strict=False) @pytest.mark.parametrize("num_cubes", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) 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) + """Test setting per-shape material via the OvPhysxView binding API.""" + with _ovphysx_sim_context(device=device, add_ground_plane=True, auto_add_lighting=True) as sim: + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) + + # Play sim + sim.reset() + + # Generate random material properties: (static_friction, dynamic_friction, restitution). + num_shapes = _num_shapes(cube_object) + materials = torch.empty(num_cubes, num_shapes, 3, device=device).uniform_(0.0, 1.0) + materials[..., 1] = torch.min(materials[..., 0], materials[..., 1]) # dynamic <= static + + # Set material properties through the view, simulate, then read back. + _write_shape_material(cube_object, materials) + sim.step() + cube_object.update(sim.cfg.dt) + + torch.testing.assert_close(_read_shape_material(cube_object), materials) -@pytest.mark.xfail(reason=_MATERIAL_GAP_REASON, strict=False) @pytest.mark.parametrize("num_cubes", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) 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) + """Test that a rigid object with no friction will maintain its velocity when sliding across a plane.""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, height=0.0, device=device) + + # Create a ground plane with no friction + cfg = sim_utils.GroundPlaneCfg( + physics_material=materials.RigidBodyMaterialBaseCfg( + static_friction=0.0, dynamic_friction=0.0, restitution=0.0 + ) + ) + cfg.func("/World/GroundPlane", cfg) + + # Play sim + sim.reset() + + # Set the cubes' friction (and restitution) to zero. This test isolates friction, so a zero + # restitution keeps the resting contact clean instead of letting the cube bounce vertically. + num_shapes = _num_shapes(cube_object) + cube_materials = torch.zeros(num_cubes, num_shapes, 3, device=device) + _write_shape_material(cube_object, cube_materials) + + # Let the cube settle onto the plane so the initial ground-penetration transient (a small + # vertical velocity) dies out before we measure that the horizontal sliding velocity holds. + for _ in range(30): + sim.step() + cube_object.update(sim.cfg.dt) + + # Initial velocity in X to get the block moving. + initial_velocity = torch.zeros((num_cubes, 6), device=device) + initial_velocity[:, 0] = 0.1 + cube_object.write_root_velocity_to_sim_index(root_velocity=initial_velocity) + + # Non-deterministic on GPU, so use a looser tolerance there. + tolerance = 1e-2 if device == "cuda:0" else 1e-5 + for _ in range(5): + sim.step() + cube_object.update(sim.cfg.dt) + torch.testing.assert_close( + cube_object.data.root_lin_vel_w.torch, initial_velocity[:, :3], rtol=1e-5, atol=tolerance + ) -@pytest.mark.xfail(reason=_MATERIAL_GAP_REASON, strict=False) @pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("device", ["cuda", "cpu"]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_rigid_body_with_static_friction(num_cubes, device): """Test that static friction applied to rigid object works as expected. @@ -661,10 +748,60 @@ def test_rigid_body_with_static_friction(num_cubes, device): 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) + with _ovphysx_sim_context(device=device, dt=0.01, auto_add_lighting=True) as sim: + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, height=0.03125, device=device) + + # Create ground plane. Dynamic friction is set equal to static friction to work around a PhysX bug. + mu = 0.5 + cfg = sim_utils.GroundPlaneCfg( + physics_material=materials.RigidBodyMaterialBaseCfg(static_friction=mu, dynamic_friction=mu) + ) + cfg.func("/World/GroundPlane", cfg) + + # Play sim + sim.reset() + + # Set the cubes' static (and, per the PhysX bug, dynamic) friction to mu, restitution zero. + num_shapes = _num_shapes(cube_object) + cube_materials = torch.zeros(num_cubes, num_shapes, 3, device=device) + cube_materials[..., 0] = mu + cube_materials[..., 1] = mu + _write_shape_material(cube_object, cube_materials) + + # Let everything settle. + for _ in range(100): + sim.step() + cube_object.update(sim.cfg.dt) + cube_object.write_root_velocity_to_sim_index(root_velocity=torch.zeros((num_cubes, 6), device=device)) + + cube_mass = cube_object.data.body_mass.torch[:, 0] # [N], PhysX reads this via root_view.get_masses() + gravity_magnitude = abs(sim.cfg.gravity[2]) + # below mu: block should not move (applied force <= mu); above mu: block should move. + for force in "below_mu", "above_mu": + cube_object.write_root_velocity_to_sim_index(root_velocity=torch.zeros((num_cubes, 6), device=device)) + + external_wrench_b = torch.zeros((num_cubes, 1, 6), device=device) + factor = 0.99 if force == "below_mu" else 1.01 + external_wrench_b[:, 0, 0] = mu * cube_mass * gravity_magnitude * factor + + cube_object.permanent_wrench_composer.set_forces_and_torques_index( + forces=external_wrench_b[..., :3], + torques=external_wrench_b[..., 3:], + ) + + initial_root_pos = cube_object.data.root_pos_w.torch.clone() + for _ in range(200): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + if force == "below_mu": + torch.testing.assert_close( + cube_object.data.root_pos_w.torch, initial_root_pos, rtol=2e-3, atol=2e-3 + ) + if force == "above_mu": + assert (cube_object.data.root_pos_w.torch[..., 0] - initial_root_pos[..., 0] > 0.02).all() -@pytest.mark.xfail(reason=_MATERIAL_GAP_REASON, strict=False) @pytest.mark.parametrize("num_cubes", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_rigid_body_with_restitution(num_cubes, device): @@ -675,7 +812,58 @@ def test_rigid_body_with_restitution(num_cubes, device): 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) + for expected_collision_type in "partially_elastic", "inelastic": + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, height=1.0, device=device) + + restitution_coefficient = 0.0 if expected_collision_type == "inelastic" else 0.5 + + # Create ground plane with the matching restitution. + cfg = sim_utils.GroundPlaneCfg( + physics_material=materials.RigidBodyMaterialBaseCfg(restitution=restitution_coefficient) + ) + cfg.func("/World/GroundPlane", cfg) + + # Play sim + sim.reset() + + # Drop the cubes from a height with an initial downward velocity. + root_pose = torch.zeros(num_cubes, 7, device=device) + root_pose[:, 3] = 1.0 # unit quaternion + for i in range(num_cubes): + root_pose[i, 1] = 1.0 * i + root_pose[:, 2] = 1.0 + root_vel = torch.zeros(num_cubes, 6, device=device) + root_vel[:, 2] = -1.0 + cube_object.write_root_pose_to_sim_index(root_pose=root_pose) + cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) + + # Frictionless cubes with the matching restitution. + num_shapes = _num_shapes(cube_object) + cube_materials = torch.zeros(num_cubes, num_shapes, 3, device=device) + cube_materials[..., 2] = restitution_coefficient + _write_shape_material(cube_object, cube_materials) + + curr_z_velocity = cube_object.data.root_lin_vel_w.torch[:, 2].clone() + prev_z_velocity = curr_z_velocity + for _ in range(100): + sim.step() + cube_object.update(sim.cfg.dt) + curr_z_velocity = cube_object.data.root_lin_vel_w.torch[:, 2].clone() + + if expected_collision_type == "inelastic": + # The block must not bounce: its z velocity stays <= 0. + assert (curr_z_velocity <= 0.0).all() + + if torch.all(curr_z_velocity <= 0.0): + prev_z_velocity = curr_z_velocity # still falling + else: + break # collision happened (now moving up) + + if expected_collision_type == "partially_elastic": + # The block bounced but lost energy. + assert torch.all(torch.le(abs(curr_z_velocity), abs(prev_z_velocity))) + assert (curr_z_velocity > 0.0).all() @pytest.mark.parametrize("num_cubes", [1, 2]) From 5dbcfb1a615f9eef157e9dc41bea5fcb0139af54 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Wed, 8 Jul 2026 13:22:24 +0200 Subject: [PATCH 111/139] Add unified runtime and startup benchmark scripts (benchmark refactor, Part 2/5) (#6198) # Description **Part 2 of 5** of the benchmark refactor series: unified runtime and startup benchmark entry points. > **Stacked on Part 1 (#6197).** The incremental Part 2 diff is: > https://github.com/AntoineRichard/IsaacLab/compare/antoiner/benchmark-core...antoiner/benchmark-runtime-startup Series: Part 1/5 core (#6197) -> **Part 2/5 runtime + startup (this PR)** -> Part 3/5 training (#6199) -> Part 4/5 play (#6201) -> Part 5/5 cleanup (#6206). This PR is additive. The existing benchmark scripts remain available until Part 5. Adds: - `scripts/benchmarks/runtime.py`: steps an environment with random actions and emits a `RuntimeBundle`. - `scripts/benchmarks/startup.py`: profiles five startup phases with `cProfile` and emits a `StartupBundle`. - One end-to-end smoke test per entry point. Physics and rendering backends are selected with Hydra preset overrides. Output is selected with `--benchmark_formatter`, which accepts canonical formatter names and comma-separated values such as `schema,omniperf`. Formatter-list parsing is handled by the benchmark core, and preset metadata is derived directly from the Hydra arguments. The startup bundle records the complete benchmark duration. Both entry points close their environments when benchmark processing raises. ## Validation - `237 passed` across `scripts/benchmarks/test` and `source/isaaclab/test/benchmark`. - Both runtime and startup smoke tests pass with Newton/MJWarp. - `./isaaclab.sh -f` passes after commit. ## Type of change - New feature (non-breaking change which adds functionality) ## Checklist - [x] I have read and understood the contribution guidelines. - [x] I have run all pre-commit checks with `./isaaclab.sh -f`. - [x] I have updated the benchmark documentation. - [x] I have added focused end-to-end coverage. - [x] I have added the required changelog fragment. --- .../comparing_simulation_isaacgym.rst | 2 +- docs/source/testing/benchmarks.rst | 73 ++-- pyproject.toml | 1 + scripts/benchmarks/runtime.py | 194 +++++++++++ scripts/benchmarks/startup.py | 318 ++++++++++++++++++ scripts/benchmarks/test/test_runtime_smoke.py | 57 ++++ scripts/benchmarks/test/test_startup_smoke.py | 65 ++++ ...toiner-benchmark-runtime-startup.minor.rst | 22 ++ source/isaaclab/isaaclab/cli/__init__.py | 22 ++ .../test/cli/test_benchmark_entrypoint.py | 31 ++ 10 files changed, 752 insertions(+), 33 deletions(-) create mode 100644 scripts/benchmarks/runtime.py create mode 100644 scripts/benchmarks/startup.py create mode 100644 scripts/benchmarks/test/test_runtime_smoke.py create mode 100644 scripts/benchmarks/test/test_startup_smoke.py create mode 100644 source/isaaclab/changelog.d/antoiner-benchmark-runtime-startup.minor.rst create mode 100644 source/isaaclab/test/cli/test_benchmark_entrypoint.py diff --git a/docs/source/migration/comparing_simulation_isaacgym.rst b/docs/source/migration/comparing_simulation_isaacgym.rst index e70292f99aa..dff6c484635 100644 --- a/docs/source/migration/comparing_simulation_isaacgym.rst +++ b/docs/source/migration/comparing_simulation_isaacgym.rst @@ -40,7 +40,7 @@ arguments. It is important that the ``omniPvdOvdRecordingDirectory`` variable is .. code-block:: bash - ./isaaclab.sh -p scripts/benchmarks/benchmark_non_rl.py --task \ + uv run isaaclab benchmark runtime --task \ --kit_args="--/persistent/physics/omniPvdOvdRecordingDirectory=/tmp/myovds/ \ --/physics/omniPvdOutputEnabled=true" diff --git a/docs/source/testing/benchmarks.rst b/docs/source/testing/benchmarks.rst index 4e40faeaef6..1f3941e345c 100644 --- a/docs/source/testing/benchmarks.rst +++ b/docs/source/testing/benchmarks.rst @@ -96,20 +96,28 @@ Basic usage with :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark`: Running Benchmark Scripts ------------------------- -Isaac Lab provides shell scripts for running benchmark suites: +Isaac Lab provides unified ``runtime.py`` and ``startup.py`` entry points under +``scripts/benchmarks/``. They default to ``--benchmark_formatter schema``, which +emits a schema-v1 JSON bundle via :mod:`isaaclab.test.benchmark`. +``--benchmark_formatter`` accepts a comma-separated list (e.g. +``schema,omniperf``) to emit several formats in a single run. Each selected +formatter writes timestamped output; the Osmo formatter writes one +phase-suffixed JSON file per phase. -Non-RL Benchmarks -~~~~~~~~~~~~~~~~~ +The examples below use ``uv run isaaclab benchmark``. From an existing +Isaac Lab environment, run the same workflows directly instead: -Measure environment stepping performance without training: +* Runtime: ``./isaaclab.sh -p scripts/benchmarks/runtime.py `` +* Startup: ``./isaaclab.sh -p scripts/benchmarks/startup.py `` -.. code-block:: bash +Non-RL / Runtime Benchmarks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - # Run all non-RL benchmarks - ./scripts/benchmarks/run_non_rl_benchmarks.sh ./output_dir +Measure environment stepping performance without any RL library: - # Run a single benchmark manually - ./isaaclab.sh -p scripts/benchmarks/benchmark_non_rl.py \ +.. code-block:: bash + + uv run isaaclab benchmark runtime \ --task Isaac-Cartpole \ --num_envs 4096 \ --num_frames 100 \ @@ -141,10 +149,7 @@ Measure asset method and property performance using mock interfaces: .. code-block:: bash - # Run PhysX micro-benchmarks - ./scripts/benchmarks/run_physx_benchmarks.sh ./output_dir - - # Run articulation benchmarks manually + # Run articulation benchmarks ./isaaclab.sh -p source/isaaclab_physx/benchmark/assets/benchmark_articulation.py \ --num_iterations 1000 \ --num_instances 4096 @@ -163,23 +168,23 @@ understanding where time is spent during initialization. .. code-block:: bash # Basic usage — reports top 30 functions per phase - ./isaaclab.sh -p scripts/benchmarks/benchmark_startup.py \ + uv run isaaclab benchmark startup \ --task Isaac-Ant \ --num_envs 4096 \ --benchmark_formatter summary The script profiles five phases independently: -- **app_launch**: ``launch_simulation()`` context entry (Kit/USD/PhysX init) +- **app_launch**: ``launch_simulation()`` context entry (simulation runtime initialization) - **python_imports**: importing gymnasium, torch, isaaclab_tasks, etc. - **task_config**: ``resolve_task_config()`` (Hydra config resolution) - **env_creation**: ``gym.make()`` + ``env.reset()`` (scene creation, sim start) - **first_step**: a single ``env.step()`` call -Each phase records a wall-clock time plus per-function own-time and cumulative -time as ``SingleMeasurement`` entries. Only IsaacLab functions and first-level -calls into external libraries are included (deep internals of torch, USD, etc. -are filtered out). +Schema output records each phase wall-clock time and per-function own-time, +cumulative time, and call count. Flat formatters project the same data into +measurements. Only Isaac Lab functions and first-level calls into external +libraries are included (deep internals of torch, USD, etc. are filtered out). **Whitelist mode** — For dashboard time-series comparisons across runs, use a YAML whitelist config to report a fixed set of functions instead of top-N. @@ -201,7 +206,7 @@ Patterns use ``fnmatch`` syntax (``*`` and ``?`` wildcards): .. code-block:: bash - ./isaaclab.sh -p scripts/benchmarks/benchmark_startup.py \ + uv run isaaclab benchmark startup \ --task Isaac-Ant \ --num_envs 4096 \ --benchmark_formatter omniperf \ @@ -234,8 +239,8 @@ A default whitelist is provided at ``scripts/benchmarks/startup_whitelist.yaml`` - None - Path to YAML whitelist file * - ``--benchmark_formatter`` - - ``omniperf`` - - Output formatter (``json``, ``osmo``, ``omniperf``, ``summary``, or ``schema``) + - ``schema`` + - Output formatter(s), comma-separated (``schema``, ``json``, ``osmo``, ``omniperf``, ``summary``) * - ``--output_path`` - ``.`` - Directory for output files @@ -254,14 +259,14 @@ Common Arguments - Default - Description * - ``--benchmark_formatter`` - - ``json`` - - Output formatter: ``json``, ``osmo``, ``omniperf``, ``summary``, or ``schema`` + - ``schema`` + - Output formatter(s), comma-separated (``schema``, ``json``, ``osmo``, ``omniperf``, ``summary``) * - ``--output_path`` - ``./`` - Directory for output files -Non-RL Benchmark Arguments -~~~~~~~~~~~~~~~~~~~~~~~~~~ +Non-RL / Runtime Benchmark Arguments +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 @@ -274,7 +279,7 @@ Non-RL Benchmark Arguments - required - Environment task name (e.g., ``Isaac-Cartpole``) * - ``--num_envs`` - - ``4096`` + - ``None`` (task config) - Number of parallel environments * - ``--num_frames`` - ``100`` @@ -632,14 +637,16 @@ Step 4: Finalize Integration with CI/CD ---------------------- -The shell scripts in ``scripts/benchmarks/`` are designed for CI/CD integration: +The benchmark entry points under ``scripts/benchmarks/`` are designed for CI/CD integration: .. code-block:: bash # GitHub Actions / GitLab CI example - - name: Run Benchmarks + - name: Run Runtime Benchmark run: | - ./scripts/benchmarks/run_non_rl_benchmarks.sh ./benchmark_results + uv run isaaclab benchmark runtime \ + --task Isaac-Cartpole --num_envs 4096 --num_frames 100 \ + --benchmark_formatter json --output_path ./benchmark_results - name: Upload Results uses: actions/upload-artifact@v3 @@ -651,7 +658,9 @@ For Osmo integration, use the ``osmo`` formatter: .. code-block:: bash - ./scripts/benchmarks/run_non_rl_benchmarks.sh ./results + uv run isaaclab benchmark runtime \ + --task Isaac-Cartpole --num_envs 4096 --num_frames 100 \ + --benchmark_formatter osmo --output_path ./results # Results are in Osmo-compatible JSON format Troubleshooting @@ -697,7 +706,7 @@ Ensure ``_finalize_impl()`` is called before the script exits: Formatter Not Recognized ~~~~~~~~~~~~~~~~~~~~~~~~ -Valid formatter types are: ``json``, ``osmo``, ``omniperf``, ``summary``, or ``schema`` +Valid formatter types are: ``schema``, ``json``, ``osmo``, ``omniperf``, or ``summary`` .. code-block:: bash diff --git a/pyproject.toml b/pyproject.toml index 92cca8dfdf0..5a26ec3228a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "torchaudio>=2.11", "onnx>=1.18.0", "prettytable>=3.3.0", + "psutil", "protobuf>=4.25.8,!=5.26.0", "hidapi>=0.14.0", "gymnasium>=1.2.0", diff --git a/scripts/benchmarks/runtime.py b/scripts/benchmarks/runtime.py new file mode 100644 index 00000000000..e2287509765 --- /dev/null +++ b/scripts/benchmarks/runtime.py @@ -0,0 +1,194 @@ +# 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 + +r"""Benchmark environment runtime (random actions, no policy). + +Standalone script that steps an Isaac Lab environment with random actions. The +schema formatter emits a :class:`~isaaclab.test.benchmark.RuntimeBundle`; +other selected formatters receive equivalent measurement phases. +Supports all physics backends (PhysX, Newton/MJWarp, Newton/Kamino, OVPhysX) +via Hydra preset tokens — no ``--rl_library`` dispatch needed. + +Usage example:: + + uv run isaaclab benchmark runtime \\ + --task Isaac-Cartpole-Direct \\ + --num_envs 16 --num_frames 100 \\ + presets=newton_mjwarp --headless +""" + +from __future__ import annotations + +import argparse +import sys + + +def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: + """Parse benchmark arguments and retain Hydra overrides in ``sys.argv``. + + Args: + argv: Command-line arguments excluding the script path. + + Returns: + Parsed arguments and the remaining Hydra overrides. + """ + from isaaclab.app import add_launcher_args + + from isaaclab_tasks.utils import setup_preset_cli + + parser = argparse.ArgumentParser(description="Benchmark environment runtime (random actions, no policy).") + parser.add_argument("--task", type=str, required=True, help="Gym task id to benchmark.") + parser.add_argument("--num_envs", type=int, default=None, help="Number of parallel environments.") + parser.add_argument("--num_frames", type=int, default=100, help="Number of environment steps to benchmark.") + parser.add_argument("--seed", type=int, default=None, help="Environment seed.") + parser.add_argument("--output_path", type=str, default=".", help="Directory to write the output JSON.") + parser.add_argument( + "--benchmark_formatter", + type=str, + default="schema", + help=( + "Output format(s): comma-separated list of 'schema' (default, the typed benchmark bundle)," + " 'omniperf', 'osmo', 'json', 'summary'." + " Example: 'schema,omniperf'." + ), + ) + add_launcher_args(parser) + + args, remaining = setup_preset_cli(parser, argv) + sys.argv = [sys.argv[0]] + remaining + return args, remaining + + +def run(argv: list[str]) -> None: + """Run the runtime benchmark and write the selected formatter outputs. + + Args: + argv: Command-line arguments excluding the script path. + """ + import time + + imports_t0 = time.perf_counter_ns() + import contextlib + + import gymnasium as gym + + from isaaclab.app import launch_simulation + from isaaclab.test.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor, builders, capture, stepping + from isaaclab.test.benchmark.schema import StartupTime + + # Importing the task packages registers their gym environments so the + # requested ``--task`` can be resolved. + import isaaclab_tasks # noqa: F401 + from isaaclab_tasks.utils import resolve_task_config + + # PLACEHOLDER: Extension template (do not remove this comment) + with contextlib.suppress(ImportError): + import isaaclab_tasks_experimental # noqa: F401 + + args, remaining = _parse_args(argv) + imports_t1 = time.perf_counter_ns() + + task_config_t0 = time.perf_counter_ns() + env_cfg, _ = resolve_task_config(args.task, None) + task_config_t1 = time.perf_counter_ns() + + start_utc = capture.now_utc_iso() + app_t0 = time.perf_counter_ns() + + with launch_simulation(env_cfg, args): + app_t1 = time.perf_counter_ns() + + if args.num_envs is not None: + env_cfg.scene.num_envs = args.num_envs + if args.device is not None: + env_cfg.sim.device = args.device + if args.seed is not None: + env_cfg.seed = args.seed + + formatter_types = [value.strip() for value in args.benchmark_formatter.split(",") if value.strip()] + formatter_types = formatter_types or ["omniperf"] + cfg = capture.run_config_from_presets(remaining, env_cfg=env_cfg) + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_runtime", + formatter_type=args.benchmark_formatter, + output_path=args.output_path, + use_recorders=True, + frametime_recorders=any(t in ("summary", "omniperf") for t in formatter_types), + output_prefix=f"benchmark_runtime_{args.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args.task}, + {"name": "num_envs", "data": args.num_envs}, + {"name": "num_frames", "data": args.num_frames}, + {"name": "presets", "data": ",".join(cfg.presets)}, + ] + }, + ) + + env_t0 = time.perf_counter_ns() + with contextlib.closing(gym.make(args.task, cfg=env_cfg)) as env: + env_t1 = time.perf_counter_ns() + + num_envs = env.unwrapped.num_envs + + with BenchmarkMonitor(benchmark, interval=1.0): + step_times_s = stepping.run_runtime_loop(env, args.num_frames) + + benchmark.update_manual_recorders() + + startup = StartupTime( + app_launch=(app_t1 - app_t0) / 1e9, + env_creation=(env_t1 - env_t0) / 1e9, + first_step=(step_times_s[0] if step_times_s else 0.0), + python_imports=(imports_t1 - imports_t0) / 1e9, + task_config=(task_config_t1 - task_config_t0) / 1e9, + ) + + fps = [num_envs / t for t in step_times_s if t > 0] + runtime = builders.build_runtime( + startup_time_s=startup, + iteration_times_s=step_times_s, + collection_fps=fps, + total_fps=fps, + steps_per_iteration=num_envs, + ) + + versions = capture.capture_versions(benchmark) + hardware = capture.capture_hardware(benchmark) + resources = capture.capture_resources(benchmark) + + end_utc = capture.now_utc_iso() + stamp = end_utc.translate(str.maketrans("", "", ":-"))[:15] + + seed = args.seed if args.seed is not None else 0 + run_id = capture.synth_run_id(None, cfg.physics_backend, args.task, seed, stamp) + + run = builders.build_run_identity( + run_id=run_id, + framework=None, + config=cfg, + task=args.task, + seed=seed, + start_utc=start_utc, + end_utc=end_utc, + num_envs=num_envs, + ) + + bundle = builders.build_runtime_bundle( + run=run, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + ) + + benchmark.attach_bundle(bundle) + + benchmark._finalize_impl() + + +if __name__ == "__main__": + run(sys.argv[1:]) diff --git a/scripts/benchmarks/startup.py b/scripts/benchmarks/startup.py new file mode 100644 index 00000000000..82c88f9c7ec --- /dev/null +++ b/scripts/benchmarks/startup.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 + +r"""Profile Isaac Lab startup phases. + +Each phase runs in an independent ``cProfile`` session. The schema formatter emits +a :class:`~isaaclab.test.benchmark.StartupBundle`; other selected +formatters receive equivalent measurement phases. + +Profiled phases +--------------- +* **app_launch**: :func:`~isaaclab.app.launch_simulation` context entry + (simulation runtime initialization). +* **python_imports**: launcher, task registration, and runtime-library imports. +* **task_config**: :func:`~isaaclab_tasks.utils.resolve_task_config`. +* **env_creation**: :func:`gym.make` + ``env.reset()``. +* **first_step**: first ``env.step()`` call. + +Usage example:: + + uv run isaaclab benchmark startup \\ + --task Isaac-Cartpole-Direct \\ + --num_envs 16 \\ + presets=newton_mjwarp --headless +""" + +from __future__ import annotations + +import argparse +import cProfile +import os +import sys +import time +from datetime import datetime, timezone + +_START_UTC = datetime.now(timezone.utc).isoformat() +_VALID_PHASES = {"app_launch", "python_imports", "task_config", "env_creation", "first_step"} + + +def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: + """Parse benchmark arguments and retain Hydra overrides in ``sys.argv``. + + Args: + argv: Command-line arguments excluding the script path. + + Returns: + Parsed arguments and the remaining Hydra overrides. + """ + from isaaclab.app import add_launcher_args + + from isaaclab_tasks.utils import setup_preset_cli + + parser = argparse.ArgumentParser(description="Profile Isaac Lab startup phases.") + parser.add_argument("--task", type=str, required=True, help="Gym task id to profile.") + parser.add_argument("--num_envs", type=int, default=None, help="Number of parallel environments.") + parser.add_argument("--seed", type=int, default=None, help="Environment seed.") + parser.add_argument( + "--top_n", + type=int, + default=None, + help="Number of top cProfile functions per phase (default: 5 with whitelist, 30 otherwise).", + ) + parser.add_argument( + "--benchmark_formatter", + type=str, + default="schema", + help=( + "Output format(s): comma-separated list of 'schema' (default, the typed benchmark bundle)," + " 'omniperf', 'osmo', 'json', 'summary'." + " Example: 'schema,omniperf'." + ), + ) + parser.add_argument("--output_path", type=str, default=".", help="Directory to write the output JSON.") + parser.add_argument( + "--whitelist_config", + type=str, + default=None, + help="Path to YAML file with per-phase fnmatch patterns. Overrides --top_n for listed phases.", + ) + add_launcher_args(parser) + + args, remaining = setup_preset_cli(parser, argv) + sys.argv = [sys.argv[0]] + remaining + return args, remaining + + +def _load_whitelist(path: str | None) -> dict[str, list[str]]: + """Load and validate a startup profiling whitelist. + + Args: + path: Path to the whitelist YAML file, or ``None``. + + Returns: + Validated patterns keyed by startup phase. + """ + if path is None: + return {} + + import yaml + + try: + with open(path) as whitelist_file: + raw = yaml.safe_load(whitelist_file) + except OSError as exc: + print(f"[ERROR] Cannot read whitelist config '{path}': {exc}") + sys.exit(1) + except yaml.YAMLError as exc: + print(f"[ERROR] Invalid YAML in whitelist config '{path}': {exc}") + sys.exit(1) + + if raw is None: + return {} + if not isinstance(raw, dict): + print( + f"[ERROR] Whitelist config must be a YAML mapping (got {type(raw).__name__})." + " Expected format: phase_name: [pattern, ...]" + ) + sys.exit(1) + + unknown = set(raw) - _VALID_PHASES + if unknown: + print( + f"[WARNING] Whitelist config contains unknown phase(s): {unknown}. " + f"Valid phases: {_VALID_PHASES}. Check for typos." + ) + for phase_name, patterns in raw.items(): + if not isinstance(patterns, list) or not all(isinstance(pattern, str) for pattern in patterns): + print( + f"[ERROR] Whitelist phase '{phase_name}' must be a list of strings, " + f"got {type(patterns).__name__}. Check YAML formatting (use '- pattern' syntax)." + ) + sys.exit(1) + return raw + + +def _isaaclab_source_prefixes() -> list[str]: + """Return package paths included in function-level startup profiles.""" + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) + source_dir = os.path.join(repo_root, "source") + if not os.path.isdir(source_dir): + print( + f"[WARNING] IsaacLab source directory not found at '{source_dir}'. Function-level profiling will be empty." + ) + return [] + return [ + os.path.join(source_dir, directory) + for directory in os.listdir(source_dir) + if os.path.isdir(os.path.join(source_dir, directory)) + ] + + +def run(argv: list[str]) -> None: + """Run the startup benchmark and write the selected formatter outputs. + + Args: + argv: Command-line arguments excluding the script path. + """ + imports_profile = cProfile.Profile() + imports_time_begin = time.perf_counter_ns() + imports_profile.enable() + + args, hydra_args = _parse_args(argv) + + import gymnasium as gym + import torch + + from isaaclab.app import launch_simulation + from isaaclab.test.benchmark import BaseIsaacLabBenchmark, builders, capture, stepping + from isaaclab.test.benchmark.profiling import parse_cprofile_stats + from isaaclab.test.benchmark.schema import CProfileFunction, StartupPhase + + from isaaclab_tasks.utils import resolve_task_config + + imports_profile.disable() + if torch.cuda.is_available() and torch.cuda.is_initialized(): + torch.cuda.synchronize() + imports_time_end = time.perf_counter_ns() + + task_config_profile = cProfile.Profile() + task_config_time_begin = time.perf_counter_ns() + task_config_profile.enable() + try: + env_cfg, _ = resolve_task_config(args.task, None) + finally: + task_config_profile.disable() + task_config_time_end = time.perf_counter_ns() + + source_prefixes = _isaaclab_source_prefixes() + whitelist = _load_whitelist(args.whitelist_config) + if args.top_n is None: + args.top_n = 5 if whitelist else 30 + + app_launch_profile = cProfile.Profile() + app_launch_time_begin = time.perf_counter_ns() + app_launch_profile.enable() + + with launch_simulation(env_cfg, args): + app_launch_profile.disable() + if torch.cuda.is_available() and torch.cuda.is_initialized(): + torch.cuda.synchronize() + app_launch_time_end = time.perf_counter_ns() + + if args.num_envs is not None: + env_cfg.scene.num_envs = args.num_envs + if args.device is not None: + env_cfg.sim.device = args.device + if args.seed is not None: + env_cfg.seed = args.seed + + env = None + env_creation_profile = cProfile.Profile() + env_creation_time_begin = time.perf_counter_ns() + try: + env_creation_profile.enable() + try: + env = gym.make(args.task, cfg=env_cfg) + env.reset() + finally: + env_creation_profile.disable() + + if torch.cuda.is_available() and torch.cuda.is_initialized(): + torch.cuda.synchronize() + env_creation_time_end = time.perf_counter_ns() + + actions = stepping.sample_random_actions(env) + + first_step_profile = cProfile.Profile() + first_step_time_begin = time.perf_counter_ns() + first_step_profile.enable() + try: + env.step(actions) + finally: + first_step_profile.disable() + + if torch.cuda.is_available() and torch.cuda.is_initialized(): + torch.cuda.synchronize() + first_step_time_end = time.perf_counter_ns() + end_utc = capture.now_utc_iso() + + phase_profiles: dict[str, tuple[cProfile.Profile, float]] = { + "app_launch": (app_launch_profile, (app_launch_time_end - app_launch_time_begin) / 1e6), + "python_imports": (imports_profile, (imports_time_end - imports_time_begin) / 1e6), + "task_config": (task_config_profile, (task_config_time_end - task_config_time_begin) / 1e6), + "env_creation": (env_creation_profile, (env_creation_time_end - env_creation_time_begin) / 1e6), + "first_step": (first_step_profile, (first_step_time_end - first_step_time_begin) / 1e6), + } + + phases: dict[str, StartupPhase] = {} + for phase_name, (profile, wall_ms) in phase_profiles.items(): + functions = parse_cprofile_stats( + profile, source_prefixes, top_n=args.top_n, whitelist=whitelist.get(phase_name) + ) + phases[phase_name] = StartupPhase( + total_time_s=wall_ms / 1000.0, + top_functions=[ + CProfileFunction( + name=label, + own_time_s=own_ms / 1000.0, + cum_time_s=cumulative_ms / 1000.0, + calls=calls, + ) + for label, own_ms, cumulative_ms, calls in functions + ], + ) + + cfg = capture.run_config_from_presets(hydra_args, env_cfg=env_cfg) + stamp = end_utc.translate(str.maketrans("", "", ":-"))[:15] + seed = args.seed if args.seed is not None else 0 + run_id = capture.synth_run_id(None, cfg.physics_backend, args.task, seed, stamp) + run_identity = builders.build_run_identity( + run_id=run_id, + framework=None, + config=cfg, + task=args.task, + seed=seed, + start_utc=_START_UTC, + end_utc=end_utc, + num_envs=None, + max_iterations=None, + ) + + benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_startup", + formatter_type=args.benchmark_formatter, + output_path=args.output_path, + use_recorders=True, + output_prefix=f"startup_{args.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args.task}, + {"name": "seed", "data": args.seed}, + {"name": "num_envs", "data": args.num_envs}, + {"name": "top_n", "data": args.top_n}, + {"name": "presets", "data": ",".join(cfg.presets)}, + ] + }, + ) + benchmark.update_manual_recorders() + + bundle = builders.build_startup_bundle( + run=run_identity, + versions=capture.capture_versions(benchmark), + hardware=capture.capture_hardware(benchmark), + phases=phases, + top_n=args.top_n, + whitelist=args.whitelist_config, + ) + benchmark.attach_bundle(bundle) + benchmark._finalize_impl() + finally: + if env is not None: + env.close() + + +if __name__ == "__main__": + run(sys.argv[1:]) diff --git a/scripts/benchmarks/test/test_runtime_smoke.py b/scripts/benchmarks/test/test_runtime_smoke.py new file mode 100644 index 00000000000..92fa033a72b --- /dev/null +++ b/scripts/benchmarks/test/test_runtime_smoke.py @@ -0,0 +1,57 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Smoke test for the runtime benchmark entry point.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] + +_TASK = "Isaac-Cartpole-Direct" + + +def test_runtime_writes_all_requested_formats(tmp_path): + """The runtime entry point writes schema and OmniPerf data in one run.""" + sh = ROOT / "isaaclab.sh" + cmd = [ + str(sh), + "-p", + "scripts/benchmarks/runtime.py", + "--task", + _TASK, + "--num_envs", + "16", + "--num_frames", + "20", + "--seed", + "0", + "--device", + "cpu", + "--output_path", + str(tmp_path), + "--benchmark_formatter", + "schema,omniperf", + "presets=newton_mjwarp", + "--headless", + ] + res = subprocess.run(cmd, cwd=str(ROOT), capture_output=True, text=True, timeout=900) + if res.returncode != 0: + pytest.fail(f"runtime.py rc={res.returncode}\nSTDOUT:\n{res.stdout[-2000:]}\nSTDERR:\n{res.stderr[-2000:]}") + + device_lines = [line for line in res.stdout.splitlines() if "Environment device" in line] + assert device_lines and device_lines[-1].endswith(": cpu"), f"unexpected device output: {device_lines}" + + files = sorted(tmp_path.glob("*.json")) + schema_files = [path for path in files if path.name.endswith("_schema.json")] + omniperf_files = [path for path in files if path.name.endswith("_omniperf.json")] + assert len(schema_files) == len(omniperf_files) == 1 + + schema_data = json.loads(schema_files[0].read_text()) + assert schema_data["run"]["config"]["physics_backend"] == "newton_mjwarp" + assert "runtime" in json.loads(omniperf_files[0].read_text()) diff --git a/scripts/benchmarks/test/test_startup_smoke.py b/scripts/benchmarks/test/test_startup_smoke.py new file mode 100644 index 00000000000..c9ceba61346 --- /dev/null +++ b/scripts/benchmarks/test/test_startup_smoke.py @@ -0,0 +1,65 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Smoke test for the startup benchmark entry point.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] + +_TASK = "Isaac-Cartpole-Direct" + +_EXPECTED_PHASES = {"app_launch", "python_imports", "task_config", "env_creation", "first_step"} + + +def test_startup_writes_startup_bundle(tmp_path): + """The startup entry point profiles imports and writes both requested formats.""" + whitelist = tmp_path / "whitelist.yaml" + whitelist.write_text('python_imports:\n - "*isaaclab_tasks*importer:_walk_packages"\n') + + sh = ROOT / "isaaclab.sh" + cmd = [ + str(sh), + "-p", + "scripts/benchmarks/startup.py", + "--task", + _TASK, + "--num_envs", + "16", + "--seed", + "0", + "--top_n", + "30", + "--output_path", + str(tmp_path), + "--benchmark_formatter", + "schema,omniperf", + "--whitelist_config", + str(whitelist), + "presets=newton_mjwarp", + "--headless", + ] + res = subprocess.run(cmd, cwd=str(ROOT), capture_output=True, text=True, timeout=900) + if res.returncode != 0: + pytest.fail(f"startup.py rc={res.returncode}\nSTDOUT:\n{res.stdout[-2000:]}\nSTDERR:\n{res.stderr[-2000:]}") + + files = list(tmp_path.glob("*.json")) + schema_files = [path for path in files if path.name.endswith("_schema.json")] + omniperf_files = [path for path in files if path.name.endswith("_omniperf.json")] + assert len(schema_files) == len(omniperf_files) == 1 + + data = json.loads(schema_files[0].read_text()) + assert set(data["phases"]) == _EXPECTED_PHASES + assert data["config"]["whitelist"] == str(whitelist) + assert data["phases"]["python_imports"]["top_functions"] + + phase_total_s = sum(phase["total_time_s"] for phase in data["phases"].values()) + assert data["run"]["duration_s"] >= phase_total_s + assert data["run"]["duration_s"] - phase_total_s < 0.25 + assert set(json.loads(omniperf_files[0].read_text())) >= _EXPECTED_PHASES diff --git a/source/isaaclab/changelog.d/antoiner-benchmark-runtime-startup.minor.rst b/source/isaaclab/changelog.d/antoiner-benchmark-runtime-startup.minor.rst new file mode 100644 index 00000000000..c0c32444b3f --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-benchmark-runtime-startup.minor.rst @@ -0,0 +1,22 @@ +Added +^^^^^ + +* Added physics-backend-agnostic ``runtime.py`` and ``startup.py`` benchmark + entry points. They emit :class:`~isaaclab.test.benchmark.RuntimeBundle` and + :class:`~isaaclab.test.benchmark.StartupBundle` outputs and select physics and + rendering backends with ``presets=`` Hydra tokens. +* Added the ``uv run isaaclab benchmark`` entry point for runtime and startup benchmarks. + +Fixed +^^^^^ + +* Fixed benchmark recorder imports in uv environments by declaring the + ``psutil`` dependency. +* Fixed the runtime benchmark to honor ``--device`` for Kitless physics + backends. +* Fixed runtime and startup bundle metadata to record resolved task-default + physics and rendering backends. +* Fixed runtime benchmark output to record Python-import and task-configuration + startup timings. +* Fixed the startup benchmark total duration to end at the first synchronized + environment step instead of including profile-report generation. diff --git a/source/isaaclab/isaaclab/cli/__init__.py b/source/isaaclab/isaaclab/cli/__init__.py index 748a140b7e0..706829f403a 100644 --- a/source/isaaclab/isaaclab/cli/__init__.py +++ b/source/isaaclab/isaaclab/cli/__init__.py @@ -53,8 +53,29 @@ def play(args: list[str] | None = None) -> None: run_python_command(ISAACLAB_ROOT / "scripts" / "reinforcement_learning" / "play.py", args, check=True) +def benchmark(args: list[str] | None = None) -> None: + """Run a runtime or startup benchmark. + + Args: + args: Command-line arguments. Uses ``sys.argv`` when omitted. + """ + parser = argparse.ArgumentParser(description="Run an Isaac Lab benchmark.") + parser.add_argument("command", choices=("runtime", "startup"), help="Benchmark workflow to run.") + if args is None: + args = sys.argv[1:] + if not args or args[0] in ("-h", "--help"): + parser.parse_args(args) + parsed_args = parser.parse_args(args[:1]) + run_python_command( + ISAACLAB_ROOT / "scripts" / "benchmarks" / f"{parsed_args.command}.py", args[1:], check=True + ) + + def cli() -> None: """Parse CLI arguments and run the requested command.""" + if len(sys.argv) > 1 and sys.argv[1] == "benchmark": + benchmark(sys.argv[2:]) + return if len(sys.argv) > 1 and sys.argv[1] == "train": train(sys.argv[2:]) return @@ -73,6 +94,7 @@ def cli() -> None: formatter_class=argparse.RawTextHelpFormatter, epilog=( "commands:\n" + " benchmark Run a runtime or startup benchmark\n" " train Run scripts/reinforcement_learning/train.py\n" " train_multigpu Run scripts/reinforcement_learning/train_multigpu.py\n" " play Run scripts/reinforcement_learning/play.py" diff --git a/source/isaaclab/test/cli/test_benchmark_entrypoint.py b/source/isaaclab/test/cli/test_benchmark_entrypoint.py new file mode 100644 index 00000000000..b645cad1d30 --- /dev/null +++ b/source/isaaclab/test/cli/test_benchmark_entrypoint.py @@ -0,0 +1,31 @@ +# 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 unified benchmark console entry point.""" + +import sys +from unittest import mock + +import pytest + +import isaaclab.cli as cli + + +@pytest.mark.parametrize("command", ["runtime", "startup"]) +def test_benchmark_dispatches_to_requested_script(command): + """The ``isaaclab benchmark`` command forwards arguments to the requested script.""" + args = [command, "--help"] + + with ( + mock.patch.object(sys, "argv", ["isaaclab", "benchmark", *args]), + mock.patch("isaaclab.cli.run_python_command") as run_python, + ): + cli.cli() + + run_python.assert_called_once_with( + cli.ISAACLAB_ROOT / "scripts" / "benchmarks" / f"{command}.py", + args[1:], + check=True, + ) From f92249a9767b75366d7edd47e701e543df8c4860 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Wed, 8 Jul 2026 16:53:12 +0200 Subject: [PATCH 112/139] Support OVPhysX in randomize_rigid_body_material MDP event (#6243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked on #6239 (articulation material) and #6240 (rigid-object material)** — this branch merges both so the MDP term has `Articulation.root_view` + `RigidObject.root_view` (the #6225/#6226 migrations) and the material aliases. Review/merge those (and their bases #6224/#6225/#6226) first; the diff here collapses to a single commit once they land. (#6241/collection is **not** needed — this MDP term targets `RigidObject`/`Articulation`, not collections.) ## What `randomize_rigid_body_material` previously **no-op'd on the OVPhysX backend** (the wheel lacked a material binding). The wheel now exposes per-shape material as `*_SHAPE_FRICTION_AND_RESTITUTION`, so this replaces the no-op with a real implementation. OVPhysX runs the PhysX solver, so the new `_RandomizeRigidBodyMaterialOvPhysx`: - mirrors the **PhysX bucket approach** (PhysX's 64000 unique-material limit applies): pre-samples `num_buckets` materials and randomly assigns them to shapes; - writes per-shape `(static_friction, dynamic_friction, restitution)` through the asset's `OvPhysxView` (`set_attribute` on `SHAPE_FRICTION_AND_RESTITUTION` for articulations, `RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION` for rigid objects), read-modify-writing the full `[N, S, 3]` buffer so non-selected envs are preserved. ### Limitation Randomizes **all shapes** only. Per-body selection (`asset_cfg.body_ids`) raises `NotImplementedError` — the ovphysx wheel exposes no per-body shape counts (unlike PhysX's `link_physx_view.max_shapes` / Newton's `num_shapes_per_body`), so body→shape ranges can't be indexed. ## Verification New real-backend test (`test_randomize_rigid_body_material_mdp.py`) drives the implementation against an OVPhysX `RigidObject`: it asserts the written friction/restitution land within the sampled ranges, and that a per-body selection fails loud. Passes on both `cpu` and `cuda:0`. The PhysX and Newton paths are untouched. --- ...toiner-feat-ovphysx-mdp-material.minor.rst | 8 ++ source/isaaclab/isaaclab/envs/mdp/events.py | 106 ++++++++++++--- .../antoiner-feat-ovphysx-mdp-material.skip | 1 + .../test_randomize_rigid_body_material_mdp.py | 121 ++++++++++++++++++ 4 files changed, 218 insertions(+), 18 deletions(-) create mode 100644 source/isaaclab/changelog.d/antoiner-feat-ovphysx-mdp-material.minor.rst create mode 100644 source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-mdp-material.skip create mode 100644 source/isaaclab_ovphysx/test/test_randomize_rigid_body_material_mdp.py diff --git a/source/isaaclab/changelog.d/antoiner-feat-ovphysx-mdp-material.minor.rst b/source/isaaclab/changelog.d/antoiner-feat-ovphysx-mdp-material.minor.rst new file mode 100644 index 00000000000..47a27af6f0e --- /dev/null +++ b/source/isaaclab/changelog.d/antoiner-feat-ovphysx-mdp-material.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added OVPhysX backend support to :class:`~isaaclab.envs.mdp.events.randomize_rigid_body_material`. + OVPhysX runs the PhysX solver, so materials are bucket-sampled (PhysX's 64000-material limit + applies) and written per shape through the asset's + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView`. Randomizes all shapes; per-body selection via + ``asset_cfg.body_ids`` is not supported, as the ovphysx wheel exposes no per-body shape counts. diff --git a/source/isaaclab/isaaclab/envs/mdp/events.py b/source/isaaclab/isaaclab/envs/mdp/events.py index ca8647fb688..b651d244a34 100644 --- a/source/isaaclab/isaaclab/envs/mdp/events.py +++ b/source/isaaclab/isaaclab/envs/mdp/events.py @@ -332,14 +332,94 @@ def __call__( self._newton_manager.add_model_change(self._notify_shape_properties) +class _RandomizeRigidBodyMaterialOvPhysx: + """OVPhysX backend implementation for material randomization. + + OVPhysX runs the PhysX solver, so PhysX's 64000 unique-material limit applies and this + mirrors the PhysX bucket approach: ``num_buckets`` materials are pre-sampled once and + randomly assigned to shapes. Materials are written through the asset's + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` on the per-collision-shape + ``shape_friction_and_restitution`` binding (shape ``[N, S, 3]`` = static friction, + dynamic friction, restitution). + + OVPhysX does not expose per-body shape counts, so only whole-asset (all shapes) + randomization is supported; ``asset_cfg.body_ids`` must select all bodies. + """ + + def __init__( + self, cfg: EventTermCfg, env: ManagerBasedEnv, asset: RigidObject | Articulation, asset_cfg: SceneEntityCfg + ): + import isaaclab_ovphysx.tensor_types as ovphysx_tt # noqa: PLC0415 + + from isaaclab.assets import BaseArticulation # noqa: PLC0415 + + # OVPhysX cannot map body ids to shape ranges (no per-body shape counts), so a + # per-body subset cannot be indexed -- fail loud rather than silently randomize all. + if asset_cfg.body_ids != slice(None): + raise NotImplementedError( + "randomize_rigid_body_material on the OVPhysX backend randomizes all shapes only; " + "per-body selection via 'asset_cfg.body_ids' is not supported because the ovphysx " + "wheel does not expose per-body shape counts. Use the default (all bodies)." + ) + + # sample material buckets once (PhysX-style; the 64000 unique-material limit applies) + static_friction_range = cfg.params.get("static_friction_range", (1.0, 1.0)) + dynamic_friction_range = cfg.params.get("dynamic_friction_range", (1.0, 1.0)) + restitution_range = cfg.params.get("restitution_range", (0.0, 0.0)) + num_buckets = int(cfg.params.get("num_buckets", 1)) + ranges = torch.tensor([static_friction_range, dynamic_friction_range, restitution_range], device="cpu") + self.material_buckets = math_utils.sample_uniform(ranges[:, 0], ranges[:, 1], (num_buckets, 3), device="cpu") + if cfg.params.get("make_consistent", False): + self.material_buckets[:, 1] = torch.min(self.material_buckets[:, 0], self.material_buckets[:, 1]) + + self.asset = asset + self.asset_cfg = asset_cfg + # per-shape material tensor type for this asset family (articulation vs rigid body) + self._material_type = ( + ovphysx_tt.SHAPE_FRICTION_AND_RESTITUTION + if isinstance(asset, BaseArticulation) + else ovphysx_tt.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION + ) + + def __call__( + self, + env: ManagerBasedEnv, + env_ids: torch.Tensor | None, + static_friction_range: tuple[float, float], + dynamic_friction_range: tuple[float, float], + restitution_range: tuple[float, float], + num_buckets: int, + asset_cfg: SceneEntityCfg, + make_consistent: bool = False, + ): + view = self.asset.root_view + # read the current per-shape material [N, S, 3] on the binding's native (sim) device + materials = wp.to_torch(view.get_attribute(self._material_type)) + num_instances, num_shapes = materials.shape[0], materials.shape[1] + + # resolve environment ids on the material buffer's device + if env_ids is None: + env_ids = torch.arange(num_instances, device=materials.device) + else: + env_ids = env_ids.to(materials.device) + + # randomly assign pre-sampled bucket materials to every shape of the selected envs + bucket_ids = torch.randint(0, num_buckets, (len(env_ids), num_shapes), device="cpu") + material_samples = self.material_buckets[bucket_ids].to(materials.device) # [len(env_ids), S, 3] + materials[env_ids] = material_samples + + # write the full buffer back through the view (read-modify-write keeps non-selected envs) + view.set_attribute(self._material_type, wp.from_torch(materials.contiguous(), dtype=wp.float32)) + + class randomize_rigid_body_material(ManagerTermBase): """Randomize the physics materials on all geometries of the asset. This function creates a set of physics materials with random static friction, dynamic friction, and restitution values and assigns them to the geometries of the asset. - Automatically detects the active physics backend (PhysX or Newton) and delegates to - the appropriate backend-specific implementation: + Automatically detects the active physics backend (PhysX, Newton, or OVPhysX) and delegates + to the appropriate backend-specific implementation: - **PhysX**: Uses the 3-tuple material format (static_friction, dynamic_friction, restitution) with bucket-based assignment (limited to 64000 unique materials). Applied via the PhysX @@ -347,6 +427,11 @@ class randomize_rigid_body_material(ManagerTermBase): - **Newton**: Samples friction (mu) and restitution continuously per shape (no bucket limitation). Newton uses a single friction coefficient, so ``dynamic_friction_range`` and ``num_buckets`` are ignored. Applied directly to Newton's view-level bindings. + - **OVPhysX**: Runs the PhysX solver, so the same 3-tuple, bucket-based assignment is used, + written through the :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` on the per-shape + ``shape_friction_and_restitution`` binding. Randomizes all shapes only -- per-body + selection (``asset_cfg.body_ids``) is not supported (the wheel exposes no per-body shape + counts). If the flag ``make_consistent`` is set to ``True``, the dynamic friction is set to be less than or equal to the static friction (PhysX only). This obeys the physics constraint on friction values. @@ -394,22 +479,7 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv): # ``NewtonKaminoManager``, ...) are caught by the substring branch. manager_name = env.sim.physics_manager.__name__.lower() 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() + self._impl = _RandomizeRigidBodyMaterialOvPhysx(cfg, env, self.asset, self.asset_cfg) elif "newton" in manager_name: self._impl = _RandomizeRigidBodyMaterialNewton(cfg, env, self.asset, self.asset_cfg) elif "physx" in manager_name: diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-mdp-material.skip b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-mdp-material.skip new file mode 100644 index 00000000000..4f5689048b3 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx-mdp-material.skip @@ -0,0 +1 @@ +Test-only coverage for the OVPhysX package. diff --git a/source/isaaclab_ovphysx/test/test_randomize_rigid_body_material_mdp.py b/source/isaaclab_ovphysx/test/test_randomize_rigid_body_material_mdp.py new file mode 100644 index 00000000000..f12a03e7c1a --- /dev/null +++ b/source/isaaclab_ovphysx/test/test_randomize_rigid_body_material_mdp.py @@ -0,0 +1,121 @@ +# 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 test for the OVPhysX branch of the ``randomize_rigid_body_material`` MDP term. + +Exercises :class:`isaaclab.envs.mdp.events._RandomizeRigidBodyMaterialOvPhysx` against a real +OVPhysX :class:`~isaaclab_ovphysx.assets.RigidObject`, verifying that it writes per-shape +friction/restitution through the asset's ``OvPhysxView``. The ``cfg`` / ``env`` / ``asset_cfg`` +inputs are stubbed (the OVPhysX implementation only reads ``cfg.params`` and +``asset_cfg.body_ids`` and operates on ``asset.root_view``). + +Kitless; run once per device (``-k cpu`` / ``-k 'cuda:0'``) -- the ovphysx runtime binds the +device mode process-globally (see the asset tests' module docstring). +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +import warp as wp + +pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") + +from isaaclab_ovphysx import tensor_types as TT # noqa: E402 +from isaaclab_ovphysx.assets import RigidObject # noqa: E402 +from isaaclab_ovphysx.physics import OvPhysxCfg # noqa: E402 + +import isaaclab.sim as sim_utils # noqa: E402 +from isaaclab.assets import RigidObjectCfg # noqa: E402 +from isaaclab.envs.mdp.events import _RandomizeRigidBodyMaterialOvPhysx # noqa: E402 +from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402 +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR # noqa: E402 + +wp.init() + +pytestmark = pytest.mark.device_split + +_LOCKED_DEVICE: list[str | None] = [None] + + +@pytest.fixture(autouse=True) +def _ovphysx_skip_other_device(request): + """Skip parametrized tests on the device the session is not pinned to (process-global lock).""" + callspec = getattr(request.node, "callspec", None) + device = callspec.params.get("device") if callspec is not None else None + if device is None: + 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): + """Build a simulation context that dispatches to the OVPhysX manager.""" + sim_cfg = SimulationCfg(physics=OvPhysxCfg(), device=device, dt=1.0 / 60.0, gravity=(0.0, 0.0, -9.81)) + return build_simulation_context(device=device, sim_cfg=sim_cfg, **kwargs) + + +def _make_cubes(num_cubes: int, device: str) -> RigidObject: + """Spawn ``num_cubes`` rigid-body cubes as a single RigidObject.""" + for i in range(num_cubes): + sim_utils.create_prim(f"/World/Table_{i}", "Xform", translation=(i * 1.0, 0.0, 1.0)) + cfg = RigidObjectCfg( + prim_path="/World/Table_*/Object", + spawn=sim_utils.UsdFileCfg(usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/DexCube/dex_cube_instanceable.usd"), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)), + ) + return RigidObject(cfg=cfg) + + +@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_randomize_material_writes_friction_within_range(num_cubes, device): + """The OVPhysX impl should write per-shape friction/restitution sampled within the given ranges.""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + cube_object = _make_cubes(num_cubes, device) + sim.reset() + + static_range, dynamic_range, restitution_range = (0.4, 0.8), (0.2, 0.6), (0.0, 0.3) + cfg = SimpleNamespace( + params={ + "static_friction_range": static_range, + "dynamic_friction_range": dynamic_range, + "restitution_range": restitution_range, + "num_buckets": 16, + } + ) + asset_cfg = SimpleNamespace(body_ids=slice(None)) + env = SimpleNamespace() # unused by the OVPhysX implementation + + impl = _RandomizeRigidBodyMaterialOvPhysx(cfg, env, cube_object, asset_cfg) + impl(env, None, static_range, dynamic_range, restitution_range, 16, asset_cfg) + + materials = wp.to_torch(cube_object.root_view.get_attribute(TT.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION)) + assert materials.shape[0] == num_cubes and materials.shape[-1] == 3 + eps = 1e-5 + for component, (lo, hi) in enumerate((static_range, dynamic_range, restitution_range)): + values = materials[..., component] + assert (values >= lo - eps).all() and (values <= hi + eps).all() + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_randomize_material_body_subset_unsupported(device): + """A per-body selection must fail loud on OVPhysX (no per-body shape counts).""" + with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: + cube_object = _make_cubes(1, device) + sim.reset() + + cfg = SimpleNamespace(params={"static_friction_range": (0.4, 0.8), "num_buckets": 4}) + asset_cfg = SimpleNamespace(body_ids=[0]) # subset selection + with pytest.raises(NotImplementedError, match="per-body"): + _RandomizeRigidBodyMaterialOvPhysx(cfg, SimpleNamespace(), cube_object, asset_cfg) From 05bdde8c6f4326fc50e4e8d5e258b5be31ba0290 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Wed, 8 Jul 2026 16:53:27 +0200 Subject: [PATCH 113/139] Clear MJWarp solver internals on env reset (#5730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Clears per-world solver-internal buffers on env reset via Newton's solver-reset API — closing the NaN-recovery gap discussed in [newton-physics/newton#1266](https://github.com/newton-physics/newton/issues/1266). Without this, a world that produces a NaN in one solve warm-starts the next iteration from that NaN (MJWarp leaves `mj_checkPos` / `mj_checkVel` / `mj_checkAcc` as TODOs, so nothing in the solver cleans it up). IsaacLab's standard reset rewrites `joint_q` / `joint_qd` / `body_q` but never touches MJWarp-internal `qacc_warmstart`, applied forces, or activations — the world is permanently dead. ## What changed * New `NewtonManager._reset_solver_internals(world_mask)` hook, invoked at **both** boundaries that consume the accumulated reset masks, immediately before the masks are zeroed: the top of `step()` and `forward()`. The `forward()` call site matters because data reads may trigger `forward()` between an env reset and the next `step()`, which would otherwise consume the mask before the solver internals were ever cleared. * The **base implementation is solver-agnostic**: it forwards to `SolverBase.reset(state, world_mask=..., flags=0)` — the upstream API from [newton-physics/newton#2657](https://github.com/newton-physics/newton/pull/2657), available at the Newton commit `develop` already pins. `flags=0` clears only solver-owned buffers and preserves the joint state IsaacLab authored during the reset. `SolverBase.reset` is a documented no-op, so solvers that do not implement it (VBD, XPBD, Featherstone, MPM today) are unaffected — and any solver that adds a `reset()` upstream is covered automatically with no IsaacLab change. * `NewtonMJWarpManager` specializes the hook only to gate the CPU-MuJoCo path (`use_mujoco_cpu=True`): its reset branch owns a single global `MjData` and is not mask-aware, and the hook fires on every boundary with a usually-all-`False` mask. For MJWarp, `SolverMuJoCo.reset(flags=0)` zeroes `qacc_warmstart`, `qfrc_applied`, `xfrc_applied`, `ctrl`, `act` per masked world. * `NewtonKaminoManager` opts out with a documented no-op override — its FK delegate already performs the masked `SolverKamino.reset` with an explicit `ResetConfig` at the same boundaries, so the generic call would be a redundant second reset. ## Type of change * Bug fix (non-breaking change which fixes an issue) ## Screenshots N/A — physics behavior fix, no UI. ## 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 ## Test plan * New regression test `source/isaaclab_newton/test/physics/test_newton_solver_reset.py` (2 envs, CUDA): poisons `qacc_warmstart`, authors a joint write for one env, and verifies both public boundaries (`sim.forward()` and `sim.step()`) clear the flagged world's history while preserving the other world's history and the authored joint state. Verified to fail with the reset disabled. * `source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py` passes (74 tests). * Canonical training probes run to completion with no tracebacks: * `./isaaclab.sh train --rl_library rsl_rl --task Isaac-Cartpole-Direct --num_envs 16 presets=newton_mjwarp --max_iterations 5` * `./isaaclab.sh train --rl_library rsl_rl --task Isaac-Cartpole-Direct --num_envs 16 presets=newton_kamino --max_iterations 5` --- .../antoiner-mjwarp-reset-data.rst | 31 +++++ .../isaaclab_newton/physics/kamino_manager.py | 11 ++ .../isaaclab_newton/physics/mjwarp_manager.py | 35 +++++ .../isaaclab_newton/physics/newton_manager.py | 24 +++- .../test/physics/test_newton_solver_reset.py | 130 ++++++++++++++++++ 5 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab_newton/changelog.d/antoiner-mjwarp-reset-data.rst create mode 100644 source/isaaclab_newton/test/physics/test_newton_solver_reset.py diff --git a/source/isaaclab_newton/changelog.d/antoiner-mjwarp-reset-data.rst b/source/isaaclab_newton/changelog.d/antoiner-mjwarp-reset-data.rst new file mode 100644 index 00000000000..36282f4956d --- /dev/null +++ b/source/isaaclab_newton/changelog.d/antoiner-mjwarp-reset-data.rst @@ -0,0 +1,31 @@ +Fixed +^^^^^ + +* Fixed NaN values in MJWarp solver-internal buffers (``qacc_warmstart``, + ``qfrc_applied``, ``xfrc_applied``, ``ctrl``, ``act``) persisting across + env reset and re-diverging on the next solve. + :class:`~isaaclab_newton.physics.NewtonMJWarpManager` now calls + :meth:`SolverMuJoCo.reset` with the accumulated per-world reset mask + whenever the reset masks are consumed (at the top of + :meth:`~isaaclab_newton.physics.NewtonManager.step` and in + :meth:`~isaaclab_newton.physics.NewtonManager.forward`), so a world that + produces a NaN can recover after :meth:`~isaaclab.envs.ManagerBasedEnv.reset`. + See https://github.com/newton-physics/newton/issues/1266 for the upstream + discussion. + +Changed +^^^^^^^ + +* Added :meth:`~isaaclab_newton.physics.NewtonManager._reset_solver_internals` + hook that clears per-world solver-internal scratch buffers before the + accumulated reset masks are consumed by + :meth:`~isaaclab_newton.physics.NewtonManager.step` or + :meth:`~isaaclab_newton.physics.NewtonManager.forward`. The default + implementation forwards to :meth:`SolverBase.reset` with ``flags=0``, + preserving the authored joint state — a no-op for solvers that do not + implement ``reset()``, and automatic coverage for any solver that does. + :class:`~isaaclab_newton.physics.NewtonMJWarpManager` specializes it to + gate the non-mask-aware CPU-MuJoCo path; + :class:`~isaaclab_newton.physics.NewtonKaminoManager` opts out because its + forward-kinematics delegate already routes through + :meth:`SolverKamino.reset`. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py index 85bc166b2c3..f415a5b6a2d 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py @@ -100,6 +100,17 @@ def _eval_fk_impl(cls, world_reset_mask: wp.array | None, fk_mask: wp.array | No config=SolverKamino.ResetConfig.preserve(), ) + @classmethod + def _reset_solver_internals(cls, world_mask: wp.array | None) -> None: + """Skip the generic solver reset. + + :meth:`_eval_fk_impl` already performs the masked + :meth:`SolverKamino.reset` with an explicit reset configuration. + + Args: + world_mask: Unused; accepted to match the base hook signature. + """ + @classmethod def _build_solver(cls, model: Model, solver_cfg: KaminoSolverCfg) -> None: """Construct :class:`SolverKamino` and populate the base-class slots. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py index f475695f0d9..cf3685c4ed7 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py @@ -11,6 +11,7 @@ import logging import numpy as np +import warp as wp from newton import Contacts, Model from newton.solvers import SolverMuJoCo @@ -79,6 +80,40 @@ def _initialize_contacts(cls) -> None: requested_attributes=cls._model.get_requested_contact_attributes(), ) + @classmethod + def _reset_solver_internals(cls, world_mask: wp.array | None) -> None: + """Clear MuJoCo Warp solver-internal state for flagged worlds. + + Specializes the base hook, whose :meth:`SolverBase.reset` call resolves + to :meth:`SolverMuJoCo.reset` here: with ``flags=0`` it zeroes only the + solver-owned buffers persisting across steps (``qacc_warmstart``, + ``qfrc_applied``, ``xfrc_applied``, ``ctrl``, ``act``) for the flagged + worlds, while the joint state IsaacLab authored during the env reset is + left untouched. Without this, a NaN produced in one solve persists + across :meth:`isaaclab.envs.ManagerBasedEnv.reset` because the next + solver substep warm-starts from the NaN — the world is then permanently + dead. See https://github.com/newton-physics/newton/issues/1266. + + With ``use_mujoco_cpu=True`` the solver owns a single global ``MjData`` + and its reset path is not mask-aware — it clears the buffers for every + world. Since this hook fires on every step/forward boundary (usually + with an all-``False`` mask), the CPU path is gated on at least one + world actually being flagged so warm-starting is not defeated on every + step. + + Args: + world_mask: Per-world bool mask of shape ``(world_count,)``; + ``True`` for worlds that need their MJWarp internals cleared. + ``None`` is treated as a no-op. + """ + if world_mask is None: + return + if cls._solver.use_mujoco_cpu and not world_mask.numpy().any(): + return + # flags=0 skips the joint-state reset to model defaults: IsaacLab owns + # joint_q/joint_qd and has already written the authored reset pose. + cls._solver.reset(cls._state_0, world_mask=world_mask, flags=0) + @classmethod def _log_solver_debug(cls) -> None: """Optionally log MuJoCo solver convergence at the end of step.""" diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 82d412eae05..bcb3e8b61e0 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -211,7 +211,8 @@ class NewtonManager(PhysicsManager): Concrete subclasses (one per solver) implement :meth:`_build_solver` and may extend :meth:`_initialize_contacts`, :meth:`_prepare_builder_for_finalize`, :meth:`_step_solver`, :meth:`_supports_cuda_graph_capture`, - :meth:`_solver_specific_clear`, and :meth:`_log_solver_debug`. + :meth:`_reset_solver_internals`, :meth:`_solver_specific_clear`, and + :meth:`_log_solver_debug`. Subclasses are selected via :attr:`NewtonSolverCfg.class_type`, which :meth:`NewtonCfg.__post_init__` propagates onto :attr:`NewtonCfg.class_type` @@ -409,6 +410,7 @@ def forward(cls) -> None: data layer invokes ``NewtonManager.forward()`` on the base class, where ``cls`` is the base ``NewtonManager``; the bound delegate dispatches to the concrete subclass override. """ + cls._reset_solver_internals(cls._world_reset_mask) cls._eval_fk(cls._world_reset_mask, cls._fk_reset_mask) if cls._fk_reset_mask is not None: cls._fk_reset_mask.zero_() @@ -677,6 +679,8 @@ def step(cls) -> None: if sim is None or not sim.is_playing(): return + cls._reset_solver_internals(cls._world_reset_mask) + # Notify solver of model changes if cls._model_changes: with wp.ScopedDevice(PhysicsManager._device): @@ -1420,6 +1424,24 @@ def _log_solver_debug(cls) -> None: (e.g. constraint violations, contact forces, etc.) after stepping. """ + @classmethod + def _reset_solver_internals(cls, world_mask: wp.array | None) -> None: + """Clear solver-internal state for environments reset since the last boundary. + + The hook runs immediately before reset masks are consumed by :meth:`step` + and :meth:`forward`. The base implementation delegates to + :meth:`SolverBase.reset` with ``flags=0``, preserving the joint state + authored by Isaac Lab while clearing solver-owned buffers. Solvers with + no reset implementation are unaffected. + + Args: + world_mask: Per-world reset mask, or ``None`` when no simulation + state is available. + """ + if world_mask is None: + return + cls._solver.reset(cls._state_0, world_mask=world_mask, flags=0) + # ----- Lifecycle orchestration ---------------------------------------- @classmethod diff --git a/source/isaaclab_newton/test/physics/test_newton_solver_reset.py b/source/isaaclab_newton/test/physics/test_newton_solver_reset.py new file mode 100644 index 00000000000..2546c57cdf3 --- /dev/null +++ b/source/isaaclab_newton/test/physics/test_newton_solver_reset.py @@ -0,0 +1,130 @@ +# 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 solver-internal reset performed when env-reset masks are consumed.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +from unittest.mock import patch + +import pytest +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 +from newton.solvers import SolverMuJoCo + +import isaaclab.sim as sim_utils +from isaaclab.actuators import IdealPDActuatorCfg +from isaaclab.assets import ArticulationCfg +from isaaclab.sim import SimulationCfg, build_simulation_context +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR + + +def _generate_single_joint_articulations(num_articulations: int, device: str) -> Articulation: + """Spawn ``num_articulations`` copies of the simple revolute articulation, one per env prim.""" + for i in range(num_articulations): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 2.5, 0.0, 0.0)) + articulation_cfg = ArticulationCfg( + prim_path="/World/Env_.*/Robot", + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/IsaacSim/SimpleArticulation/revolute_articulation.usd", + joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_force=80.0, max_joint_velocity=5.0), + ), + actuators={ + "joint": IdealPDActuatorCfg( + joint_names_expr=[".*"], + effort_limit=400.0, + velocity_limit=100.0, + stiffness=0.0, + damping=10.0, + ), + }, + ) + return Articulation(articulation_cfg) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_env_reset_clears_selected_mjwarp_solver_internals(device): + """An env reset clears the flagged world's MuJoCo warm-start history and keeps the others. + + Regression test for NaN values persisting across env reset: MJWarp warm-starts each solve + from ``qacc_warmstart``, so solver-internal buffers must be cleared for reset worlds at both + boundaries that consume the accumulated reset masks (``forward()`` and ``step()``), while the + authored joint state and the untouched worlds' history are preserved. + """ + sim_cfg = SimulationCfg( + dt=1 / 120, + physics=NewtonCfg( + solver_cfg=MJWarpSolverCfg( + njmax=20, + nconmax=20, + integrator="implicitfast", + ), + num_substeps=1, + use_cuda_graph=False, + ), + ) + with build_simulation_context(sim_cfg=sim_cfg, device=device) as sim: + sim._app_control_on_stop_handle = None + articulation = _generate_single_joint_articulations(num_articulations=2, device=device) + sim.reset() + + solver = SimulationManager._solver + assert isinstance(solver, SolverMuJoCo) + warm_start = wp.to_torch(solver.mjw_data.qacc_warmstart) + assert warm_start.shape[0] == 2 + assert warm_start.shape[1] > 0 + + env_ids = torch.tensor([0], dtype=torch.int32, device=device) + joint_pos = articulation.data.default_joint_pos.torch[:1].clone() + 0.25 + joint_vel = torch.full_like(articulation.data.default_joint_vel.torch[:1], 0.5) + articulation.write_joint_state_to_sim_index( + position=joint_pos, + velocity=joint_vel, + env_ids=env_ids, + ) + + state = SimulationManager._state_0 + joint_q_before = wp.to_torch(state.joint_q).clone() + joint_qd_before = wp.to_torch(state.joint_qd).clone() + warm_start[0].fill_(13.0) + warm_start[1].fill_(17.0) + wp.synchronize_device(device) + + # The public, non-integrating forward boundary consumes the authored-write mask. + sim.forward() + wp.synchronize_device(device) + + torch.testing.assert_close(wp.to_torch(state.joint_q), joint_q_before) + torch.testing.assert_close(wp.to_torch(state.joint_qd), joint_qd_before) + assert torch.count_nonzero(warm_start[0]).item() == 0 + torch.testing.assert_close(warm_start[1], torch.full_like(warm_start[1], 17.0)) + + # The physics-step boundary also resets when no forward boundary consumed the mask first. + articulation.write_joint_state_to_sim_index( + position=joint_pos, + velocity=joint_vel, + env_ids=env_ids, + ) + warm_start[0].fill_(23.0) + warm_start[1].fill_(29.0) + wp.synchronize_device(device) + + with ( + patch.object(SimulationManager, "_simulate_full", classmethod(lambda cls: None)), + patch.object(SimulationManager, "_simulate_physics_only", classmethod(lambda cls: None)), + ): + sim.step(render=False) + wp.synchronize_device(device) + + torch.testing.assert_close(wp.to_torch(state.joint_q), joint_q_before) + torch.testing.assert_close(wp.to_torch(state.joint_qd), joint_qd_before) + assert torch.count_nonzero(warm_start[0]).item() == 0 + torch.testing.assert_close(warm_start[1], torch.full_like(warm_start[1], 29.0)) From 86492c9de46ee95d7dd3268ab7ebc74f99f54978 Mon Sep 17 00:00:00 2001 From: hujc Date: Wed, 8 Jul 2026 08:34:13 -0700 Subject: [PATCH 114/139] Fix option-like --kit_args values failing argparse on all entry points (#6363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem ```bash --kit_args "--ext-folder=/path/to/ext" # exit 2: "argument --kit_args: expected one argument" --kit_args=--ext-folder=/path/to/ext # worked --kit_args "--ext-folder=/a --ext-folder=/b" # worked ``` argparse rejects a value token that looks like an option (`-`-prefixed, no space) — and Kit args always start with `--`. Affects every entry point. ## Fix Two normalization points, sharing one fusing helper that rewrites `["--kit_args", "--ext-folder=/path/to/ext"]` into `["--kit_args=--ext-folder=/path/to/ext"]`: 1. `AppLauncher.add_app_launcher_args` fuses the pair in `sys.argv` before any parsing. Covers every script that parses `sys.argv`: demos, tutorials, tools, benchmarks, and the per-library play scripts. 2. `dispatch_library_entrypoint` applies the same fusing to the explicit argv list it forwards to the per-library train/play scripts. Those scripts parse that list (not `sys.argv`), so the `sys.argv` normalization alone never reached the unified `train` command or the multi-GPU ranks (each child rank runs through the dispatcher). Already-working forms are unchanged: a guard test asserts the equals-attached, quoted multi-token, and plain-flag forms are forwarded byte-identical. Tests: 17 in `source/isaaclab/test/cli/test_train_multigpu_command_building.py`. 12 fail with the `AppLauncher` fusing reverted; the dispatcher regression test fails with the dispatcher fusing reverted. Verified end-to-end: cartpole training via `train --rl_library rsl_rl ... --kit_args "--ext-folder=..."` (previously exit 2) now trains to completion, and the same command without `--kit_args` behaves identically to before. ## 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 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 --- scripts/reinforcement_learning/common.py | 5 +- .../jichuanh-fix-kit-args-forwarding.rst | 9 + source/isaaclab/isaaclab/app/app_launcher.py | 43 +++- .../test_train_multigpu_command_building.py | 215 ++++++++++++++++++ 4 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 source/isaaclab/changelog.d/jichuanh-fix-kit-args-forwarding.rst create mode 100644 source/isaaclab/test/cli/test_train_multigpu_command_building.py diff --git a/scripts/reinforcement_learning/common.py b/scripts/reinforcement_learning/common.py index e5591704ba5..a86c23976d4 100644 --- a/scripts/reinforcement_learning/common.py +++ b/scripts/reinforcement_learning/common.py @@ -24,7 +24,7 @@ import torch from PIL import Image -from isaaclab.app import add_launcher_args +from isaaclab.app import AppLauncher, add_launcher_args from isaaclab.envs import DirectMARLEnvCfg, ManagerBasedRLEnvCfg from isaaclab.utils.dict import print_dict from isaaclab.utils.images import make_camera_output_grid, normalize_camera_output_for_display @@ -166,6 +166,9 @@ def dispatch_library_entrypoint( """ if argv is None: argv = sys.argv[1:] + # the per-library scripts parse this explicit list (not sys.argv), so the sys.argv + # fusing in AppLauncher.add_app_launcher_args never reaches it; normalize here too + argv = AppLauncher._fuse_kit_args(argv) parser = argparse.ArgumentParser(add_help=False) parser.add_argument("--rl_library", choices=sorted(entrypoints), required=True) diff --git a/source/isaaclab/changelog.d/jichuanh-fix-kit-args-forwarding.rst b/source/isaaclab/changelog.d/jichuanh-fix-kit-args-forwarding.rst new file mode 100644 index 00000000000..25472149e5d --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-fix-kit-args-forwarding.rst @@ -0,0 +1,9 @@ +Fixed +^^^^^ + +* Fixed passing a single Kit argument in the space-separated form (e.g. + ``--kit_args "--ext-folder=/path/to/ext"``) failing with the argparse error + "argument --kit_args: expected one argument" on every entry point, including the unified + ``train``/``play`` commands and all ranks of the multi-GPU training launcher. + :meth:`~isaaclab.app.AppLauncher.add_app_launcher_args` and the unified RL entry point + dispatcher now normalize such pairs into single ``--kit_args=`` tokens before parsing. diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index d538e3b6802..59f3abf0c96 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -384,6 +384,39 @@ def app(self) -> SimulationApp: Operations. """ + @staticmethod + def _fuse_kit_args(argv: list[str]) -> list[str]: + """Fuse ``["--kit_args", ""]`` pairs into single ``--kit_args=`` tokens. + + Argparse rejects a value token that itself looks like an option (starts with ``-`` and contains + no space) with "expected one argument", and Kit arguments always start with ``--``. Fusing the + pair into the ``=``-attached form before parsing makes the documented space-separated form work + for a single Kit argument. All other forms pass through unchanged. + + Args: + argv: Command-line tokens, excluding the program name. + + Returns: + Tokens with any affected pair replaced by one fused token. + """ + fused: list[str] = [] + index = 0 + while index < len(argv): + token = argv[index] + next_token = argv[index + 1] if index + 1 < len(argv) else None + if ( + token == "--kit_args" + and next_token is not None + and next_token.startswith("-") + and " " not in next_token + ): + fused.append(f"--kit_args={next_token}") + index += 2 + else: + fused.append(token) + index += 1 + return fused + @staticmethod def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: """Utility function to configure AppLauncher arguments with an existing argument parser object. @@ -438,6 +471,8 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: * ``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" + A single Kit argument works in both the space-separated and the ``=``-attached form + (e.g. ``--kit_args "--ext-folder=/path/to/ext1"`` or ``--kit_args=--ext-folder=/path/to/ext1``). Isaac Lab experiences use one renderer GPU by default. Applications that need single-process multi-GPU rendering can override the ``renderer.multiGpu`` settings through this argument. @@ -461,6 +496,10 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: Args: parser: An argument parser instance to be extended with the AppLauncher specific options. """ + # argparse rejects an option-like value token after "--kit_args"; fuse the pair before + # anything parses the command line so the space-separated form works on every entry point + sys.argv[1:] = AppLauncher._fuse_kit_args(sys.argv[1:]) + # If the passed parser has an existing _HelpAction when passed, # we here remove the options which would invoke it, # to be added back after the additional AppLauncher args @@ -586,7 +625,9 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: default="", help=( "Command line arguments for Omniverse Kit as a string separated by a space delimiter." - ' Example usage: --kit_args "--ext-folder=/path/to/ext1 --ext-folder=/path/to/ext2"' + ' Example usage: --kit_args "--ext-folder=/path/to/ext1 --ext-folder=/path/to/ext2".' + ' A single Kit argument works in both forms: --kit_args "--ext-folder=/path/to/ext1"' + " or --kit_args=--ext-folder=/path/to/ext1." ), ) arg_group.add_argument( diff --git a/source/isaaclab/test/cli/test_train_multigpu_command_building.py b/source/isaaclab/test/cli/test_train_multigpu_command_building.py new file mode 100644 index 00000000000..93567d8dec0 --- /dev/null +++ b/source/isaaclab/test/cli/test_train_multigpu_command_building.py @@ -0,0 +1,215 @@ +# 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 passing ``--kit_args`` with an option-like value. + +Kit arguments always start with ``--``, and argparse rejects a value token that +itself looks like an option (starts with ``-`` and contains no space) with +"expected one argument" (exit code 2). This used to break the documented +space-separated form ``--kit_args "--foo=/bar"`` for a single Kit argument on +every entry point, including all ranks of the multi-GPU launcher. + +:meth:`~isaaclab.app.AppLauncher.add_app_launcher_args` now fuses such pairs in +``sys.argv`` into single ``--kit_args=`` tokens before any parsing, and +``dispatch_library_entrypoint`` applies the same fusing to the explicit argv list +it forwards to the per-library scripts (which parse that list, not ``sys.argv``). +The multi-GPU launcher forwards the tokens verbatim; each child rank runs through +the dispatcher and normalizes them at startup. + +These tests exercise the pure normalization and command-building logic and run +without a GPU or Isaac Sim installation. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import shlex +import sys +from pathlib import Path + +import pytest + +from isaaclab.app.app_launcher import AppLauncher + +# The launcher script lives outside the installed packages; load it by path. +# This test lives at source/isaaclab/test/cli/test_train_multigpu_command_building.py. +_REPO_ROOT = Path(__file__).resolve().parents[4] +_TRAIN_MULTIGPU_PATH = _REPO_ROOT / "scripts" / "reinforcement_learning" / "train_multigpu.py" + +_spec = importlib.util.spec_from_file_location("train_multigpu", _TRAIN_MULTIGPU_PATH) +train_multigpu = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(train_multigpu) + +_COMMON_PATH = _REPO_ROOT / "scripts" / "reinforcement_learning" / "common.py" +_common_spec = importlib.util.spec_from_file_location("rl_common", _COMMON_PATH) +rl_common = importlib.util.module_from_spec(_common_spec) +_common_spec.loader.exec_module(rl_common) + + +def _build_command(argv: list[str]) -> list[str]: + """Build the distributed launcher command the same way ``main`` does.""" + args_cli, train_args = train_multigpu._parse_args(argv) + return train_multigpu._build_distributed_command(args_cli, train_args) + + +def _forwarded_train_argv(command: list[str]) -> list[str]: + """Return the argv forwarded to the child training script.""" + return command[command.index(str(train_multigpu.TRAIN_SCRIPT)) + 1 :] + + +def _parse_as_training_script(child_argv: list[str], monkeypatch: pytest.MonkeyPatch) -> str: + """Parse forwarded argv the way the child training script does. + + Replicates the training-script startup: ``sys.argv`` holds the forwarded + tokens, the parser is extended via :meth:`AppLauncher.add_app_launcher_args` + (which installs the ``--kit_args`` normalization), and parsing must succeed. + """ + monkeypatch.setattr(sys, "argv", ["train.py", *child_argv]) + parser = argparse.ArgumentParser() + parser.add_argument("--task", type=str, default=None) + AppLauncher.add_app_launcher_args(parser) + try: + args, _unknown = parser.parse_known_args() + except SystemExit: + pytest.fail(f"training script argparse rejected the forwarded arguments: {child_argv}") + return args.kit_args + + +class TestFuseKitArgs: + """Unit tests for :meth:`AppLauncher._fuse_kit_args`.""" + + def test_space_separated_option_like_value_is_fused(self): + assert AppLauncher._fuse_kit_args(["--kit_args", "--foo=/bar"]) == ["--kit_args=--foo=/bar"] + + def test_equals_attached_value_is_unchanged(self): + argv = ["--kit_args=--foo=/bar"] + assert AppLauncher._fuse_kit_args(argv) == argv + + def test_value_with_space_is_unchanged(self): + # a token containing a space cannot be mistaken for an option by argparse + argv = ["--kit_args", "--foo=/bar --baz=1"] + assert AppLauncher._fuse_kit_args(argv) == argv + + def test_non_option_value_is_unchanged(self): + argv = ["--kit_args", "foo.txt"] + assert AppLauncher._fuse_kit_args(argv) == argv + + def test_trailing_kit_args_is_unchanged(self): + # argparse should still report the missing value normally + argv = ["--task", "Isaac-Cartpole-Direct", "--kit_args"] + assert AppLauncher._fuse_kit_args(argv) == argv + + def test_multiple_occurrences_are_all_fused(self): + argv = ["--kit_args", "--foo=/a", "--task", "X", "--kit_args", "--bar=/b"] + assert AppLauncher._fuse_kit_args(argv) == ["--kit_args=--foo=/a", "--task", "X", "--kit_args=--bar=/b"] + + def test_surrounding_tokens_are_preserved(self): + argv = ["--task", "X", "--kit_args", "--foo=/bar", "--headless"] + assert AppLauncher._fuse_kit_args(argv) == ["--task", "X", "--kit_args=--foo=/bar", "--headless"] + + +class TestAddAppLauncherArgsNormalization: + """Integration tests for the ``sys.argv`` normalization in ``add_app_launcher_args``.""" + + def test_space_separated_single_kit_arg_parses(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["prog", "--task", "X", "--kit_args", "--foo=/bar"]) + parser = argparse.ArgumentParser() + parser.add_argument("--task", type=str, default=None) + AppLauncher.add_app_launcher_args(parser) + args, unknown = parser.parse_known_args() + assert args.kit_args == "--foo=/bar" + assert unknown == [] + + def test_unknown_leftovers_are_preserved(self, monkeypatch): + monkeypatch.setattr(sys, "argv", ["prog", "--task", "X", "--kit_args", "--foo=/bar", "env.param=1"]) + parser = argparse.ArgumentParser() + parser.add_argument("--task", type=str, default=None) + AppLauncher.add_app_launcher_args(parser) + args, unknown = parser.parse_known_args() + assert args.kit_args == "--foo=/bar" + assert unknown == ["env.param=1"] + + +class TestDispatchLibraryEntrypoint: + """Tests for the ``--kit_args`` normalization in ``dispatch_library_entrypoint``. + + The unified ``train.py``/``play.py`` dispatchers hand the per-library scripts an + explicit argv list (not ``sys.argv``), so the normalization must also run on that + list before it is forwarded. + """ + + @staticmethod + def _dispatch(tmp_path: Path, argv: list[str]) -> list[str]: + """Dispatch to a stub library entry point and return the argv it received.""" + stub = tmp_path / "stub_train.py" + stub.write_text( + "received = None\n\n\ndef run(argv):\n global received\n globals()['received'] = list(argv)\n" + ) + exit_code = rl_common.dispatch_library_entrypoint( + argv, {"stub": stub}, action="train", description="test", library_help="test" + ) + assert exit_code == 0 + return sys.modules["isaaclab_rl_train_stub"].received + + def test_space_separated_kit_args_fused_before_library_script(self, tmp_path): + received = self._dispatch(tmp_path, ["--rl_library", "stub", "--task", "X", "--kit_args", "--foo=/bar"]) + assert received == ["--task", "X", "--kit_args=--foo=/bar"] + + def test_already_working_argv_forms_forwarded_unchanged(self, tmp_path): + argv = ["--task", "X", "--kit_args=--foo=/bar", "--kit_args", "--a=/x --b=/y", "--num_envs", "16", "--headless"] + received = self._dispatch(tmp_path, ["--rl_library", "stub", *argv]) + assert received == argv + + +class TestKitArgsForwarding: + """Tests for forwarding ``--kit_args`` through the multi-GPU launcher.""" + + def test_space_separated_kit_args_forwarded_verbatim(self): + command = _build_command(["--task", "Isaac-Cartpole-Direct", "--kit_args", "--foo=/bar"]) + index = command.index("--kit_args") + assert command[index + 1] == "--foo=/bar" + + def test_equals_attached_kit_args_forwarded_unchanged(self): + command = _build_command(["--task", "Isaac-Cartpole-Direct", "--kit_args=--foo=/bar"]) + assert "--kit_args=--foo=/bar" in command + + def test_multi_token_kit_args_value_forwarded_as_single_token(self): + command = _build_command(["--task", "Isaac-Cartpole-Direct", "--kit_args", "--foo=/bar --baz=1"]) + index = command.index("--kit_args") + assert command[index + 1] == "--foo=/bar --baz=1" + + def test_forwarded_space_separated_kit_args_accepted_by_training_script(self, monkeypatch): + command = _build_command(["--task", "Isaac-Cartpole-Direct", "--kit_args", "--foo=/bar"]) + kit_args = _parse_as_training_script(_forwarded_train_argv(command), monkeypatch) + assert kit_args == "--foo=/bar" + + def test_forwarded_skrl_jax_kit_args_accepted_by_training_script(self, monkeypatch): + monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "0,1") + command = _build_command( + [ + "--rl_library", + "skrl", + "--num_gpus", + "2", + "--task", + "Isaac-Cartpole-Direct", + "--ml_framework", + "jax", + "--kit_args", + "--foo=/bar", + ] + ) + assert "skrl.utils.distributed.jax" in command + kit_args = _parse_as_training_script(_forwarded_train_argv(command), monkeypatch) + assert kit_args == "--foo=/bar" + + def test_dry_run_prints_shell_parsable_command(self, capsys): + exit_code = train_multigpu.main(["--dry_run", "--task", "Isaac-Cartpole-Direct", "--kit_args", "--foo=/bar"]) + assert exit_code == 0 + printed = capsys.readouterr().out.strip() + tokens = shlex.split(printed) + index = tokens.index("--kit_args") + assert tokens[index + 1] == "--foo=/bar" From 1acbea6b8b511455e9ffdd95b629f1dc7b397b76 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Wed, 8 Jul 2026 19:12:24 +0200 Subject: [PATCH 115/139] docs: headless doc cleanup (#6108) --- docs/source/features/hydra.rst | 4 ++-- docs/source/how-to/save_camera_output.rst | 2 +- .../core-concepts/physical-backends/ovphysx/index.rst | 2 +- scripts/tutorials/04_sensors/run_usd_camera.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/source/features/hydra.rst b/docs/source/features/hydra.rst index 3ad8e5b3897..da83e35ebd3 100644 --- a/docs/source/features/hydra.rst +++ b/docs/source/features/hydra.rst @@ -48,8 +48,8 @@ As a result, training with hydra arguments can be run with the following syntax: ./isaaclab.sh train --rl_library sb3 --task=Isaac-Cartpole env.actions.joint_effort.scale=10.0 agent.seed=2024 -The above command will run training with the task ``Isaac-Cartpole`` in headless mode, and set the -``env.actions.joint_effort.scale`` parameter to 10.0 and the ``agent.seed`` parameter to 2024. +The above command will run training with the task ``Isaac-Cartpole`` without selecting a visualizer, +and set the ``env.actions.joint_effort.scale`` parameter to 10.0 and the ``agent.seed`` parameter to 2024. .. note:: diff --git a/docs/source/how-to/save_camera_output.rst b/docs/source/how-to/save_camera_output.rst index c8174bb75ea..db155132778 100644 --- a/docs/source/how-to/save_camera_output.rst +++ b/docs/source/how-to/save_camera_output.rst @@ -94,7 +94,7 @@ To run the accompanying script, execute the following command: # Usage with saving and drawing python scripts/tutorials/04_sensors/run_usd_camera.py --save --draw --enable_cameras - # Usage with saving only in headless mode + # Usage with saving only (no visualizer) python scripts/tutorials/04_sensors/run_usd_camera.py --save --enable_cameras diff --git a/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst b/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst index 3120d1dce06..7efd7fa9f78 100644 --- a/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst +++ b/docs/source/overview/core-concepts/physical-backends/ovphysx/index.rst @@ -109,7 +109,7 @@ syntax as the other backends: .. code-block:: bash - ./isaaclab.sh -p scripts/environments/zero_agent.py --task Isaac-Cartpole-Direct --num_envs 128 presets=ovphysx + ./isaaclab.sh -p scripts/environments/zero_agent.py --task Isaac-Cartpole-Direct --num_envs 128 --viz none presets=ovphysx This command starts a headless zero-action rollout; stop it with ``Ctrl+C`` after the environment has started and stepped successfully. diff --git a/scripts/tutorials/04_sensors/run_usd_camera.py b/scripts/tutorials/04_sensors/run_usd_camera.py index 941a34bc7df..ab7b59a2c3d 100644 --- a/scripts/tutorials/04_sensors/run_usd_camera.py +++ b/scripts/tutorials/04_sensors/run_usd_camera.py @@ -14,8 +14,8 @@ # Usage with GUI ./isaaclab.sh -p scripts/tutorials/04_sensors/run_usd_camera.py --enable_cameras --viz kit - # Usage with headless - ./isaaclab.sh -p scripts/tutorials/04_sensors/run_usd_camera.py --headless --enable_cameras + # Usage with no visualizer + ./isaaclab.sh -p scripts/tutorials/04_sensors/run_usd_camera.py --enable_cameras """ From e50c68cb71a8a477598d39a3e9143d4ac8292958 Mon Sep 17 00:00:00 2001 From: Richard Lei Date: Thu, 9 Jul 2026 07:49:12 +1200 Subject: [PATCH 116/139] ovrtx: expose semantic segmentation idToLabels (#6385) --- ...mantic-segmentation-id-to-labels.minor.rst | 20 +++ .../renderers/ovrtx_annotator_utils.py | 150 ++++++++++++++++++ .../isaaclab_ov/renderers/ovrtx_renderer.py | 72 +++++++-- .../renderers/ovrtx_renderer_cfg.py | 10 ++ .../renderers/ovrtx_renderer_kernels.py | 29 ++-- .../isaaclab_ov/renderers/ovrtx_usd.py | 10 +- .../test/test_ovrtx_annotator_utils.py | 109 +++++++++++++ .../test/test_ovrtx_renderer_contract.py | 57 ++++++- .../test/test_ovrtx_renderer_kernels.py | 11 +- source/isaaclab_ov/test/test_ovrtx_usd.py | 25 +++ .../ovrtx-semantic-seg-id-to-labels.rst | 7 + .../renderers/isaac_rtx_renderer.py | 13 +- .../test_isaac_rtx_renderer_contract.py | 27 ++++ ...tx-semantic-segmentation-id-to-labels.skip | 2 + ...sim_rtx_renderer-semantic_segmentation.png | 4 +- ...n-ovrtx_renderer-semantic_segmentation.png | 4 +- ...x-ovrtx_renderer-semantic_segmentation.png | 4 +- ...sim_rtx_renderer-semantic_segmentation.png | 4 +- ...n-ovrtx_renderer-semantic_segmentation.png | 4 +- ...sim_rtx_renderer-semantic_segmentation.png | 4 +- ...sim_rtx_renderer-semantic_segmentation.png | 4 +- ...n-ovrtx_renderer-semantic_segmentation.png | 4 +- ...sim_rtx_renderer-semantic_segmentation.png | 0 .../test/rendering_test_utils.py | 58 +++++++ 24 files changed, 587 insertions(+), 45 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/ovrtx-semantic-segmentation-id-to-labels.minor.rst create mode 100644 source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_annotator_utils.py create mode 100644 source/isaaclab_ov/test/test_ovrtx_annotator_utils.py create mode 100644 source/isaaclab_physx/changelog.d/ovrtx-semantic-seg-id-to-labels.rst create mode 100644 source/isaaclab_tasks/changelog.d/ovrtx-semantic-segmentation-id-to-labels.skip mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-semantic_segmentation.png diff --git a/source/isaaclab_ov/changelog.d/ovrtx-semantic-segmentation-id-to-labels.minor.rst b/source/isaaclab_ov/changelog.d/ovrtx-semantic-segmentation-id-to-labels.minor.rst new file mode 100644 index 00000000000..f267676511d --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovrtx-semantic-segmentation-id-to-labels.minor.rst @@ -0,0 +1,20 @@ +Added +^^^^^ + +* Added semantic-segmentation label metadata to the OVRTX renderer. The + ``SemanticIdMap`` render var is now decoded into + ``camera.data.info["semantic_segmentation"]["idToLabels"]``, matching the + Isaac RTX / Replicator contract (keys are semantic IDs or RGBA colors, values + are ``{semantic_type: label}`` dicts, always including ``BACKGROUND`` and + ``UNLABELLED``). +* Added :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_semantic_segmentation` + to select between colorized RGBA (``uint8``) and raw ``int32`` semantic-ID + output, at parity with the Isaac RTX renderer. + +Fixed +^^^^^ + +* Fixed the OVRTX segmentation colorization hash to use 32-bit wraparound + arithmetic (it previously widened to ``uint64``, changing the hashed bits). + Colorized semantic and instance segmentation IDs now map to the same colors + as ``omni.replicator`` / the Isaac RTX renderer. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_annotator_utils.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_annotator_utils.py new file mode 100644 index 00000000000..f0178eaa06e --- /dev/null +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_annotator_utils.py @@ -0,0 +1,150 @@ +# 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 + +"""Semantic annotator utilities for the OVRTX renderer. + +Decodes the OVRTX ``SemanticIdMap`` render var into an ``idToLabels`` mapping compatible with the +Isaac RTX / Replicator contract exposed through ``camera.data.info["semantic_segmentation"]``. +""" + +from __future__ import annotations + +import numpy as np +import warp as wp + +from .ovrtx_renderer_kernels import generate_random_colors_from_ids_kernel + +# Reserved semantic IDs shared by the OVRTX SemanticSegmentation AOV and Isaac RTX / Replicator: +# ID 0 is BACKGROUND (no prim), ID 1 is UNLABELLED (a prim with no matching semantic label). Entries +# with ID >= 2 are decoded from the SemanticIdMap render var. +SEMANTIC_ID_BACKGROUND = 0 +SEMANTIC_ID_UNLABELLED = 1 +RESERVED_SEMANTIC_LABELS: dict[int, dict[str, str]] = { + SEMANTIC_ID_BACKGROUND: {"class": "BACKGROUND"}, + SEMANTIC_ID_UNLABELLED: {"class": "UNLABELLED"}, +} + + +def parse_semantic_label(raw_label: str) -> dict[str, str]: + """Parse a raw OVRTX semantic label string into a ``{semantic_type: label}`` dict. + + OVRTX encodes labels as ``":