From 43ae9f35d06fbe5b28519a78f3b7c3a0ee722f7c Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Thu, 9 Apr 2026 18:06:21 -0700 Subject: [PATCH 01/20] expose newton collision cfg --- .../isaaclab_newton/physics/__init__.pyi | 11 +- .../physics/newton_collision_cfg.py | 168 ++++++++++++++++++ .../isaaclab_newton/physics/newton_manager.py | 31 +++- .../physics/newton_manager_cfg.py | 43 ++++- 4 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py diff --git a/source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi index 4e589e6f69d1..1b18da3838e1 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi @@ -5,12 +5,21 @@ __all__ = [ "FeatherstoneSolverCfg", + "HydroelasticSDFCfg", "MJWarpSolverCfg", "NewtonCfg", + "NewtonCollisionPipelineCfg", "NewtonManager", "NewtonSolverCfg", "XPBDSolverCfg", ] +from .newton_collision_cfg import HydroelasticSDFCfg, NewtonCollisionPipelineCfg from .newton_manager import NewtonManager -from .newton_manager_cfg import FeatherstoneSolverCfg, MJWarpSolverCfg, NewtonCfg, NewtonSolverCfg, XPBDSolverCfg +from .newton_manager_cfg import ( + FeatherstoneSolverCfg, + MJWarpSolverCfg, + NewtonCfg, + NewtonSolverCfg, + XPBDSolverCfg, +) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py new file mode 100644 index 000000000000..be1d6a6748c9 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py @@ -0,0 +1,168 @@ +# Copyright (c) 2022-2026, 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 Newton collision pipeline.""" + +from __future__ import annotations + +from typing import Literal + +from isaaclab.utils import configclass + + +@configclass +class HydroelasticSDFCfg: + """Configuration for SDF-based hydroelastic collision handling. + + Hydroelastic contacts generate distributed contact areas instead of point contacts, + providing more realistic force distribution for manipulation and compliant surfaces. + + For more details, see the `Newton Collisions Guide`_. + + .. _Newton Collisions Guide: https://newton-physics.github.io/newton/latest/concepts/collisions.html#hydroelastic-contacts + """ + + reduce_contacts: bool = True + """Whether to reduce contacts to a smaller representative set per shape pair. + + When False, all generated contacts are passed through without reduction. + + Defaults to ``True`` (same as Newton's default). + """ + + buffer_fraction: float = 1.0 + """Fraction of worst-case hydroelastic buffer allocations. Range: (0, 1]. + + Lower values reduce memory usage but may cause overflows in dense scenes. + Overflows are bounds-safe and emit warnings; increase this value when warnings appear. + + Defaults to ``1.0`` (same as Newton's default). + """ + + normal_matching: bool = True + """Whether to rotate reduced contact normals to align with aggregate force direction. + + Only active when ``reduce_contacts`` is True. + + Defaults to ``True`` (same as Newton's default). + """ + + anchor_contact: bool = False + """Whether to add an anchor contact at the center of pressure for each normal bin. + + The anchor contact helps preserve moment balance. Only active when ``reduce_contacts`` is True. + + Defaults to ``False`` (same as Newton's default). + """ + + margin_contact_area: float = 0.01 + """Contact area [m^2] used for non-penetrating contacts at the margin. + + Defaults to ``0.01`` (same as Newton's default). + """ + + output_contact_surface: bool = False + """Whether to output hydroelastic contact surface vertices for visualization. + + Defaults to ``False`` (same as Newton's default). + """ + + +@configclass +class NewtonCollisionPipelineCfg: + """Configuration for Newton collision pipeline. + + Full-featured collision pipeline with GJK/MPR narrow phase and pluggable broad phase. + When this config is set on :attr:`NewtonCfg.collision_cfg`: + + - **MJWarpSolverCfg**: Newton's collision pipeline replaces MuJoCo's internal contact solver. + - **Other solvers** (XPBD, Featherstone, etc.): Configures the collision pipeline parameters + (these solvers always use Newton's collision pipeline). + + Key features: + + - GJK/MPR algorithms for convex-convex collision detection + - Multiple broad phase options: NXN (all-pairs), SAP (sweep-and-prune), EXPLICIT (precomputed pairs) + - Mesh-mesh collision via SDF with contact reduction + - Optional hydroelastic contact model for compliant surfaces + + For more details, see the `Newton Collisions Guide`_ and `CollisionPipeline API`_. + + .. _Newton Collisions Guide: https://newton-physics.github.io/newton/latest/concepts/collisions.html + .. _CollisionPipeline API: https://newton-physics.github.io/newton/api/_generated/newton.CollisionPipeline.html + """ + + broad_phase: Literal["explicit", "nxn", "sap"] = "explicit" + """Broad phase algorithm for collision detection. + + Options: + + - ``"explicit"``: Use precomputed shape pairs from ``model.shape_contact_pairs``. + - ``"nxn"``: All-pairs brute force. Simple but O(n^2) complexity. + - ``"sap"``: Sweep-and-prune. Good for scenes with many dynamic objects. + + Defaults to ``"explicit"`` (same as Newton's default when ``broad_phase=None``). + """ + + reduce_contacts: bool = True + """Whether to reduce contacts for mesh-mesh collisions. + + When True, uses shared memory contact reduction to select representative contacts. + Improves performance and stability for meshes with many vertices. + + Defaults to ``True`` (same as Newton's default). + """ + + rigid_contact_max: int | None = None + """Maximum number of rigid contacts to allocate. + + Resolution order: + + 1. If provided, use this value. + 2. Else if ``model.rigid_contact_max > 0``, use the model value. + 3. Else estimate automatically from model shape and pair metadata. + + Defaults to ``None`` (auto-estimate, same as Newton's default). + """ + + max_triangle_pairs: int = 1_000_000 + """Maximum number of triangle pairs allocated by narrow phase for mesh and heightfield collisions. + + Increase this when scenes with large/complex meshes or heightfields report + triangle-pair overflow warnings. + + Defaults to ``1_000_000`` (same as Newton's default). + """ + + soft_contact_max: int | None = None + """Maximum number of soft contacts to allocate. + + If None, computed as ``shape_count * particle_count``. + + Defaults to ``None`` (auto-compute, same as Newton's default). + """ + + soft_contact_margin: float = 0.01 + """Margin [m] for soft contact generation. + + Defaults to ``0.01`` (same as Newton's default). + """ + + requires_grad: bool | None = None + """Whether to enable gradient computation for collision. + + If ``None``, uses ``model.requires_grad``. + + Defaults to ``None`` (same as Newton's default). + """ + + sdf_hydroelastic_config: HydroelasticSDFCfg | None = None + """Configuration for SDF-based hydroelastic collision handling. + + If ``None``, hydroelastic contacts are disabled. + If set, enables hydroelastic contacts with the specified parameters. + + Defaults to ``None`` (hydroelastic disabled, same as Newton's default). + """ diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 3e967751d8df..9971aef1c101 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -28,6 +28,7 @@ _cudart = None from newton import Axis, CollisionPipeline, Contacts, Control, Model, ModelBuilder, State, eval_fk from newton._src.usd.schemas import SchemaResolverNewton, SchemaResolverPhysx +from newton.geometry import HydroelasticSDF from newton.sensors import SensorContact as NewtonContactSensor from newton.solvers import SolverBase, SolverFeatherstone, SolverMuJoCo, SolverNotifyFlags, SolverXPBD @@ -37,6 +38,7 @@ if TYPE_CHECKING: from isaaclab.sim.simulation_context import SimulationContext + from .newton_collision_cfg import NewtonCollisionPipelineCfg logger = logging.getLogger(__name__) @@ -91,6 +93,7 @@ class NewtonManager(PhysicsManager): _contacts: Contacts | None = None _needs_collision_pipeline: bool = False _collision_pipeline = None + _collision_cfg: NewtonCollisionPipelineCfg | None = None _newton_contact_sensors: dict = {} # Maps sensor_key to NewtonContactSensor _report_contacts: bool = False _fk_dirty: bool = False @@ -365,6 +368,7 @@ def clear(cls): cls._contacts = None cls._needs_collision_pipeline = False cls._collision_pipeline = None + cls._collision_cfg = None cls._newton_contact_sensors = {} cls._report_contacts = False cls._fk_dirty = False @@ -532,7 +536,15 @@ def _initialize_contacts(cls) -> None: if cls._needs_collision_pipeline: # Newton collision pipeline: create pipeline and generate contacts if cls._collision_pipeline is None: - cls._collision_pipeline = CollisionPipeline(cls._model, broad_phase="explicit") + if cls._collision_cfg is not None: + cfg_dict = cls._collision_cfg.to_dict() + hydro_cfg = cfg_dict.pop("sdf_hydroelastic_config", None) + if hydro_cfg: + cfg_dict["sdf_hydroelastic_config"] = HydroelasticSDF.Config(**hydro_cfg) + cls._collision_pipeline = CollisionPipeline(cls._model, **cfg_dict) + else: + cls._collision_pipeline = CollisionPipeline(cls._model, broad_phase="explicit") + if cls._contacts is None: cls._contacts = cls._collision_pipeline.contacts() @@ -592,19 +604,20 @@ def initialize_solver(cls) -> None: else: raise ValueError(f"Invalid solver type: {cls._solver_type}") + # Store collision pipeline config + cls._collision_cfg = cfg.collision_cfg # type: ignore[union-attr] + # Determine if we need external collision detection # - SolverMuJoCo with use_mujoco_contacts=True: uses internal MuJoCo collision detection # - SolverMuJoCo with use_mujoco_contacts=False: needs Newton's unified collision pipeline # - Other solvers (XPBD, Featherstone): always need Newton's unified collision pipeline if isinstance(cls._solver, SolverMuJoCo): - # Handle both dict and object configs - if hasattr(solver_cfg, "use_mujoco_contacts"): - use_mujoco_contacts = solver_cfg.use_mujoco_contacts - elif isinstance(solver_cfg, dict): - use_mujoco_contacts = solver_cfg.get("use_mujoco_contacts", False) - else: - use_mujoco_contacts = getattr(solver_cfg, "use_mujoco_contacts", False) - cls._needs_collision_pipeline = not use_mujoco_contacts + cls._needs_collision_pipeline = not solver_cfg.use_mujoco_contacts + if solver_cfg.use_mujoco_contacts and cls._collision_cfg is not None: + raise ValueError( + "NewtonManager: collision_cfg cannot be set when use_mujoco_contacts=True." + " Either set use_mujoco_contacts=False or remove collision_cfg." + ) else: cls._needs_collision_pipeline = True diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py index afbf7f54ba0c..942a6dc2f49d 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py @@ -12,8 +12,10 @@ from isaaclab.physics import PhysicsCfg from isaaclab.utils import configclass +from .newton_collision_cfg import NewtonCollisionPipelineCfg + if TYPE_CHECKING: - from .newton_manager import NewtonManager + from isaaclab_newton.physics import NewtonManager @configclass @@ -104,7 +106,27 @@ class MJWarpSolverCfg(NewtonSolverCfg): """Whether to use parallel line search.""" use_mujoco_contacts: bool = True - """Whether to use MuJoCo's contact solver.""" + """Whether to use MuJoCo's internal contact solver. + + If ``True`` (default), MuJoCo handles collision detection and contact resolution internally. + If ``False``, Newton's :class:`CollisionPipeline` is used instead. A default pipeline + (``broad_phase="explicit"``) is created automatically when :attr:`NewtonCfg.collision_cfg` + is ``None``. Set :attr:`NewtonCfg.collision_cfg` to a :class:`NewtonCollisionPipelineCfg` + to customize pipeline parameters (broad phase, contact limits, hydroelastic, etc.). + + .. note:: + Setting ``collision_cfg`` while ``use_mujoco_contacts=True`` raises + :class:`ValueError` because the two collision modes are mutually exclusive. + """ + + tolerance: float = 1e-6 + """Solver convergence tolerance for the constraint residual. + + The solver iterates until the residual drops below this threshold or + ``iterations`` is reached. Lower values give more precise constraint + satisfaction at the cost of more iterations. MuJoCo default is ``1e-8``; + Newton default is ``1e-6``. + """ @configclass @@ -218,3 +240,20 @@ class NewtonCfg(PhysicsCfg): solver_cfg: NewtonSolverCfg = MJWarpSolverCfg() """Solver configuration. Default is MJWarpSolverCfg().""" + + collision_cfg: NewtonCollisionPipelineCfg | None = None + """Newton collision pipeline configuration. + + Controls how Newton's :class:`CollisionPipeline` is configured when it is active. + The pipeline is active when: + + - :class:`MJWarpSolverCfg` with ``use_mujoco_contacts=False``, or + - any non-MuJoCo solver (:class:`XPBDSolverCfg`, :class:`FeatherstoneSolverCfg`). + + If ``None`` (default), a pipeline with ``broad_phase="explicit"`` is created + automatically. Set this to a :class:`NewtonCollisionPipelineCfg` to customize + parameters such as broad phase algorithm, contact limits, or hydroelastic mode. + + .. note:: + Must not be set when ``use_mujoco_contacts=True`` (raises :class:`ValueError`). + """ From c9c1a29eaad465f35a724accadf7cbe33a88433a Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Thu, 9 Apr 2026 18:09:32 -0700 Subject: [PATCH 02/20] Add collision pipeline cfg, bump to 0.5.11 Add NewtonCollisionPipelineCfg and HydroelasticSDFCfg to expose Newton's collision pipeline parameters (broad phase, contact limits, hydroelastic mode) through NewtonCfg.collision_cfg. Add MJWarpSolverCfg.tolerance for solver convergence control. Fix validation order so collision_cfg is stored before the use_mujoco_contacts consistency check runs. Reset _collision_cfg in clear() to avoid stale state across reset cycles. Fall back to a default CollisionPipeline when collision_cfg is None. --- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 9 +++++++++ .../isaaclab_newton/physics/newton_manager.py | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 7f6e09f992a5..9cfd4c5fc105 100644 --- a/source/isaaclab_newton/config/extension.toml +++ b/source/isaaclab_newton/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.10" +version = "0.5.11" # 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 3a7633711b8d..fa54dc114eb8 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +0.5.11 (2026-04-09) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.physics.NewtonCollisionPipelineCfg` to expose Newton collision pipeline parameters via :attr:`~isaaclab_newton.physics.NewtonCfg.collision_cfg`. + + 0.5.10 (2026-04-05) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 9971aef1c101..2ca7df7cab21 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -38,6 +38,7 @@ if TYPE_CHECKING: from isaaclab.sim.simulation_context import SimulationContext + from .newton_collision_cfg import NewtonCollisionPipelineCfg logger = logging.getLogger(__name__) From 7bab8a0dc0ea168062caa17ac04916ed910425d6 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Thu, 9 Apr 2026 18:11:47 -0700 Subject: [PATCH 03/20] line break --- source/isaaclab_newton/docs/CHANGELOG.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index fa54dc114eb8..7a720e3ea215 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -7,7 +7,8 @@ Changelog Added ^^^^^ -* Added :class:`~isaaclab_newton.physics.NewtonCollisionPipelineCfg` to expose Newton collision pipeline parameters via :attr:`~isaaclab_newton.physics.NewtonCfg.collision_cfg`. +* Added :class:`~isaaclab_newton.physics.NewtonCollisionPipelineCfg` to expose Newton collision pipeline parameters via + :attr:`~isaaclab_newton.physics.NewtonCfg.collision_cfg`. 0.5.10 (2026-04-05) From 7bde3cb9379b7a0586fb29e1542626d81f35b75a Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 18:02:45 +0200 Subject: [PATCH 04/20] Address review feedback on collision pipeline config Move config resolution out of NewtonManager into NewtonCollisionPipelineCfg.to_pipeline_args(), following the Kamino to_solver_config() pattern. Fix truthiness check on hydroelastic config dict to use explicit `is not None`. Add missing changelog entry for MJWarpSolverCfg.tolerance. Use specific return type hint dict[str, Any]. --- source/isaaclab_newton/docs/CHANGELOG.rst | 9 +++++++++ .../physics/newton_collision_cfg.py | 20 ++++++++++++++++++- .../isaaclab_newton/physics/newton_manager.py | 7 +------ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index 7a720e3ea215..ac613c0e3d34 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -9,6 +9,15 @@ Added * Added :class:`~isaaclab_newton.physics.NewtonCollisionPipelineCfg` to expose Newton collision pipeline parameters via :attr:`~isaaclab_newton.physics.NewtonCfg.collision_cfg`. +* Added :attr:`~isaaclab_newton.physics.MJWarpSolverCfg.tolerance` for solver convergence control. + +Fixed +^^^^^ + +* Fixed truthiness check on hydroelastic config dict in collision pipeline + initialization. An explicit ``is not None`` check is now used so that + :class:`~isaaclab_newton.physics.newton_collision_cfg.HydroelasticSDFCfg` + with all-default values is no longer silently skipped. 0.5.10 (2026-04-05) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py index be1d6a6748c9..c8b0db0b3f4a 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import Literal +from typing import Any, Literal from isaaclab.utils import configclass @@ -166,3 +166,21 @@ class NewtonCollisionPipelineCfg: Defaults to ``None`` (hydroelastic disabled, same as Newton's default). """ + + def to_pipeline_args(self) -> dict[str, Any]: + """Build keyword arguments for :class:`newton.CollisionPipeline`. + + Converts this configuration into the dict expected by + ``CollisionPipeline.__init__``, handling nested config conversion + (e.g. :class:`HydroelasticSDFCfg` → ``HydroelasticSDF.Config``). + + Returns: + Keyword arguments suitable for ``CollisionPipeline(model, **args)``. + """ + from newton.geometry import HydroelasticSDF + + cfg_dict = self.to_dict() + hydro_cfg = cfg_dict.pop("sdf_hydroelastic_config", None) + if hydro_cfg is not None: + cfg_dict["sdf_hydroelastic_config"] = HydroelasticSDF.Config(**hydro_cfg) + return cfg_dict diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 2ca7df7cab21..e7c191aab067 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -28,7 +28,6 @@ _cudart = None from newton import Axis, CollisionPipeline, Contacts, Control, Model, ModelBuilder, State, eval_fk from newton._src.usd.schemas import SchemaResolverNewton, SchemaResolverPhysx -from newton.geometry import HydroelasticSDF from newton.sensors import SensorContact as NewtonContactSensor from newton.solvers import SolverBase, SolverFeatherstone, SolverMuJoCo, SolverNotifyFlags, SolverXPBD @@ -538,11 +537,7 @@ def _initialize_contacts(cls) -> None: # Newton collision pipeline: create pipeline and generate contacts if cls._collision_pipeline is None: if cls._collision_cfg is not None: - cfg_dict = cls._collision_cfg.to_dict() - hydro_cfg = cfg_dict.pop("sdf_hydroelastic_config", None) - if hydro_cfg: - cfg_dict["sdf_hydroelastic_config"] = HydroelasticSDF.Config(**hydro_cfg) - cls._collision_pipeline = CollisionPipeline(cls._model, **cfg_dict) + cls._collision_pipeline = CollisionPipeline(cls._model, **cls._collision_cfg.to_pipeline_args()) else: cls._collision_pipeline = CollisionPipeline(cls._model, broad_phase="explicit") From 99b6848d4c3ac1b7e2e8bf619d1f924501e08405 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 18:26:10 +0200 Subject: [PATCH 05/20] Add SDF/hydroelastic config design spec and implementation plan --- .../2026-04-10-newton-sdf-hydroelastic.md | 1106 +++++++++++++++++ ...0-newton-sdf-hydroelastic-config-design.md | 156 +++ 2 files changed, 1262 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-10-newton-sdf-hydroelastic.md create mode 100644 docs/superpowers/specs/2026-04-10-newton-sdf-hydroelastic-config-design.md diff --git a/docs/superpowers/plans/2026-04-10-newton-sdf-hydroelastic.md b/docs/superpowers/plans/2026-04-10-newton-sdf-hydroelastic.md new file mode 100644 index 000000000000..73d5e5039ceb --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-newton-sdf-hydroelastic.md @@ -0,0 +1,1106 @@ +# Newton SDF & Hydroelastic Config Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port SDF collision and hydroelastic shape preparation from PR #5160 into PR #5219's config design, as a new PR built on top of #5219's branch. + +**Architecture:** Add `SDFCfg` configclass for SDF mesh preparation (patterns, resolution, hydroelastic flags). Add manager methods to build SDF on matching shapes before model finalization. Integrate with the Newton cloner to apply SDF on prototypes before replication. + +**Tech Stack:** Python, Newton physics engine, Warp, IsaacLab configclass system + +--- + +### Task 0: Create feature branch + +**Files:** None + +- [ ] **Step 1: Create branch off PR #5219** + +```bash +git checkout expose_newton_collision_pipeline +git checkout -b antoiner/newton-sdf-config +``` + +- [ ] **Step 2: Commit the spec and plan docs** + +```bash +git add docs/superpowers/specs/2026-04-10-newton-sdf-hydroelastic-config-design.md docs/superpowers/plans/2026-04-10-newton-sdf-hydroelastic.md +git commit -m "Add SDF/hydroelastic config design spec and implementation plan" +``` + +--- + +### Task 1: Add `SDFCfg` configclass + +**Files:** +- Modify: `source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py` + +- [ ] **Step 1: Add `SDFCfg` class after `NewtonCollisionPipelineCfg`** + +Add this class at the end of `newton_collision_cfg.py`: + +```python +@configclass +class SDFCfg: + """Configuration for SDF (Signed Distance Field) collision on Newton meshes. + + When provided as :attr:`~isaaclab_newton.physics.NewtonCfg.sdf_cfg`, mesh + collision shapes matching the configured patterns will have SDF built via + Newton's ``mesh.build_sdf()`` API before model finalization. + + At least one of :attr:`max_resolution` or :attr:`target_voxel_size` must be + set for SDF to be built. At least one of :attr:`body_patterns` or + :attr:`shape_patterns` must be set to select which shapes receive SDF. + + .. note:: + For hydroelastic contacts to be generated, shapes must have SDF built + and the ``HYDROELASTIC`` flag set. Set :attr:`k_hydro` to enable + hydroelastic on all matched shapes, or use + :attr:`hydroelastic_shape_patterns` to limit which shapes get the flag. + The pipeline-level hydroelastic processing parameters are configured + separately via + :attr:`NewtonCollisionPipelineCfg.sdf_hydroelastic_config`. + """ + + max_resolution: int | None = None + """Maximum dimension [voxels] for sparse SDF grid (must be divisible by 8). + + Typical values: 128, 256, 512. + """ + + target_voxel_size: float | None = None + """Target voxel size [m] for sparse SDF grid. + + If provided, takes precedence over :attr:`max_resolution`. + """ + + narrow_band_range: tuple[float, float] = (-0.1, 0.1) + """Narrow band distance range (inner, outer) [m] for SDF computation.""" + + margin: float | None = None + """Collision margin [m] for SDF shapes. If ``None``, uses the builder's default.""" + + body_patterns: list[str] | None = None + """Regex patterns to match body labels (USD prim paths) for SDF. + + Bodies whose label matches at least one pattern will have SDF applied to + all their mesh shapes. Example: ``[".*elbow.*", ".*wrist.*"]``. + """ + + shape_patterns: list[str] | None = None + """Regex patterns to match shape labels (USD prim paths) for SDF. + + Only shapes whose label matches at least one pattern get SDF. + Example: ``[".*Gear.*", ".*gear.*"]``. + + .. note:: + At least one of :attr:`body_patterns` or :attr:`shape_patterns` must + be set for SDF to be applied. + """ + + pattern_resolutions: dict[str, int] | None = None + """Per-pattern SDF resolution overrides. + + Maps regex pattern to ``max_resolution`` for matching shapes. Shapes not + matching any pattern use the global :attr:`max_resolution`. First matching + pattern wins. Example: ``{".*elbow.*": 128, ".*power_supply.*": 512}``. + """ + + use_visual_meshes: bool = False + """Whether to create collision shapes from visual meshes for matched bodies + that lack collision geometry. + + When ``False`` (default), only existing collision meshes are patched with + SDF. When ``True``, bodies matching the configured patterns but lacking + collision shapes get a new collision shape created from their first visual + mesh. + """ + + k_hydro: float | None = None + """Hydroelastic stiffness coefficient [Pa] applied to matched shapes. + + If ``None`` (default), the ``HYDROELASTIC`` flag is not set on any shapes. + If set, matched shapes (optionally filtered by + :attr:`hydroelastic_shape_patterns`) get the ``HYDROELASTIC`` flag and + this stiffness value. + + .. note:: + Pipeline-level hydroelastic processing parameters (contact reduction, + buffer sizes, etc.) are configured separately via + :attr:`NewtonCollisionPipelineCfg.sdf_hydroelastic_config`. + """ + + hydroelastic_shape_patterns: list[str] | None = None + """Regex patterns to select which SDF shapes also get hydroelastic contacts. + + If ``None`` and :attr:`k_hydro` is set, all shapes matching the SDF + patterns get hydroelastic. If provided, only shapes whose label matches at + least one pattern get the ``HYDROELASTIC`` flag. + """ +``` + +- [ ] **Step 2: Add missing pipeline params to `HydroelasticSDFCfg`** + +Add these fields to `HydroelasticSDFCfg`, after the existing `output_contact_surface` field: + +```python + moment_matching: bool = False + """Whether to adjust reduced contact friction so net maximum moment matches + the unreduced reference. + + Only active when ``reduce_contacts`` is True. + + Defaults to ``False`` (same as Newton's default). + """ + + buffer_mult_broad: int = 1 + """Multiplier for preallocated broadphase buffer. + + Increase if a broadphase overflow warning is issued. + + Defaults to ``1`` (same as Newton's default). + """ + + buffer_mult_iso: int = 1 + """Multiplier for preallocated iso-surface extraction buffers. + + Increase if an iso buffer overflow warning is issued. + + Defaults to ``1`` (same as Newton's default). + """ + + buffer_mult_contact: int = 1 + """Multiplier for the preallocated face contact buffer. + + Increase if a face contact overflow warning is issued. + + Defaults to ``1`` (same as Newton's default). + """ + + grid_size: int = 262144 + """Grid size for hydroelastic contact handling. + + Defaults to ``262144`` (``256 * 8 * 128``, same as Newton's default). + """ +``` + +- [ ] **Step 3: Run pre-commit** + +```bash +./isaaclab.sh -f +``` + +Expected: All checks pass. + +- [ ] **Step 4: Commit** + +```bash +git add source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py +git commit -m "Add SDFCfg configclass and missing HydroelasticSDFCfg fields" +``` + +--- + +### Task 2: Wire `SDFCfg` into `NewtonCfg` and exports + +**Files:** +- Modify: `source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py` +- Modify: `source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi` + +- [ ] **Step 1: Add `sdf_cfg` field to `NewtonCfg`** + +In `newton_manager_cfg.py`, add this import at the top (after the existing `NewtonCollisionPipelineCfg` import): + +```python +from .newton_collision_cfg import NewtonCollisionPipelineCfg, SDFCfg +``` + +Then add this field to the `NewtonCfg` class, after the existing `collision_cfg` field: + +```python + sdf_cfg: SDFCfg | None = None + """SDF collision configuration. + + When set, mesh collision shapes matching the configured patterns will have + SDF built via Newton's ``mesh.build_sdf()`` at simulation start. This + also forces Newton's collision pipeline to be active (overriding + ``use_mujoco_contacts=True`` if necessary). + + See :class:`~isaaclab_newton.physics.newton_collision_cfg.SDFCfg` for + available parameters. + """ +``` + +- [ ] **Step 2: Update `__init__.pyi`** + +Add `SDFCfg` to `__all__` and the import from `newton_collision_cfg`: + +```python +__all__ = [ + "FeatherstoneSolverCfg", + "HydroelasticSDFCfg", + "MJWarpSolverCfg", + "NewtonCfg", + "NewtonCollisionPipelineCfg", + "NewtonManager", + "NewtonSolverCfg", + "SDFCfg", + "XPBDSolverCfg", +] + +from .newton_collision_cfg import HydroelasticSDFCfg, NewtonCollisionPipelineCfg, SDFCfg +from .newton_manager import NewtonManager +from .newton_manager_cfg import ( + FeatherstoneSolverCfg, + MJWarpSolverCfg, + NewtonCfg, + NewtonSolverCfg, + XPBDSolverCfg, +) +``` + +- [ ] **Step 3: Run pre-commit** + +```bash +./isaaclab.sh -f +``` + +- [ ] **Step 4: Commit** + +```bash +git add source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi +git commit -m "Wire SDFCfg into NewtonCfg and update exports" +``` + +--- + +### Task 3: Add SDF manager methods + +**Files:** +- Modify: `source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py` + +- [ ] **Step 1: Add `re` import** + +Add `import re` to the imports at the top of `newton_manager.py` (after `import logging`). + +- [ ] **Step 2: Add `_build_sdf_on_mesh` static method** + +Add this method to `NewtonManager`, after the `add_model_change` method (around line 392): + +```python + @staticmethod + def _build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, label: str): + """Build SDF on a mesh, resolving per-pattern resolution overrides. + + Args: + mesh: Newton mesh object to build SDF on. + sdf_cfg: The active :class:`SDFCfg` instance. + res_overrides: Compiled ``(pattern, resolution)`` pairs, or ``None``. + label: Shape label used for pattern resolution matching. + """ + if mesh is None: + return + if mesh.sdf is not None: + mesh.clear_sdf() + resolution = sdf_cfg.max_resolution + if res_overrides is not None: + for pat, res in res_overrides: + if pat.search(label): + resolution = res + break + sdf_kwargs: dict = dict(narrow_band_range=sdf_cfg.narrow_band_range) + if resolution is not None: + sdf_kwargs["max_resolution"] = resolution + if sdf_cfg.target_voxel_size is not None: + sdf_kwargs["target_voxel_size"] = sdf_cfg.target_voxel_size + mesh.build_sdf(**sdf_kwargs) +``` + +- [ ] **Step 3: Add `_create_sdf_collision_from_visual` classmethod** + +Add this method right after `_build_sdf_on_mesh`: + +```python + @classmethod + def _create_sdf_collision_from_visual( + cls, builder: ModelBuilder, sdf_shape_indices: set[int], sdf_cfg, res_overrides + ): + """Create collision shapes from visual meshes for matched bodies lacking collision geometry. + + Args: + builder: Newton model builder to modify. + sdf_shape_indices: Shape indices that matched SDF patterns. + sdf_cfg: The active :class:`SDFCfg` instance. + res_overrides: Compiled ``(pattern, resolution)`` pairs, or ``None``. + + Returns: + Tuple of ``(num_added, num_hydro)`` counts. + """ + from newton import ShapeFlags + + matched_bodies: set[int] = {builder.shape_body[si] for si in sdf_shape_indices} + bodies_with_collision: set[int] = set() + for si in range(builder.shape_count): + if builder.shape_flags[si] & ShapeFlags.COLLIDE_SHAPES and builder.shape_body[si] in matched_bodies: + bodies_with_collision.add(builder.shape_body[si]) + + shape_cfg_kwargs: dict = dict( + density=0.0, + has_shape_collision=True, + has_particle_collision=True, + is_visible=False, + ) + if sdf_cfg.margin is not None: + shape_cfg_kwargs["margin"] = sdf_cfg.margin + if sdf_cfg.k_hydro is not None: + shape_cfg_kwargs["is_hydroelastic"] = True + shape_cfg_kwargs["kh"] = sdf_cfg.k_hydro + sdf_shape_cfg = ModelBuilder.ShapeConfig(**shape_cfg_kwargs) + + num_added = 0 + num_hydro = 0 + for body_idx in matched_bodies - bodies_with_collision: + visual_si = None + for si in sdf_shape_indices: + if builder.shape_body[si] == body_idx and builder.shape_source[si] is not None: + visual_si = si + break + if visual_si is None: + body_lbl = builder.body_label[body_idx] + logger.warning(f"SDF: body '{body_lbl}' matched but has no visual mesh to create collision from.") + continue + + mesh = builder.shape_source[visual_si] + cls._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, builder.shape_label[visual_si]) + + body_lbl = builder.body_label[body_idx] + builder.add_shape_mesh( + body=body_idx, + xform=builder.shape_transform[visual_si], + mesh=mesh, + scale=builder.shape_scale[visual_si], + cfg=sdf_shape_cfg, + label=f"{body_lbl}/sdf_collision", + ) + num_added += 1 + if sdf_cfg.k_hydro is not None: + num_hydro += 1 + + return num_added, num_hydro +``` + +- [ ] **Step 4: Add `_apply_sdf_config` classmethod** + +Add this method right after `_create_sdf_collision_from_visual`: + +```python + @classmethod + def _apply_sdf_config(cls, builder: ModelBuilder): + """Apply SDF collision and optional hydroelastic flags to matching mesh shapes. + + Reads :attr:`SDFCfg` from the active physics config. Collects shapes + matching body/shape regex patterns, builds SDF on their meshes, and + optionally sets the ``HYDROELASTIC`` flag with :attr:`SDFCfg.k_hydro`. + + Args: + builder: Newton model builder to modify (before finalization). + """ + from newton import GeoType, ShapeFlags + + cfg = PhysicsManager._cfg + if cfg is None: + return + sdf_cfg = getattr(cfg, "sdf_cfg", None) + if sdf_cfg is None: + return + + if sdf_cfg.max_resolution is None and sdf_cfg.target_voxel_size is None: + logger.warning("SDFCfg provided but neither max_resolution nor target_voxel_size is set. SDF disabled.") + return + + # Compile patterns + body_patterns = [re.compile(p) for p in sdf_cfg.body_patterns] if sdf_cfg.body_patterns else None + shape_patterns = [re.compile(p) for p in sdf_cfg.shape_patterns] if sdf_cfg.shape_patterns else None + res_overrides = ( + [(re.compile(p), r) for p, r in sdf_cfg.pattern_resolutions.items()] + if sdf_cfg.pattern_resolutions + else None + ) + hydro_patterns = None + if sdf_cfg.k_hydro is not None and sdf_cfg.hydroelastic_shape_patterns is not None: + hydro_patterns = [re.compile(p) for p in sdf_cfg.hydroelastic_shape_patterns] + + if body_patterns is None and shape_patterns is None: + logger.warning("SDFCfg has no body_patterns or shape_patterns set. No shapes will receive SDF.") + return + + # Build reverse map: body_idx -> [mesh shape indices] + body_to_shapes: dict[int, list[int]] = {} + for si in range(builder.shape_count): + if builder.shape_type[si] == GeoType.MESH: + body_to_shapes.setdefault(builder.shape_body[si], []).append(si) + + sdf_shape_indices: set[int] = set() + + if body_patterns is not None: + for body_idx in range(len(builder.body_label)): + if any(p.search(builder.body_label[body_idx]) for p in body_patterns): + sdf_shape_indices.update(body_to_shapes.get(body_idx, [])) + + if shape_patterns is not None: + for shape_indices in body_to_shapes.values(): + for si in shape_indices: + if any(p.search(builder.shape_label[si]) for p in shape_patterns): + sdf_shape_indices.add(si) + + # Patch existing collision meshes + num_patched = 0 + num_hydro = 0 + for si in sdf_shape_indices: + if not (builder.shape_flags[si] & ShapeFlags.COLLIDE_SHAPES): + continue + cls._build_sdf_on_mesh(builder.shape_source[si], sdf_cfg, res_overrides, builder.shape_label[si]) + if sdf_cfg.margin is not None: + builder.shape_margin[si] = sdf_cfg.margin + if sdf_cfg.k_hydro is not None: + apply_hydro = hydro_patterns is None or any( + p.search(builder.shape_label[si]) for p in hydro_patterns + ) + if apply_hydro: + builder.shape_flags[si] |= ShapeFlags.HYDROELASTIC + builder.shape_material_kh[si] = sdf_cfg.k_hydro + num_hydro += 1 + num_patched += 1 + + # Optionally create collision shapes from visual meshes + num_added = 0 + if sdf_cfg.use_visual_meshes: + num_added, hydro_from_visual = cls._create_sdf_collision_from_visual( + builder, sdf_shape_indices, sdf_cfg, res_overrides + ) + num_hydro += hydro_from_visual + + hydro_msg = f", {num_hydro} hydroelastic shape(s)" if sdf_cfg.k_hydro is not None else "" + logger.info( + f"SDF config: {num_added} collision shape(s) added, {num_patched} existing shape(s) patched{hydro_msg}. " + f"(max_resolution={sdf_cfg.max_resolution}, narrow_band={sdf_cfg.narrow_band_range})" + ) +``` + +- [ ] **Step 5: Run pre-commit** + +```bash +./isaaclab.sh -f +``` + +- [ ] **Step 6: Commit** + +```bash +git add source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +git commit -m "Add SDF manager methods for shape preparation" +``` + +--- + +### Task 4: Integrate SDF into manager lifecycle + +**Files:** +- Modify: `source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py` + +- [ ] **Step 1: Call `_apply_sdf_config` in `instantiate_builder_from_stage`** + +In `instantiate_builder_from_stage()`, add a call to `_apply_sdf_config` just before `cls.set_builder(builder)` (around line 527): + +```python + cls._apply_sdf_config(builder) + cls.set_builder(builder) +``` + +- [ ] **Step 2: Force collision pipeline in `initialize_solver` when SDF is configured** + +In `initialize_solver()`, after the existing `if isinstance(cls._solver, SolverMuJoCo):` / `else:` block that sets `cls._needs_collision_pipeline` (after line 618), add: + +```python + # Force Newton pipeline when collision_cfg or SDF is configured + if cfg.collision_cfg is not None and not cls._needs_collision_pipeline: + logger.warning("collision_cfg set — enabling Newton collision pipeline.") + cls._needs_collision_pipeline = True + + sdf_cfg = getattr(cfg, "sdf_cfg", None) + has_sdf = ( + sdf_cfg is not None + and (sdf_cfg.body_patterns is not None or sdf_cfg.shape_patterns is not None) + and (sdf_cfg.max_resolution is not None or sdf_cfg.target_voxel_size is not None) + ) + if has_sdf and not cls._needs_collision_pipeline: + logger.warning("SDF collision requires Newton collision pipeline. Overriding use_mujoco_contacts.") + cls._needs_collision_pipeline = True +``` + +- [ ] **Step 3: Add hydroelastic warning in `_initialize_contacts`** + +In `_initialize_contacts()`, after the pipeline is created (after the `cls._collision_pipeline = CollisionPipeline(...)` lines, around line 542), add a warning check: + +```python + # Warn if hydroelastic was requested but no shapes qualify + hydro_requested = ( + cls._collision_cfg is not None + and cls._collision_cfg.sdf_hydroelastic_config is not None + ) + if hydro_requested and cls._collision_pipeline.hydroelastic_sdf is None: + logger.warning( + "HydroelasticSDFCfg was set but no hydroelastic shape pairs found. " + "Ensure shapes have SDF built (via SDFCfg with k_hydro set) and that " + "both shapes in each contact pair have the HYDROELASTIC flag." + ) +``` + +This goes after the `else: cls._collision_pipeline = CollisionPipeline(...)` branch but before the `if cls._contacts is None:` line, so it applies regardless of whether `_collision_cfg` was set or not. The check should be at the same indent level as the `if cls._collision_cfg is not None:` block: + +```python + if cls._needs_collision_pipeline: + # Newton collision pipeline: create pipeline and generate contacts + if cls._collision_pipeline is None: + if cls._collision_cfg is not None: + cls._collision_pipeline = CollisionPipeline(cls._model, **cls._collision_cfg.to_pipeline_args()) + else: + cls._collision_pipeline = CollisionPipeline(cls._model, broad_phase="explicit") + + # Warn if hydroelastic was requested but no shapes qualify + hydro_requested = ( + cls._collision_cfg is not None + and cls._collision_cfg.sdf_hydroelastic_config is not None + ) + if hydro_requested and cls._collision_pipeline.hydroelastic_sdf is None: + logger.warning( + "HydroelasticSDFCfg was set but no hydroelastic shape pairs found. " + "Ensure shapes have SDF built (via SDFCfg with k_hydro set) and that " + "both shapes in each contact pair have the HYDROELASTIC flag." + ) + + if cls._contacts is None: + cls._contacts = cls._collision_pipeline.contacts() +``` + +- [ ] **Step 4: Run pre-commit** + +```bash +./isaaclab.sh -f +``` + +- [ ] **Step 5: Commit** + +```bash +git add source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +git commit -m "Integrate SDF config into manager lifecycle" +``` + +--- + +### Task 5: Cloner integration + +**Files:** +- Modify: `source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py` + +- [ ] **Step 1: Add imports** + +Add `re` and `GeoType` imports at the top of `newton_replicate.py`: + +```python +import re +``` + +(after `from __future__ import annotations`) + +And update the newton import: + +```python +from newton import GeoType, ModelBuilder, solvers +``` + +And add the `PhysicsManager` import: + +```python +from isaaclab.physics import PhysicsManager +``` + +- [ ] **Step 2: Add SDF pattern skip and prototype SDF application** + +In `_build_newton_builder_from_mapping`, after the `env0_pos = positions[0]` line (line 64), add SDF pattern compilation: + +```python + # SDF collision requires original triangle meshes for mesh.build_sdf(). + # Convex hull approximation destroys the source geometry, so shapes + # matching SDF patterns must be excluded from approximation here. + # _apply_sdf_config() builds the SDF on each prototype after approximation. + cfg = PhysicsManager._cfg + sdf_cfg = getattr(cfg, "sdf_cfg", None) if cfg is not None else None + body_pats = [re.compile(x) for x in sdf_cfg.body_patterns] if sdf_cfg and sdf_cfg.body_patterns else None + shape_pats = [re.compile(x) for x in sdf_cfg.shape_patterns] if sdf_cfg and sdf_cfg.shape_patterns else None + has_sdf_patterns = body_pats is not None or shape_pats is not None +``` + +Then replace the existing `if simplify_meshes:` block (line 76-77): + +```python + if simplify_meshes: + p.approximate_meshes("convex_hull", keep_visual_shapes=True) + protos[src_path] = p +``` + +with: + +```python + if simplify_meshes: + if has_sdf_patterns: + sdf_bodies: set[int] = set() + if body_pats is not None: + for bi in range(len(p.body_label)): + if any(pat.search(p.body_label[bi]) for pat in body_pats): + sdf_bodies.add(bi) + + approx_indices = [] + for i in range(len(p.shape_type)): + if p.shape_type[i] != GeoType.MESH: + continue + # Skip shapes that will use SDF (matched by body or shape pattern) + if p.shape_body[i] in sdf_bodies: + continue + if shape_pats is not None: + lbl = p.shape_label[i] if i < len(p.shape_label) else "" + if any(pat.search(lbl) for pat in shape_pats): + continue + approx_indices.append(i) + if approx_indices: + p.approximate_meshes("convex_hull", shape_indices=approx_indices, keep_visual_shapes=True) + else: + p.approximate_meshes("convex_hull", keep_visual_shapes=True) + # Build SDF on prototype before add_builder copies it N times. + # Mesh objects are shared by reference, so SDF is built once and + # all environments inherit it. + NewtonManager._apply_sdf_config(p) + protos[src_path] = p +``` + +- [ ] **Step 3: Run pre-commit** + +```bash +./isaaclab.sh -f +``` + +- [ ] **Step 4: Commit** + +```bash +git add source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py +git commit -m "Skip convex hull for SDF shapes and apply SDF on cloner prototypes" +``` + +--- + +### Task 6: Tests + +**Files:** +- Create: `source/isaaclab_newton/test/physics/test_sdf_config.py` + +- [ ] **Step 1: Create test file** + +Create `source/isaaclab_newton/test/physics/test_sdf_config.py` with the following content. Tests use `unittest.mock` to avoid needing a running Newton simulation: + +```python +# Copyright (c) 2022-2026, 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 SDF collision configuration and application logic.""" + +import re +from unittest.mock import MagicMock, patch + +from newton import GeoType, ModelBuilder, ShapeFlags + + +class TestBuildSdfOnMesh: + """Tests for NewtonManager._build_sdf_on_mesh.""" + + @staticmethod + def _make_sdf_cfg(max_resolution=256, narrow_band_range=(-0.1, 0.1), target_voxel_size=None): + cfg = MagicMock() + cfg.max_resolution = max_resolution + cfg.narrow_band_range = narrow_band_range + cfg.target_voxel_size = target_voxel_size + return cfg + + def test_none_mesh_is_noop(self): + """Passing None as mesh should not raise.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + sdf_cfg = self._make_sdf_cfg() + NewtonManager._build_sdf_on_mesh(None, sdf_cfg, None, "test_label") + + def test_builds_sdf_with_max_resolution(self): + """SDF is built on mesh with max_resolution and narrow_band_range.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + mesh = MagicMock() + mesh.sdf = None + sdf_cfg = self._make_sdf_cfg(max_resolution=128) + + NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, None, "test_label") + + mesh.build_sdf.assert_called_once_with(narrow_band_range=(-0.1, 0.1), max_resolution=128) + + def test_clears_existing_sdf_before_rebuild(self): + """Existing SDF on mesh is cleared before building a new one.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + mesh = MagicMock() + mesh.sdf = "existing_sdf" + sdf_cfg = self._make_sdf_cfg() + + NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, None, "test_label") + + mesh.clear_sdf.assert_called_once() + mesh.build_sdf.assert_called_once() + + def test_target_voxel_size_passed_alongside_resolution(self): + """When target_voxel_size is set, it is passed alongside max_resolution.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + mesh = MagicMock() + mesh.sdf = None + sdf_cfg = self._make_sdf_cfg(max_resolution=256, target_voxel_size=0.005) + + NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, None, "test_label") + + call_kwargs = mesh.build_sdf.call_args[1] + assert call_kwargs["target_voxel_size"] == 0.005 + assert call_kwargs["max_resolution"] == 256 + + def test_resolution_override_by_pattern(self): + """Per-pattern resolution override is applied when label matches.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + mesh = MagicMock() + mesh.sdf = None + sdf_cfg = self._make_sdf_cfg(max_resolution=256) + res_overrides = [(re.compile(".*elbow.*"), 128)] + + NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, "/World/Robot/elbow_link/collision") + + call_kwargs = mesh.build_sdf.call_args[1] + assert call_kwargs["max_resolution"] == 128 + + def test_resolution_override_no_match_uses_global(self): + """When label doesn't match any override, global max_resolution is used.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + mesh = MagicMock() + mesh.sdf = None + sdf_cfg = self._make_sdf_cfg(max_resolution=256) + res_overrides = [(re.compile(".*elbow.*"), 128)] + + NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, "/World/Robot/wrist_link/collision") + + call_kwargs = mesh.build_sdf.call_args[1] + assert call_kwargs["max_resolution"] == 256 + + def test_resolution_override_first_match_wins(self): + """First matching pattern in res_overrides determines resolution.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + mesh = MagicMock() + mesh.sdf = None + sdf_cfg = self._make_sdf_cfg(max_resolution=256) + res_overrides = [ + (re.compile(".*link.*"), 64), + (re.compile(".*elbow.*"), 128), + ] + + NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, "/World/Robot/elbow_link/collision") + + call_kwargs = mesh.build_sdf.call_args[1] + assert call_kwargs["max_resolution"] == 64 # ".*link.*" matches first + + +class TestApplySdfConfig: + """Tests for NewtonManager._apply_sdf_config shape index collection and patching.""" + + @staticmethod + def _make_builder(bodies, shapes): + """Create a minimal ModelBuilder-like mock. + + Args: + bodies: List of body label strings. + shapes: List of dicts with keys: body_idx, label, geo_type, flags, source. + """ + builder = MagicMock(spec=ModelBuilder) + builder.body_label = bodies + builder.shape_count = len(shapes) + builder.shape_type = [s["geo_type"] for s in shapes] + builder.shape_body = [s["body_idx"] for s in shapes] + builder.shape_label = [s["label"] for s in shapes] + builder.shape_flags = [s["flags"] for s in shapes] + builder.shape_source = [s.get("source") for s in shapes] + builder.shape_margin = [0.0] * len(shapes) + builder.shape_material_kh = [0.0] * len(shapes) + return builder + + @staticmethod + def _make_cfg( + body_patterns=None, + shape_patterns=None, + max_resolution=256, + k_hydro=None, + hydroelastic_shape_patterns=None, + ): + cfg = MagicMock() + cfg.sdf_cfg = MagicMock() + cfg.sdf_cfg.max_resolution = max_resolution + cfg.sdf_cfg.target_voxel_size = None + cfg.sdf_cfg.narrow_band_range = (-0.1, 0.1) + cfg.sdf_cfg.margin = None + cfg.sdf_cfg.body_patterns = body_patterns + cfg.sdf_cfg.shape_patterns = shape_patterns + cfg.sdf_cfg.pattern_resolutions = None + cfg.sdf_cfg.use_visual_meshes = False + cfg.sdf_cfg.k_hydro = k_hydro + cfg.sdf_cfg.hydroelastic_shape_patterns = hydroelastic_shape_patterns + return cfg + + def test_no_sdf_cfg_is_noop(self): + """_apply_sdf_config returns early when sdf_cfg is None.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + builder = MagicMock(spec=ModelBuilder) + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = MagicMock() + pm._cfg.sdf_cfg = None + NewtonManager._apply_sdf_config(builder) + # No crash, no calls + assert not builder.method_calls + + def test_no_patterns_warns(self): + """_apply_sdf_config warns when no patterns are set.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + builder = MagicMock(spec=ModelBuilder) + cfg = self._make_cfg(body_patterns=None, shape_patterns=None) + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = cfg + with patch("isaaclab_newton.physics.newton_manager.logger") as mock_logger: + NewtonManager._apply_sdf_config(builder) + mock_logger.warning.assert_called() + + def test_body_pattern_collects_shapes(self): + """Shapes under matching bodies are collected for SDF.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/elbow", "/World/Robot/wrist"] + shapes = [ + {"body_idx": 0, "label": "/World/Robot/elbow/col", "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, + {"body_idx": 1, "label": "/World/Robot/wrist/col", "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, + ] + builder = self._make_builder(bodies, shapes) + cfg = self._make_cfg(body_patterns=[".*elbow.*"]) + + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = cfg + NewtonManager._apply_sdf_config(builder) + + # Only elbow shape should have build_sdf called + shapes[0]["source"].build_sdf.assert_called_once() + shapes[1]["source"].build_sdf.assert_not_called() + + def test_hydroelastic_flag_set_when_k_hydro(self): + """HYDROELASTIC flag is set on matched shapes when k_hydro is provided.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/elbow"] + shapes = [ + {"body_idx": 0, "label": "/World/Robot/elbow/col", "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, + ] + builder = self._make_builder(bodies, shapes) + cfg = self._make_cfg(body_patterns=[".*elbow.*"], k_hydro=1e10) + + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = cfg + NewtonManager._apply_sdf_config(builder) + + assert builder.shape_flags[0] & ShapeFlags.HYDROELASTIC + assert builder.shape_material_kh[0] == 1e10 + + def test_hydroelastic_shape_patterns_filter(self): + """hydroelastic_shape_patterns limits which shapes get HYDROELASTIC flag.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/elbow", "/World/Robot/wrist"] + shapes = [ + {"body_idx": 0, "label": "/World/Robot/elbow/col", "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, + {"body_idx": 1, "label": "/World/Robot/wrist/col", "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, + ] + builder = self._make_builder(bodies, shapes) + cfg = self._make_cfg( + body_patterns=[".*"], + k_hydro=1e10, + hydroelastic_shape_patterns=[".*elbow.*"], + ) + + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = cfg + NewtonManager._apply_sdf_config(builder) + + # Both get SDF built + shapes[0]["source"].build_sdf.assert_called_once() + shapes[1]["source"].build_sdf.assert_called_once() + # Only elbow gets hydroelastic + assert builder.shape_flags[0] & ShapeFlags.HYDROELASTIC + assert not (builder.shape_flags[1] & ShapeFlags.HYDROELASTIC) + + def test_shape_pattern_matching(self): + """shape_patterns directly matches shape labels.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/body"] + shapes = [ + {"body_idx": 0, "label": "/World/Robot/body/Gear_col", "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, + {"body_idx": 0, "label": "/World/Robot/body/frame_col", "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, + ] + builder = self._make_builder(bodies, shapes) + cfg = self._make_cfg(shape_patterns=[".*Gear.*"]) + + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = cfg + NewtonManager._apply_sdf_config(builder) + + shapes[0]["source"].build_sdf.assert_called_once() + shapes[1]["source"].build_sdf.assert_not_called() + + def test_non_mesh_shapes_skipped(self): + """Non-mesh shapes are never collected for SDF.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/elbow"] + shapes = [ + {"body_idx": 0, "label": "/World/Robot/elbow/box", "geo_type": GeoType.BOX, + "flags": ShapeFlags.COLLIDE_SHAPES, "source": None}, + ] + builder = self._make_builder(bodies, shapes) + cfg = self._make_cfg(body_patterns=[".*elbow.*"]) + + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = cfg + NewtonManager._apply_sdf_config(builder) + # No build_sdf calls (box shape has no source to call on) +``` + +- [ ] **Step 2: Run tests** + +```bash +./isaaclab.sh -p -m pytest source/isaaclab_newton/test/physics/test_sdf_config.py -v +``` + +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add source/isaaclab_newton/test/physics/test_sdf_config.py +git commit -m "Add tests for SDF config and shape preparation" +``` + +--- + +### Task 7: Changelog and version bump + +**Files:** +- Modify: `source/isaaclab_newton/docs/CHANGELOG.rst` +- Modify: `source/isaaclab_newton/config/extension.toml` + +- [ ] **Step 1: Bump version to 0.5.12** + +In `source/isaaclab_newton/config/extension.toml`, change: +``` +version = "0.5.11" +``` +to: +``` +version = "0.5.12" +``` + +- [ ] **Step 2: Add new changelog version** + +In `source/isaaclab_newton/docs/CHANGELOG.rst`, add a new version heading before `0.5.11`: + +```rst +0.5.12 (2026-04-10) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.physics.SDFCfg` for configuring SDF-based mesh + collisions via Newton's ``mesh.build_sdf()`` API. Supports per-body and per-shape + regex pattern matching, per-pattern resolution overrides, and optional creation of + collision shapes from visual meshes. +* Added hydroelastic shape enablement fields + (:attr:`~isaaclab_newton.physics.SDFCfg.k_hydro`, + :attr:`~isaaclab_newton.physics.SDFCfg.hydroelastic_shape_patterns`) on + :class:`~isaaclab_newton.physics.SDFCfg`. +* Added missing hydroelastic pipeline parameters to + :class:`~isaaclab_newton.physics.HydroelasticSDFCfg`: ``moment_matching``, + ``buffer_mult_broad``, ``buffer_mult_iso``, ``buffer_mult_contact``, ``grid_size``. +* Added SDF pattern skip in the Newton cloner to preserve original triangle + meshes for shapes that will use SDF collision. + + +``` + +- [ ] **Step 3: Run pre-commit** + +```bash +./isaaclab.sh -f +``` + +- [ ] **Step 4: Commit** + +```bash +git add source/isaaclab_newton/docs/CHANGELOG.rst source/isaaclab_newton/config/extension.toml +git commit -m "Add SDF changelog entries and bump to 0.5.12" +``` + +--- + +### Task 8: Final validation + +- [ ] **Step 1: Run all tests** + +```bash +./isaaclab.sh -p -m pytest source/isaaclab_newton/test/physics/test_sdf_config.py -v +``` + +Expected: All tests pass. + +- [ ] **Step 2: Run pre-commit on all files** + +```bash +./isaaclab.sh -f +``` + +Expected: All checks pass. + +- [ ] **Step 3: Verify diff is clean** + +```bash +git diff +git status +``` + +Expected: No unstaged changes. diff --git a/docs/superpowers/specs/2026-04-10-newton-sdf-hydroelastic-config-design.md b/docs/superpowers/specs/2026-04-10-newton-sdf-hydroelastic-config-design.md new file mode 100644 index 000000000000..7be30b84f909 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-newton-sdf-hydroelastic-config-design.md @@ -0,0 +1,156 @@ +# Newton SDF & Hydroelastic Configuration + +**Date:** 2026-04-10 +**Built on:** PR #5219 (`expose_newton_collision_pipeline`) +**Ports from:** PR #5160 (`vidur/feature/sdf-collision`) +**Target:** New PR → `develop` + +## Summary + +Port SDF collision and hydroelastic shape preparation from PR #5160 into +PR #5219's config design. #5219 owns the collision pipeline config layer; +this PR adds the shape-preparation machinery that makes hydroelastic +contacts actually work end-to-end. + +## Design Decisions + +**Keep #5219's config hierarchy.** `HydroelasticSDFCfg` (pipeline processing +params) stays nested under `NewtonCollisionPipelineCfg.sdf_hydroelastic_config`. + +**Add `SDFCfg` as a new top-level field on `NewtonCfg`.** Shape-level concerns +(which meshes get SDF, resolution, hydroelastic flag enablement) live here, +separate from pipeline configuration. + +**Flatten hydroelastic shape enablement into `SDFCfg`.** #5160 used a nested +`HydroelasticCfg` object. We put `k_hydro` and `hydroelastic_shape_patterns` +directly on `SDFCfg` since they're simple fields that control shape flags, +not a separate subsystem. + +## Config Hierarchy + +``` +NewtonCfg +├── solver_cfg: NewtonSolverCfg +├── collision_cfg: NewtonCollisionPipelineCfg | None +│ ├── broad_phase, reduce_contacts, rigid_contact_max, ... +│ └── sdf_hydroelastic_config: HydroelasticSDFCfg | None +│ ├── reduce_contacts, normal_matching, anchor_contact +│ ├── moment_matching, margin_contact_area +│ ├── buffer_fraction, buffer_mult_broad/iso/contact +│ ├── grid_size, output_contact_surface +│ └── (maps 1:1 to HydroelasticSDF.Config via to_pipeline_args()) +└── sdf_cfg: SDFCfg | None + ├── max_resolution: int | None + ├── target_voxel_size: float | None + ├── narrow_band_range: tuple[float, float] + ├── margin: float | None + ├── body_patterns: list[str] | None + ├── shape_patterns: list[str] | None + ├── pattern_resolutions: dict[str, int] | None + ├── use_visual_meshes: bool + ├── k_hydro: float (shape-level stiffness) + └── hydroelastic_shape_patterns: list[str] | None +``` + +## Components + +### 1. Config additions (`newton_collision_cfg.py`) + +**`SDFCfg`** — new configclass in the existing file. + +Fields ported from #5160's `SDFCfg`: +- `max_resolution`, `target_voxel_size`, `narrow_band_range`, `margin` +- `body_patterns`, `shape_patterns`, `pattern_resolutions` +- `use_visual_meshes` + +Shape-level hydroelastic fields (flattened from #5160's `HydroelasticCfg`): +- `k_hydro: float = 1e10` — stiffness applied to shapes via `shape_material_kh` +- `hydroelastic_shape_patterns: list[str] | None = None` — if None, all + SDF shapes get HYDROELASTIC flag; if set, only matching shapes + +**`HydroelasticSDFCfg`** — add missing pipeline params from #5160: +- `moment_matching: bool = False` +- `buffer_mult_broad: int = 1` +- `buffer_mult_iso: int = 1` +- `buffer_mult_contact: int = 1` +- `grid_size: int = 256 * 8 * 128` + +**`NewtonCfg`** — add `sdf_cfg: SDFCfg | None = None` in `newton_manager_cfg.py`. + +### 2. Manager methods (`newton_manager.py`) + +**`_build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, label)`** — static method. +Builds SDF on a mesh. Clears existing SDF first. Applies per-pattern +resolution overrides. Passes `narrow_band_range`, `max_resolution`, +`target_voxel_size` to `mesh.build_sdf()`. + +**`_apply_sdf_config(builder)`** — classmethod. +1. Read `sdf_cfg` from `PhysicsManager._cfg`; return early if None. +2. Validate that at least one of `max_resolution`/`target_voxel_size` is set. +3. Compile body/shape/hydroelastic regex patterns. +4. Collect matching shape indices from builder. +5. For each matching collision shape: build SDF, optionally set HYDROELASTIC + flag + `k_hydro`. +6. If `use_visual_meshes`, call `_create_sdf_collision_from_visual()`. +7. Log summary. + +**`_create_sdf_collision_from_visual(builder, sdf_shape_indices, sdf_cfg, res_overrides)`** +— classmethod. For matched bodies that lack collision geometry, creates a +collision shape from the first visual mesh with SDF built on it. + +**`initialize_solver()` changes:** +- After determining `_needs_collision_pipeline`, force it `True` when + `collision_cfg is not None` or when `sdf_cfg` has valid patterns + resolution. +- Log a warning when overriding. + +**`_initialize_contacts()` changes:** +- After creating pipeline, if hydroelastic was configured + (`collision_cfg.sdf_hydroelastic_config is not None`) but + `pipeline.hydroelastic_sdf is None`, log a warning. + +**`instantiate_builder_from_stage()` change:** +- Call `cls._apply_sdf_config(builder)` before `cls.set_builder(builder)`. + +### 3. Cloner integration (`newton_replicate.py`) + +In `_build_newton_builder_from_mapping()`: +1. Read `sdf_cfg` from `PhysicsManager._cfg`. +2. Compile body/shape patterns if present. +3. When `simplify_meshes` is True and SDF patterns exist, skip convex hull + approximation for shapes matching SDF patterns (preserves triangle meshes). +4. After prototype building, call `NewtonManager._apply_sdf_config(prototype)` + on each prototype before `add_builder` replication. + +### 4. Exports (`__init__.pyi`) + +Add `SDFCfg` to `__all__` and import list. + +### 5. Tests (`test_sdf_config.py`) + +Port from #5160, adapted for new config structure: +- `TestBuildSdfOnMesh` — None mesh, max_resolution, clear existing SDF, + target_voxel_size precedence, pattern resolution overrides +- `TestApplySdfConfig` — shape index collection by body/shape patterns, + hydroelastic flag setting, visual mesh fallback, edge cases + +### 6. Changelog + +Add to existing `0.5.11` entry (or bump to `0.5.12`): +- Added `SDFCfg` for SDF mesh collision configuration +- Added SDF pattern skip in Newton cloner +- Added missing hydroelastic pipeline params to `HydroelasticSDFCfg` + +## Execution Order + +1. Config additions (SDFCfg, HydroelasticSDFCfg fields, NewtonCfg.sdf_cfg) +2. Manager methods (_build_sdf_on_mesh, _apply_sdf_config, _create_sdf_collision_from_visual) +3. Manager integration (initialize_solver, _initialize_contacts, instantiate_builder_from_stage) +4. Cloner integration (newton_replicate.py) +5. Exports (__init__.pyi) +6. Tests +7. Changelog + version bump + +## Out of Scope + +- Visualizer additions from #5160 (newton_visualizer.py changes) +- Runtime SDF rebuild / dynamic pattern updates From a1067147332d298b95ecdcc8d06fca0b8d8318e5 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 18:27:40 +0200 Subject: [PATCH 06/20] Add SDFCfg configclass and missing HydroelasticSDFCfg fields Add SDFCfg @configclass for SDF mesh collision configuration with fields for resolution, narrow band, margin, body/shape pattern matching, per-pattern resolution overrides, visual mesh fallback, and hydroelastic stiffness assignment. Add five missing fields to HydroelasticSDFCfg: moment_matching, buffer_mult_broad, buffer_mult_iso, buffer_mult_contact, and grid_size. --- .../physics/newton_collision_cfg.py | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py index c8b0db0b3f4a..dd502aad7204 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py @@ -69,6 +69,38 @@ class HydroelasticSDFCfg: Defaults to ``False`` (same as Newton's default). """ + moment_matching: bool = False + """Whether to adjust reduced contact friction so net max moment matches unreduced. + + Only active when ``reduce_contacts`` is True. + + Defaults to ``False`` (same as Newton's default). + """ + + buffer_mult_broad: int = 1 + """Multiplier for preallocated broadphase buffer. + + Defaults to ``1`` (same as Newton's default). + """ + + buffer_mult_iso: int = 1 + """Multiplier for iso-surface extraction buffers. + + Defaults to ``1`` (same as Newton's default). + """ + + buffer_mult_contact: int = 1 + """Multiplier for face contact buffer. + + Defaults to ``1`` (same as Newton's default). + """ + + grid_size: int = 262144 + """Grid size for hydroelastic contact handling (256 * 8 * 128). + + Defaults to ``262144`` (same as Newton's default). + """ + @configclass class NewtonCollisionPipelineCfg: @@ -184,3 +216,116 @@ def to_pipeline_args(self) -> dict[str, Any]: if hydro_cfg is not None: cfg_dict["sdf_hydroelastic_config"] = HydroelasticSDF.Config(**hydro_cfg) return cfg_dict + + +@configclass +class SDFCfg: + """Configuration for SDF mesh collision shapes. + + Specifies how SDF (Signed Distance Field) voxel grids are built and assigned + to bodies or shapes in a Newton model. Bodies and shapes are selected by + regex patterns; the SDF resolution can be set globally or overridden + per-pattern. + + Optional hydroelastic stiffness can be assigned to matched SDF shapes. + Pipeline-level hydroelastic parameters (contact reduction, buffer sizes, + etc.) are configured separately via + :attr:`NewtonCollisionPipelineCfg.sdf_hydroelastic_config`. + + Note: + At least one of :attr:`body_patterns` or :attr:`shape_patterns` must be + set. At least one of :attr:`max_resolution` or + :attr:`target_voxel_size` must be set. + """ + + max_resolution: int | None = None + """Maximum voxel dimension for the SDF grid. + + Must be divisible by 8. Typical values: 128, 256, 512. + Ignored when :attr:`target_voxel_size` is set. + + Defaults to ``None``. + """ + + target_voxel_size: float | None = None + """Target voxel size [m] for the SDF grid. + + When set, takes precedence over :attr:`max_resolution`. + + Defaults to ``None``. + """ + + narrow_band_range: tuple[float, float] = (-0.1, 0.1) + """Narrow band distance range (inner, outer) [m]. + + Defines the signed-distance extent stored in the SDF voxel grid. + Negative values are inside the mesh, positive values outside. + + Defaults to ``(-0.1, 0.1)``. + """ + + margin: float | None = None + """Collision margin [m] for SDF shapes. + + When ``None``, the Newton builder default is used. + + Defaults to ``None``. + """ + + body_patterns: list[str] | None = None + """Regex patterns for body labels. + + Matched bodies receive SDF collision shapes on all their mesh geometries. + At least one of :attr:`body_patterns` or :attr:`shape_patterns` must be set. + + Defaults to ``None``. + """ + + shape_patterns: list[str] | None = None + """Regex patterns for shape labels. + + Matched shapes receive SDF collision geometry directly. + At least one of :attr:`body_patterns` or :attr:`shape_patterns` must be set. + + Defaults to ``None``. + """ + + pattern_resolutions: dict[str, int] | None = None + """Per-pattern SDF resolution overrides. + + Maps a regex string to a ``max_resolution`` value. Patterns are evaluated + in insertion order; the first match wins. Unmatched bodies or shapes fall + back to the global :attr:`max_resolution` or :attr:`target_voxel_size`. + + Defaults to ``None``. + """ + + use_visual_meshes: bool = False + """Whether to create collision shapes from visual meshes. + + When ``True``, matched bodies that lack explicit collision geometry have SDF + collision shapes built from their visual meshes instead. + + Defaults to ``False``. + """ + + k_hydro: float | None = None + """Hydroelastic stiffness [Pa] assigned to matched SDF shapes. + + When ``None``, no ``HYDROELASTIC`` flag is set and hydroelastic contacts are + disabled for these shapes. When set, matched shapes receive the flag with + this stiffness value. + + Defaults to ``None``. + """ + + hydroelastic_shape_patterns: list[str] | None = None + """Regex patterns restricting which SDF shapes receive hydroelastic stiffness. + + Only relevant when :attr:`k_hydro` is set. When ``None``, all SDF shapes + matched by :attr:`body_patterns` or :attr:`shape_patterns` get the + hydroelastic flag. When set, only shapes whose labels match at least one + pattern here receive it. + + Defaults to ``None``. + """ From a01f9fdc749437968a48f52c3dde6972d7b74bbb Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 18:30:29 +0200 Subject: [PATCH 07/20] Wire SDFCfg into NewtonCfg and update exports Add sdf_cfg field (SDFCfg | None) to NewtonCfg and re-export SDFCfg from the physics package __init__.pyi so callers can configure SDF mesh collision at the top-level manager config. --- .../isaaclab_newton/physics/__init__.pyi | 3 ++- .../isaaclab_newton/physics/newton_manager_cfg.py | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi index 1b18da3838e1..45439af1d6ba 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi @@ -11,10 +11,11 @@ __all__ = [ "NewtonCollisionPipelineCfg", "NewtonManager", "NewtonSolverCfg", + "SDFCfg", "XPBDSolverCfg", ] -from .newton_collision_cfg import HydroelasticSDFCfg, NewtonCollisionPipelineCfg +from .newton_collision_cfg import HydroelasticSDFCfg, NewtonCollisionPipelineCfg, SDFCfg from .newton_manager import NewtonManager from .newton_manager_cfg import ( FeatherstoneSolverCfg, diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py index 942a6dc2f49d..1462fb1a46a6 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py @@ -12,7 +12,7 @@ from isaaclab.physics import PhysicsCfg from isaaclab.utils import configclass -from .newton_collision_cfg import NewtonCollisionPipelineCfg +from .newton_collision_cfg import NewtonCollisionPipelineCfg, SDFCfg if TYPE_CHECKING: from isaaclab_newton.physics import NewtonManager @@ -257,3 +257,15 @@ class NewtonCfg(PhysicsCfg): .. note:: Must not be set when ``use_mujoco_contacts=True`` (raises :class:`ValueError`). """ + + sdf_cfg: SDFCfg | None = None + """SDF collision configuration. + + When set, mesh collision shapes matching the configured patterns will have + SDF built via Newton's ``mesh.build_sdf()`` at simulation start. This + also forces Newton's collision pipeline to be active (overriding + ``use_mujoco_contacts=True`` if necessary). + + See :class:`~isaaclab_newton.physics.newton_collision_cfg.SDFCfg` for + available parameters. + """ From 398f666fd8e7185b7eddaeae5c747122ea5d203f Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 18:32:32 +0200 Subject: [PATCH 08/20] Add SDF manager methods for shape preparation Add _build_sdf_on_mesh, _create_sdf_collision_from_visual, and _apply_sdf_config classmethods to NewtonManager. These methods apply SDFCfg settings to matching mesh shapes in the model builder, including per-pattern resolution overrides and optional hydroelastic flags. --- .../isaaclab_newton/physics/newton_manager.py | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index e7c191aab067..a643e9ef23c3 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -11,6 +11,7 @@ import ctypes import inspect import logging +import re from typing import TYPE_CHECKING import numpy as np @@ -391,6 +392,190 @@ def add_model_change(cls, change: SolverNotifyFlags) -> None: """Register a model change to notify the solver.""" cls._model_changes.add(change) + @staticmethod + def _build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, label: str): + """Build SDF on a mesh, resolving per-pattern resolution overrides. + + Args: + mesh: Newton mesh object to build SDF on. + sdf_cfg: The active :class:`SDFCfg` instance. + res_overrides: Compiled ``(pattern, resolution)`` pairs, or ``None``. + label: Shape label used for pattern resolution matching. + """ + if mesh is None: + return + if mesh.sdf is not None: + mesh.clear_sdf() + resolution = sdf_cfg.max_resolution + if res_overrides is not None: + for pat, res in res_overrides: + if pat.search(label): + resolution = res + break + sdf_kwargs: dict = dict(narrow_band_range=sdf_cfg.narrow_band_range) + if resolution is not None: + sdf_kwargs["max_resolution"] = resolution + if sdf_cfg.target_voxel_size is not None: + sdf_kwargs["target_voxel_size"] = sdf_cfg.target_voxel_size + mesh.build_sdf(**sdf_kwargs) + + @classmethod + def _create_sdf_collision_from_visual( + cls, builder: ModelBuilder, sdf_shape_indices: set[int], sdf_cfg, res_overrides + ): + """Create collision shapes from visual meshes for matched bodies lacking collision geometry. + + Args: + builder: Newton model builder to modify. + sdf_shape_indices: Shape indices that matched SDF patterns. + sdf_cfg: The active :class:`SDFCfg` instance. + res_overrides: Compiled ``(pattern, resolution)`` pairs, or ``None``. + + Returns: + Tuple of ``(num_added, num_hydro)`` counts. + """ + from newton import ShapeFlags + + matched_bodies: set[int] = {builder.shape_body[si] for si in sdf_shape_indices} + bodies_with_collision: set[int] = set() + for si in range(builder.shape_count): + if builder.shape_flags[si] & ShapeFlags.COLLIDE_SHAPES and builder.shape_body[si] in matched_bodies: + bodies_with_collision.add(builder.shape_body[si]) + + shape_cfg_kwargs: dict = dict( + density=0.0, + has_shape_collision=True, + has_particle_collision=True, + is_visible=False, + ) + if sdf_cfg.margin is not None: + shape_cfg_kwargs["margin"] = sdf_cfg.margin + if sdf_cfg.k_hydro is not None: + shape_cfg_kwargs["is_hydroelastic"] = True + shape_cfg_kwargs["kh"] = sdf_cfg.k_hydro + sdf_shape_cfg = ModelBuilder.ShapeConfig(**shape_cfg_kwargs) + + num_added = 0 + num_hydro = 0 + for body_idx in matched_bodies - bodies_with_collision: + visual_si = None + for si in sdf_shape_indices: + if builder.shape_body[si] == body_idx and builder.shape_source[si] is not None: + visual_si = si + break + if visual_si is None: + body_lbl = builder.body_label[body_idx] + logger.warning(f"SDF: body '{body_lbl}' matched but has no visual mesh to create collision from.") + continue + + mesh = builder.shape_source[visual_si] + cls._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, builder.shape_label[visual_si]) + + body_lbl = builder.body_label[body_idx] + builder.add_shape_mesh( + body=body_idx, + xform=builder.shape_transform[visual_si], + mesh=mesh, + scale=builder.shape_scale[visual_si], + cfg=sdf_shape_cfg, + label=f"{body_lbl}/sdf_collision", + ) + num_added += 1 + if sdf_cfg.k_hydro is not None: + num_hydro += 1 + + return num_added, num_hydro + + @classmethod + def _apply_sdf_config(cls, builder: ModelBuilder): + """Apply SDF collision and optional hydroelastic flags to matching mesh shapes. + + Reads :attr:`SDFCfg` from the active physics config. Collects shapes + matching body/shape regex patterns, builds SDF on their meshes, and + optionally sets the ``HYDROELASTIC`` flag with :attr:`SDFCfg.k_hydro`. + + Args: + builder: Newton model builder to modify (before finalization). + """ + from newton import GeoType, ShapeFlags + + cfg = PhysicsManager._cfg + if cfg is None: + return + sdf_cfg = getattr(cfg, "sdf_cfg", None) + if sdf_cfg is None: + return + + if sdf_cfg.max_resolution is None and sdf_cfg.target_voxel_size is None: + logger.warning("SDFCfg provided but neither max_resolution nor target_voxel_size is set. SDF disabled.") + return + + # Compile patterns + body_patterns = [re.compile(p) for p in sdf_cfg.body_patterns] if sdf_cfg.body_patterns else None + shape_patterns = [re.compile(p) for p in sdf_cfg.shape_patterns] if sdf_cfg.shape_patterns else None + res_overrides = ( + [(re.compile(p), r) for p, r in sdf_cfg.pattern_resolutions.items()] + if sdf_cfg.pattern_resolutions + else None + ) + hydro_patterns = None + if sdf_cfg.k_hydro is not None and sdf_cfg.hydroelastic_shape_patterns is not None: + hydro_patterns = [re.compile(p) for p in sdf_cfg.hydroelastic_shape_patterns] + + if body_patterns is None and shape_patterns is None: + logger.warning("SDFCfg has no body_patterns or shape_patterns set. No shapes will receive SDF.") + return + + # Build reverse map: body_idx -> [mesh shape indices] + body_to_shapes: dict[int, list[int]] = {} + for si in range(builder.shape_count): + if builder.shape_type[si] == GeoType.MESH: + body_to_shapes.setdefault(builder.shape_body[si], []).append(si) + + sdf_shape_indices: set[int] = set() + + if body_patterns is not None: + for body_idx in range(len(builder.body_label)): + if any(p.search(builder.body_label[body_idx]) for p in body_patterns): + sdf_shape_indices.update(body_to_shapes.get(body_idx, [])) + + if shape_patterns is not None: + for shape_indices in body_to_shapes.values(): + for si in shape_indices: + if any(p.search(builder.shape_label[si]) for p in shape_patterns): + sdf_shape_indices.add(si) + + # Patch existing collision meshes + num_patched = 0 + num_hydro = 0 + for si in sdf_shape_indices: + if not (builder.shape_flags[si] & ShapeFlags.COLLIDE_SHAPES): + continue + cls._build_sdf_on_mesh(builder.shape_source[si], sdf_cfg, res_overrides, builder.shape_label[si]) + if sdf_cfg.margin is not None: + builder.shape_margin[si] = sdf_cfg.margin + if sdf_cfg.k_hydro is not None: + apply_hydro = hydro_patterns is None or any(p.search(builder.shape_label[si]) for p in hydro_patterns) + if apply_hydro: + builder.shape_flags[si] |= ShapeFlags.HYDROELASTIC + builder.shape_material_kh[si] = sdf_cfg.k_hydro + num_hydro += 1 + num_patched += 1 + + # Optionally create collision shapes from visual meshes + num_added = 0 + if sdf_cfg.use_visual_meshes: + num_added, hydro_from_visual = cls._create_sdf_collision_from_visual( + builder, sdf_shape_indices, sdf_cfg, res_overrides + ) + num_hydro += hydro_from_visual + + hydro_msg = f", {num_hydro} hydroelastic shape(s)" if sdf_cfg.k_hydro is not None else "" + logger.info( + f"SDF config: {num_added} collision shape(s) added, {num_patched} existing shape(s) patched{hydro_msg}. " + f"(max_resolution={sdf_cfg.max_resolution}, narrow_band={sdf_cfg.narrow_band_range})" + ) + @classmethod def invalidate_fk(cls) -> None: """Mark forward kinematics as needing recomputation. From 07f71ac2ee840d79ff32c91d07c0c330a516061f Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 18:34:46 +0200 Subject: [PATCH 09/20] Integrate SDF config into manager lifecycle --- .../isaaclab_newton/physics/newton_manager.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index a643e9ef23c3..d70c314bfc74 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -709,6 +709,7 @@ def instantiate_builder_from_stage(cls): cls._num_envs = len(env_paths) + cls._apply_sdf_config(builder) cls.set_builder(builder) @classmethod @@ -726,6 +727,17 @@ def _initialize_contacts(cls) -> None: else: cls._collision_pipeline = CollisionPipeline(cls._model, broad_phase="explicit") + # Warn if hydroelastic was requested but no shapes qualify + hydro_requested = ( + cls._collision_cfg is not None and cls._collision_cfg.sdf_hydroelastic_config is not None + ) + if hydro_requested and cls._collision_pipeline.hydroelastic_sdf is None: + logger.warning( + "HydroelasticSDFCfg was set but no hydroelastic shape pairs found. " + "Ensure shapes have SDF built (via SDFCfg with k_hydro set) and that " + "both shapes in each contact pair have the HYDROELASTIC flag." + ) + if cls._contacts is None: cls._contacts = cls._collision_pipeline.contacts() @@ -802,6 +814,21 @@ def initialize_solver(cls) -> None: else: cls._needs_collision_pipeline = True + # Force Newton pipeline when collision_cfg or SDF is configured + if cfg.collision_cfg is not None and not cls._needs_collision_pipeline: + logger.warning("collision_cfg set — enabling Newton collision pipeline.") + cls._needs_collision_pipeline = True + + sdf_cfg = getattr(cfg, "sdf_cfg", None) + has_sdf = ( + sdf_cfg is not None + and (sdf_cfg.body_patterns is not None or sdf_cfg.shape_patterns is not None) + and (sdf_cfg.max_resolution is not None or sdf_cfg.target_voxel_size is not None) + ) + if has_sdf and not cls._needs_collision_pipeline: + logger.warning("SDF collision requires Newton collision pipeline. Overriding use_mujoco_contacts.") + cls._needs_collision_pipeline = True + # Initialize contacts and collision pipeline cls._initialize_contacts() From d7e93d15883e55216da1b2db86f8d8c6962cb860 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 18:36:45 +0200 Subject: [PATCH 10/20] Skip convex hull for SDF shapes and apply SDF on cloner prototypes When SDF patterns are configured, exclude matching mesh shapes from convex-hull approximation so their triangle geometry is preserved for mesh.build_sdf(). Also call NewtonManager._apply_sdf_config() on each prototype before add_builder copies it N times, so SDF is built once and all environments inherit it. --- .../cloner/newton_replicate.py | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py index 59e5d2476ccc..69f255587cf9 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py @@ -5,15 +5,17 @@ from __future__ import annotations +import re from collections.abc import Callable import torch import warp as wp -from newton import ModelBuilder, solvers +from newton import GeoType, ModelBuilder, solvers from newton._src.usd.schemas import SchemaResolverNewton, SchemaResolverPhysx from pxr import Usd, UsdGeom +from isaaclab.physics import PhysicsManager from isaaclab.physics.scene_data_requirements import VisualizerPrebuiltArtifacts from isaaclab_newton.physics import NewtonManager @@ -62,6 +64,17 @@ def _build_newton_builder_from_mapping( # The prototype is built from env_0 in absolute world coordinates. # add_builder xforms are deltas from env_0 so positions don't get double-counted. env0_pos = positions[0] + + # SDF collision requires original triangle meshes for mesh.build_sdf(). + # Convex hull approximation destroys the source geometry, so shapes + # matching SDF patterns must be excluded from approximation here. + # _apply_sdf_config() builds the SDF on each prototype after approximation. + cfg = PhysicsManager._cfg + sdf_cfg = getattr(cfg, "sdf_cfg", None) if cfg is not None else None + body_pats = [re.compile(x) for x in sdf_cfg.body_patterns] if sdf_cfg and sdf_cfg.body_patterns else None + shape_pats = [re.compile(x) for x in sdf_cfg.shape_patterns] if sdf_cfg and sdf_cfg.shape_patterns else None + has_sdf_patterns = body_pats is not None or shape_pats is not None + protos: dict[str, ModelBuilder] = {} for src_path in sources: p = ModelBuilder(up_axis=up_axis) @@ -74,7 +87,33 @@ def _build_newton_builder_from_mapping( schema_resolvers=schema_resolvers, ) if simplify_meshes: - p.approximate_meshes("convex_hull", keep_visual_shapes=True) + if has_sdf_patterns: + sdf_bodies: set[int] = set() + if body_pats is not None: + for bi in range(len(p.body_label)): + if any(pat.search(p.body_label[bi]) for pat in body_pats): + sdf_bodies.add(bi) + + approx_indices = [] + for i in range(len(p.shape_type)): + if p.shape_type[i] != GeoType.MESH: + continue + # Skip shapes that will use SDF (matched by body or shape pattern) + if p.shape_body[i] in sdf_bodies: + continue + if shape_pats is not None: + lbl = p.shape_label[i] if i < len(p.shape_label) else "" + if any(pat.search(lbl) for pat in shape_pats): + continue + approx_indices.append(i) + if approx_indices: + p.approximate_meshes("convex_hull", shape_indices=approx_indices, keep_visual_shapes=True) + else: + p.approximate_meshes("convex_hull", keep_visual_shapes=True) + # Build SDF on prototype before add_builder copies it N times. + # Mesh objects are shared by reference, so SDF is built once and + # all environments inherit it. + NewtonManager._apply_sdf_config(p) protos[src_path] = p # create a separate world for each environment (heterogeneous spawning) From 13508bb2834eb607aa523f575c05d6ac14e79e73 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 18:38:44 +0200 Subject: [PATCH 11/20] Add tests for SDF config and shape preparation Unit tests for NewtonManager._build_sdf_on_mesh and _apply_sdf_config using unittest.mock to avoid needing a running Newton simulation. Covers resolution overrides, hydroelastic flags, body/shape pattern matching, and non-mesh shape exclusion. --- .../test/physics/test_sdf_config.py | 327 ++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 source/isaaclab_newton/test/physics/test_sdf_config.py diff --git a/source/isaaclab_newton/test/physics/test_sdf_config.py b/source/isaaclab_newton/test/physics/test_sdf_config.py new file mode 100644 index 000000000000..a58b3e2c6d31 --- /dev/null +++ b/source/isaaclab_newton/test/physics/test_sdf_config.py @@ -0,0 +1,327 @@ +# Copyright (c) 2022-2026, 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 SDF collision configuration and application logic.""" + +import re +from unittest.mock import MagicMock, patch + +from newton import GeoType, ModelBuilder, ShapeFlags + + +class TestBuildSdfOnMesh: + """Tests for NewtonManager._build_sdf_on_mesh.""" + + @staticmethod + def _make_sdf_cfg(max_resolution=256, narrow_band_range=(-0.1, 0.1), target_voxel_size=None): + cfg = MagicMock() + cfg.max_resolution = max_resolution + cfg.narrow_band_range = narrow_band_range + cfg.target_voxel_size = target_voxel_size + return cfg + + def test_none_mesh_is_noop(self): + """Passing None as mesh should not raise.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + sdf_cfg = self._make_sdf_cfg() + NewtonManager._build_sdf_on_mesh(None, sdf_cfg, None, "test_label") + + def test_builds_sdf_with_max_resolution(self): + """SDF is built on mesh with max_resolution and narrow_band_range.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + mesh = MagicMock() + mesh.sdf = None + sdf_cfg = self._make_sdf_cfg(max_resolution=128) + + NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, None, "test_label") + + mesh.build_sdf.assert_called_once_with(narrow_band_range=(-0.1, 0.1), max_resolution=128) + + def test_clears_existing_sdf_before_rebuild(self): + """Existing SDF on mesh is cleared before building a new one.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + mesh = MagicMock() + mesh.sdf = "existing_sdf" + sdf_cfg = self._make_sdf_cfg() + + NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, None, "test_label") + + mesh.clear_sdf.assert_called_once() + mesh.build_sdf.assert_called_once() + + def test_target_voxel_size_passed_alongside_resolution(self): + """When target_voxel_size is set, it is passed alongside max_resolution.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + mesh = MagicMock() + mesh.sdf = None + sdf_cfg = self._make_sdf_cfg(max_resolution=256, target_voxel_size=0.005) + + NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, None, "test_label") + + call_kwargs = mesh.build_sdf.call_args[1] + assert call_kwargs["target_voxel_size"] == 0.005 + assert call_kwargs["max_resolution"] == 256 + + def test_resolution_override_by_pattern(self): + """Per-pattern resolution override is applied when label matches.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + mesh = MagicMock() + mesh.sdf = None + sdf_cfg = self._make_sdf_cfg(max_resolution=256) + res_overrides = [(re.compile(".*elbow.*"), 128)] + + NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, "/World/Robot/elbow_link/collision") + + call_kwargs = mesh.build_sdf.call_args[1] + assert call_kwargs["max_resolution"] == 128 + + def test_resolution_override_no_match_uses_global(self): + """When label doesn't match any override, global max_resolution is used.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + mesh = MagicMock() + mesh.sdf = None + sdf_cfg = self._make_sdf_cfg(max_resolution=256) + res_overrides = [(re.compile(".*elbow.*"), 128)] + + NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, "/World/Robot/wrist_link/collision") + + call_kwargs = mesh.build_sdf.call_args[1] + assert call_kwargs["max_resolution"] == 256 + + def test_resolution_override_first_match_wins(self): + """First matching pattern in res_overrides determines resolution.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + mesh = MagicMock() + mesh.sdf = None + sdf_cfg = self._make_sdf_cfg(max_resolution=256) + res_overrides = [ + (re.compile(".*link.*"), 64), + (re.compile(".*elbow.*"), 128), + ] + + NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, "/World/Robot/elbow_link/collision") + + call_kwargs = mesh.build_sdf.call_args[1] + assert call_kwargs["max_resolution"] == 64 # ".*link.*" matches first + + +class TestApplySdfConfig: + """Tests for NewtonManager._apply_sdf_config shape index collection and patching.""" + + @staticmethod + def _make_builder(bodies, shapes): + """Create a minimal ModelBuilder-like mock. + + Args: + bodies: List of body label strings. + shapes: List of dicts with keys: body_idx, label, geo_type, flags, source. + """ + builder = MagicMock(spec=ModelBuilder) + builder.body_label = bodies + builder.shape_count = len(shapes) + builder.shape_type = [s["geo_type"] for s in shapes] + builder.shape_body = [s["body_idx"] for s in shapes] + builder.shape_label = [s["label"] for s in shapes] + builder.shape_flags = [s["flags"] for s in shapes] + builder.shape_source = [s.get("source") for s in shapes] + builder.shape_margin = [0.0] * len(shapes) + builder.shape_material_kh = [0.0] * len(shapes) + return builder + + @staticmethod + def _make_cfg( + body_patterns=None, + shape_patterns=None, + max_resolution=256, + k_hydro=None, + hydroelastic_shape_patterns=None, + ): + cfg = MagicMock() + cfg.sdf_cfg = MagicMock() + cfg.sdf_cfg.max_resolution = max_resolution + cfg.sdf_cfg.target_voxel_size = None + cfg.sdf_cfg.narrow_band_range = (-0.1, 0.1) + cfg.sdf_cfg.margin = None + cfg.sdf_cfg.body_patterns = body_patterns + cfg.sdf_cfg.shape_patterns = shape_patterns + cfg.sdf_cfg.pattern_resolutions = None + cfg.sdf_cfg.use_visual_meshes = False + cfg.sdf_cfg.k_hydro = k_hydro + cfg.sdf_cfg.hydroelastic_shape_patterns = hydroelastic_shape_patterns + return cfg + + def test_no_sdf_cfg_is_noop(self): + """_apply_sdf_config returns early when sdf_cfg is None.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + builder = MagicMock(spec=ModelBuilder) + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = MagicMock() + pm._cfg.sdf_cfg = None + NewtonManager._apply_sdf_config(builder) + assert not builder.method_calls + + def test_no_patterns_warns(self): + """_apply_sdf_config warns when no patterns are set.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + builder = MagicMock(spec=ModelBuilder) + cfg = self._make_cfg(body_patterns=None, shape_patterns=None) + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = cfg + with patch("isaaclab_newton.physics.newton_manager.logger") as mock_logger: + NewtonManager._apply_sdf_config(builder) + mock_logger.warning.assert_called() + + def test_body_pattern_collects_shapes(self): + """Shapes under matching bodies are collected for SDF.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/elbow", "/World/Robot/wrist"] + shapes = [ + { + "body_idx": 0, + "label": "/World/Robot/elbow/col", + "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, + "source": MagicMock(sdf=None), + }, + { + "body_idx": 1, + "label": "/World/Robot/wrist/col", + "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, + "source": MagicMock(sdf=None), + }, + ] + builder = self._make_builder(bodies, shapes) + cfg = self._make_cfg(body_patterns=[".*elbow.*"]) + + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = cfg + NewtonManager._apply_sdf_config(builder) + + shapes[0]["source"].build_sdf.assert_called_once() + shapes[1]["source"].build_sdf.assert_not_called() + + def test_hydroelastic_flag_set_when_k_hydro(self): + """HYDROELASTIC flag is set on matched shapes when k_hydro is provided.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/elbow"] + shapes = [ + { + "body_idx": 0, + "label": "/World/Robot/elbow/col", + "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, + "source": MagicMock(sdf=None), + }, + ] + builder = self._make_builder(bodies, shapes) + cfg = self._make_cfg(body_patterns=[".*elbow.*"], k_hydro=1e10) + + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = cfg + NewtonManager._apply_sdf_config(builder) + + assert builder.shape_flags[0] & ShapeFlags.HYDROELASTIC + assert builder.shape_material_kh[0] == 1e10 + + def test_hydroelastic_shape_patterns_filter(self): + """hydroelastic_shape_patterns limits which shapes get HYDROELASTIC flag.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/elbow", "/World/Robot/wrist"] + shapes = [ + { + "body_idx": 0, + "label": "/World/Robot/elbow/col", + "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, + "source": MagicMock(sdf=None), + }, + { + "body_idx": 1, + "label": "/World/Robot/wrist/col", + "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, + "source": MagicMock(sdf=None), + }, + ] + builder = self._make_builder(bodies, shapes) + cfg = self._make_cfg( + body_patterns=[".*"], + k_hydro=1e10, + hydroelastic_shape_patterns=[".*elbow.*"], + ) + + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = cfg + NewtonManager._apply_sdf_config(builder) + + shapes[0]["source"].build_sdf.assert_called_once() + shapes[1]["source"].build_sdf.assert_called_once() + assert builder.shape_flags[0] & ShapeFlags.HYDROELASTIC + assert not (builder.shape_flags[1] & ShapeFlags.HYDROELASTIC) + + def test_shape_pattern_matching(self): + """shape_patterns directly matches shape labels.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/body"] + shapes = [ + { + "body_idx": 0, + "label": "/World/Robot/body/Gear_col", + "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, + "source": MagicMock(sdf=None), + }, + { + "body_idx": 0, + "label": "/World/Robot/body/frame_col", + "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, + "source": MagicMock(sdf=None), + }, + ] + builder = self._make_builder(bodies, shapes) + cfg = self._make_cfg(shape_patterns=[".*Gear.*"]) + + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = cfg + NewtonManager._apply_sdf_config(builder) + + shapes[0]["source"].build_sdf.assert_called_once() + shapes[1]["source"].build_sdf.assert_not_called() + + def test_non_mesh_shapes_skipped(self): + """Non-mesh shapes are never collected for SDF.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/elbow"] + shapes = [ + { + "body_idx": 0, + "label": "/World/Robot/elbow/box", + "geo_type": GeoType.BOX, + "flags": ShapeFlags.COLLIDE_SHAPES, + "source": None, + }, + ] + builder = self._make_builder(bodies, shapes) + cfg = self._make_cfg(body_patterns=[".*elbow.*"]) + + with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: + pm._cfg = cfg + NewtonManager._apply_sdf_config(builder) From 0d602a6d00fe719ece7dc65d96ddc72eac9ffdc5 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 18:39:34 +0200 Subject: [PATCH 12/20] Add SDF changelog entries and bump to 0.5.12 --- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 21 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 9cfd4c5fc105..5fbc20c99341 100644 --- a/source/isaaclab_newton/config/extension.toml +++ b/source/isaaclab_newton/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.11" +version = "0.5.12" # 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 ac613c0e3d34..001356c02266 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,27 @@ Changelog --------- +0.5.12 (2026-04-10) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.physics.SDFCfg` for configuring SDF-based mesh + collisions via Newton's ``mesh.build_sdf()`` API. Supports per-body and per-shape + regex pattern matching, per-pattern resolution overrides, and optional creation of + collision shapes from visual meshes. +* Added hydroelastic shape enablement fields + (:attr:`~isaaclab_newton.physics.SDFCfg.k_hydro`, + :attr:`~isaaclab_newton.physics.SDFCfg.hydroelastic_shape_patterns`) on + :class:`~isaaclab_newton.physics.SDFCfg`. +* Added missing hydroelastic pipeline parameters to + :class:`~isaaclab_newton.physics.HydroelasticSDFCfg`: ``moment_matching``, + ``buffer_mult_broad``, ``buffer_mult_iso``, ``buffer_mult_contact``, ``grid_size``. +* Added SDF pattern skip in the Newton cloner to preserve original triangle + meshes for shapes that will use SDF collision. + + 0.5.11 (2026-04-09) ~~~~~~~~~~~~~~~~~~~ From 8d24953c38e7d41e83e0646efd7465ffb9489757 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 18:49:03 +0200 Subject: [PATCH 13/20] Remove design spec and plan docs from PR --- .../2026-04-10-newton-sdf-hydroelastic.md | 1106 ----------------- ...0-newton-sdf-hydroelastic-config-design.md | 156 --- 2 files changed, 1262 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-10-newton-sdf-hydroelastic.md delete mode 100644 docs/superpowers/specs/2026-04-10-newton-sdf-hydroelastic-config-design.md diff --git a/docs/superpowers/plans/2026-04-10-newton-sdf-hydroelastic.md b/docs/superpowers/plans/2026-04-10-newton-sdf-hydroelastic.md deleted file mode 100644 index 73d5e5039ceb..000000000000 --- a/docs/superpowers/plans/2026-04-10-newton-sdf-hydroelastic.md +++ /dev/null @@ -1,1106 +0,0 @@ -# Newton SDF & Hydroelastic Config Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Port SDF collision and hydroelastic shape preparation from PR #5160 into PR #5219's config design, as a new PR built on top of #5219's branch. - -**Architecture:** Add `SDFCfg` configclass for SDF mesh preparation (patterns, resolution, hydroelastic flags). Add manager methods to build SDF on matching shapes before model finalization. Integrate with the Newton cloner to apply SDF on prototypes before replication. - -**Tech Stack:** Python, Newton physics engine, Warp, IsaacLab configclass system - ---- - -### Task 0: Create feature branch - -**Files:** None - -- [ ] **Step 1: Create branch off PR #5219** - -```bash -git checkout expose_newton_collision_pipeline -git checkout -b antoiner/newton-sdf-config -``` - -- [ ] **Step 2: Commit the spec and plan docs** - -```bash -git add docs/superpowers/specs/2026-04-10-newton-sdf-hydroelastic-config-design.md docs/superpowers/plans/2026-04-10-newton-sdf-hydroelastic.md -git commit -m "Add SDF/hydroelastic config design spec and implementation plan" -``` - ---- - -### Task 1: Add `SDFCfg` configclass - -**Files:** -- Modify: `source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py` - -- [ ] **Step 1: Add `SDFCfg` class after `NewtonCollisionPipelineCfg`** - -Add this class at the end of `newton_collision_cfg.py`: - -```python -@configclass -class SDFCfg: - """Configuration for SDF (Signed Distance Field) collision on Newton meshes. - - When provided as :attr:`~isaaclab_newton.physics.NewtonCfg.sdf_cfg`, mesh - collision shapes matching the configured patterns will have SDF built via - Newton's ``mesh.build_sdf()`` API before model finalization. - - At least one of :attr:`max_resolution` or :attr:`target_voxel_size` must be - set for SDF to be built. At least one of :attr:`body_patterns` or - :attr:`shape_patterns` must be set to select which shapes receive SDF. - - .. note:: - For hydroelastic contacts to be generated, shapes must have SDF built - and the ``HYDROELASTIC`` flag set. Set :attr:`k_hydro` to enable - hydroelastic on all matched shapes, or use - :attr:`hydroelastic_shape_patterns` to limit which shapes get the flag. - The pipeline-level hydroelastic processing parameters are configured - separately via - :attr:`NewtonCollisionPipelineCfg.sdf_hydroelastic_config`. - """ - - max_resolution: int | None = None - """Maximum dimension [voxels] for sparse SDF grid (must be divisible by 8). - - Typical values: 128, 256, 512. - """ - - target_voxel_size: float | None = None - """Target voxel size [m] for sparse SDF grid. - - If provided, takes precedence over :attr:`max_resolution`. - """ - - narrow_band_range: tuple[float, float] = (-0.1, 0.1) - """Narrow band distance range (inner, outer) [m] for SDF computation.""" - - margin: float | None = None - """Collision margin [m] for SDF shapes. If ``None``, uses the builder's default.""" - - body_patterns: list[str] | None = None - """Regex patterns to match body labels (USD prim paths) for SDF. - - Bodies whose label matches at least one pattern will have SDF applied to - all their mesh shapes. Example: ``[".*elbow.*", ".*wrist.*"]``. - """ - - shape_patterns: list[str] | None = None - """Regex patterns to match shape labels (USD prim paths) for SDF. - - Only shapes whose label matches at least one pattern get SDF. - Example: ``[".*Gear.*", ".*gear.*"]``. - - .. note:: - At least one of :attr:`body_patterns` or :attr:`shape_patterns` must - be set for SDF to be applied. - """ - - pattern_resolutions: dict[str, int] | None = None - """Per-pattern SDF resolution overrides. - - Maps regex pattern to ``max_resolution`` for matching shapes. Shapes not - matching any pattern use the global :attr:`max_resolution`. First matching - pattern wins. Example: ``{".*elbow.*": 128, ".*power_supply.*": 512}``. - """ - - use_visual_meshes: bool = False - """Whether to create collision shapes from visual meshes for matched bodies - that lack collision geometry. - - When ``False`` (default), only existing collision meshes are patched with - SDF. When ``True``, bodies matching the configured patterns but lacking - collision shapes get a new collision shape created from their first visual - mesh. - """ - - k_hydro: float | None = None - """Hydroelastic stiffness coefficient [Pa] applied to matched shapes. - - If ``None`` (default), the ``HYDROELASTIC`` flag is not set on any shapes. - If set, matched shapes (optionally filtered by - :attr:`hydroelastic_shape_patterns`) get the ``HYDROELASTIC`` flag and - this stiffness value. - - .. note:: - Pipeline-level hydroelastic processing parameters (contact reduction, - buffer sizes, etc.) are configured separately via - :attr:`NewtonCollisionPipelineCfg.sdf_hydroelastic_config`. - """ - - hydroelastic_shape_patterns: list[str] | None = None - """Regex patterns to select which SDF shapes also get hydroelastic contacts. - - If ``None`` and :attr:`k_hydro` is set, all shapes matching the SDF - patterns get hydroelastic. If provided, only shapes whose label matches at - least one pattern get the ``HYDROELASTIC`` flag. - """ -``` - -- [ ] **Step 2: Add missing pipeline params to `HydroelasticSDFCfg`** - -Add these fields to `HydroelasticSDFCfg`, after the existing `output_contact_surface` field: - -```python - moment_matching: bool = False - """Whether to adjust reduced contact friction so net maximum moment matches - the unreduced reference. - - Only active when ``reduce_contacts`` is True. - - Defaults to ``False`` (same as Newton's default). - """ - - buffer_mult_broad: int = 1 - """Multiplier for preallocated broadphase buffer. - - Increase if a broadphase overflow warning is issued. - - Defaults to ``1`` (same as Newton's default). - """ - - buffer_mult_iso: int = 1 - """Multiplier for preallocated iso-surface extraction buffers. - - Increase if an iso buffer overflow warning is issued. - - Defaults to ``1`` (same as Newton's default). - """ - - buffer_mult_contact: int = 1 - """Multiplier for the preallocated face contact buffer. - - Increase if a face contact overflow warning is issued. - - Defaults to ``1`` (same as Newton's default). - """ - - grid_size: int = 262144 - """Grid size for hydroelastic contact handling. - - Defaults to ``262144`` (``256 * 8 * 128``, same as Newton's default). - """ -``` - -- [ ] **Step 3: Run pre-commit** - -```bash -./isaaclab.sh -f -``` - -Expected: All checks pass. - -- [ ] **Step 4: Commit** - -```bash -git add source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py -git commit -m "Add SDFCfg configclass and missing HydroelasticSDFCfg fields" -``` - ---- - -### Task 2: Wire `SDFCfg` into `NewtonCfg` and exports - -**Files:** -- Modify: `source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py` -- Modify: `source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi` - -- [ ] **Step 1: Add `sdf_cfg` field to `NewtonCfg`** - -In `newton_manager_cfg.py`, add this import at the top (after the existing `NewtonCollisionPipelineCfg` import): - -```python -from .newton_collision_cfg import NewtonCollisionPipelineCfg, SDFCfg -``` - -Then add this field to the `NewtonCfg` class, after the existing `collision_cfg` field: - -```python - sdf_cfg: SDFCfg | None = None - """SDF collision configuration. - - When set, mesh collision shapes matching the configured patterns will have - SDF built via Newton's ``mesh.build_sdf()`` at simulation start. This - also forces Newton's collision pipeline to be active (overriding - ``use_mujoco_contacts=True`` if necessary). - - See :class:`~isaaclab_newton.physics.newton_collision_cfg.SDFCfg` for - available parameters. - """ -``` - -- [ ] **Step 2: Update `__init__.pyi`** - -Add `SDFCfg` to `__all__` and the import from `newton_collision_cfg`: - -```python -__all__ = [ - "FeatherstoneSolverCfg", - "HydroelasticSDFCfg", - "MJWarpSolverCfg", - "NewtonCfg", - "NewtonCollisionPipelineCfg", - "NewtonManager", - "NewtonSolverCfg", - "SDFCfg", - "XPBDSolverCfg", -] - -from .newton_collision_cfg import HydroelasticSDFCfg, NewtonCollisionPipelineCfg, SDFCfg -from .newton_manager import NewtonManager -from .newton_manager_cfg import ( - FeatherstoneSolverCfg, - MJWarpSolverCfg, - NewtonCfg, - NewtonSolverCfg, - XPBDSolverCfg, -) -``` - -- [ ] **Step 3: Run pre-commit** - -```bash -./isaaclab.sh -f -``` - -- [ ] **Step 4: Commit** - -```bash -git add source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi -git commit -m "Wire SDFCfg into NewtonCfg and update exports" -``` - ---- - -### Task 3: Add SDF manager methods - -**Files:** -- Modify: `source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py` - -- [ ] **Step 1: Add `re` import** - -Add `import re` to the imports at the top of `newton_manager.py` (after `import logging`). - -- [ ] **Step 2: Add `_build_sdf_on_mesh` static method** - -Add this method to `NewtonManager`, after the `add_model_change` method (around line 392): - -```python - @staticmethod - def _build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, label: str): - """Build SDF on a mesh, resolving per-pattern resolution overrides. - - Args: - mesh: Newton mesh object to build SDF on. - sdf_cfg: The active :class:`SDFCfg` instance. - res_overrides: Compiled ``(pattern, resolution)`` pairs, or ``None``. - label: Shape label used for pattern resolution matching. - """ - if mesh is None: - return - if mesh.sdf is not None: - mesh.clear_sdf() - resolution = sdf_cfg.max_resolution - if res_overrides is not None: - for pat, res in res_overrides: - if pat.search(label): - resolution = res - break - sdf_kwargs: dict = dict(narrow_band_range=sdf_cfg.narrow_band_range) - if resolution is not None: - sdf_kwargs["max_resolution"] = resolution - if sdf_cfg.target_voxel_size is not None: - sdf_kwargs["target_voxel_size"] = sdf_cfg.target_voxel_size - mesh.build_sdf(**sdf_kwargs) -``` - -- [ ] **Step 3: Add `_create_sdf_collision_from_visual` classmethod** - -Add this method right after `_build_sdf_on_mesh`: - -```python - @classmethod - def _create_sdf_collision_from_visual( - cls, builder: ModelBuilder, sdf_shape_indices: set[int], sdf_cfg, res_overrides - ): - """Create collision shapes from visual meshes for matched bodies lacking collision geometry. - - Args: - builder: Newton model builder to modify. - sdf_shape_indices: Shape indices that matched SDF patterns. - sdf_cfg: The active :class:`SDFCfg` instance. - res_overrides: Compiled ``(pattern, resolution)`` pairs, or ``None``. - - Returns: - Tuple of ``(num_added, num_hydro)`` counts. - """ - from newton import ShapeFlags - - matched_bodies: set[int] = {builder.shape_body[si] for si in sdf_shape_indices} - bodies_with_collision: set[int] = set() - for si in range(builder.shape_count): - if builder.shape_flags[si] & ShapeFlags.COLLIDE_SHAPES and builder.shape_body[si] in matched_bodies: - bodies_with_collision.add(builder.shape_body[si]) - - shape_cfg_kwargs: dict = dict( - density=0.0, - has_shape_collision=True, - has_particle_collision=True, - is_visible=False, - ) - if sdf_cfg.margin is not None: - shape_cfg_kwargs["margin"] = sdf_cfg.margin - if sdf_cfg.k_hydro is not None: - shape_cfg_kwargs["is_hydroelastic"] = True - shape_cfg_kwargs["kh"] = sdf_cfg.k_hydro - sdf_shape_cfg = ModelBuilder.ShapeConfig(**shape_cfg_kwargs) - - num_added = 0 - num_hydro = 0 - for body_idx in matched_bodies - bodies_with_collision: - visual_si = None - for si in sdf_shape_indices: - if builder.shape_body[si] == body_idx and builder.shape_source[si] is not None: - visual_si = si - break - if visual_si is None: - body_lbl = builder.body_label[body_idx] - logger.warning(f"SDF: body '{body_lbl}' matched but has no visual mesh to create collision from.") - continue - - mesh = builder.shape_source[visual_si] - cls._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, builder.shape_label[visual_si]) - - body_lbl = builder.body_label[body_idx] - builder.add_shape_mesh( - body=body_idx, - xform=builder.shape_transform[visual_si], - mesh=mesh, - scale=builder.shape_scale[visual_si], - cfg=sdf_shape_cfg, - label=f"{body_lbl}/sdf_collision", - ) - num_added += 1 - if sdf_cfg.k_hydro is not None: - num_hydro += 1 - - return num_added, num_hydro -``` - -- [ ] **Step 4: Add `_apply_sdf_config` classmethod** - -Add this method right after `_create_sdf_collision_from_visual`: - -```python - @classmethod - def _apply_sdf_config(cls, builder: ModelBuilder): - """Apply SDF collision and optional hydroelastic flags to matching mesh shapes. - - Reads :attr:`SDFCfg` from the active physics config. Collects shapes - matching body/shape regex patterns, builds SDF on their meshes, and - optionally sets the ``HYDROELASTIC`` flag with :attr:`SDFCfg.k_hydro`. - - Args: - builder: Newton model builder to modify (before finalization). - """ - from newton import GeoType, ShapeFlags - - cfg = PhysicsManager._cfg - if cfg is None: - return - sdf_cfg = getattr(cfg, "sdf_cfg", None) - if sdf_cfg is None: - return - - if sdf_cfg.max_resolution is None and sdf_cfg.target_voxel_size is None: - logger.warning("SDFCfg provided but neither max_resolution nor target_voxel_size is set. SDF disabled.") - return - - # Compile patterns - body_patterns = [re.compile(p) for p in sdf_cfg.body_patterns] if sdf_cfg.body_patterns else None - shape_patterns = [re.compile(p) for p in sdf_cfg.shape_patterns] if sdf_cfg.shape_patterns else None - res_overrides = ( - [(re.compile(p), r) for p, r in sdf_cfg.pattern_resolutions.items()] - if sdf_cfg.pattern_resolutions - else None - ) - hydro_patterns = None - if sdf_cfg.k_hydro is not None and sdf_cfg.hydroelastic_shape_patterns is not None: - hydro_patterns = [re.compile(p) for p in sdf_cfg.hydroelastic_shape_patterns] - - if body_patterns is None and shape_patterns is None: - logger.warning("SDFCfg has no body_patterns or shape_patterns set. No shapes will receive SDF.") - return - - # Build reverse map: body_idx -> [mesh shape indices] - body_to_shapes: dict[int, list[int]] = {} - for si in range(builder.shape_count): - if builder.shape_type[si] == GeoType.MESH: - body_to_shapes.setdefault(builder.shape_body[si], []).append(si) - - sdf_shape_indices: set[int] = set() - - if body_patterns is not None: - for body_idx in range(len(builder.body_label)): - if any(p.search(builder.body_label[body_idx]) for p in body_patterns): - sdf_shape_indices.update(body_to_shapes.get(body_idx, [])) - - if shape_patterns is not None: - for shape_indices in body_to_shapes.values(): - for si in shape_indices: - if any(p.search(builder.shape_label[si]) for p in shape_patterns): - sdf_shape_indices.add(si) - - # Patch existing collision meshes - num_patched = 0 - num_hydro = 0 - for si in sdf_shape_indices: - if not (builder.shape_flags[si] & ShapeFlags.COLLIDE_SHAPES): - continue - cls._build_sdf_on_mesh(builder.shape_source[si], sdf_cfg, res_overrides, builder.shape_label[si]) - if sdf_cfg.margin is not None: - builder.shape_margin[si] = sdf_cfg.margin - if sdf_cfg.k_hydro is not None: - apply_hydro = hydro_patterns is None or any( - p.search(builder.shape_label[si]) for p in hydro_patterns - ) - if apply_hydro: - builder.shape_flags[si] |= ShapeFlags.HYDROELASTIC - builder.shape_material_kh[si] = sdf_cfg.k_hydro - num_hydro += 1 - num_patched += 1 - - # Optionally create collision shapes from visual meshes - num_added = 0 - if sdf_cfg.use_visual_meshes: - num_added, hydro_from_visual = cls._create_sdf_collision_from_visual( - builder, sdf_shape_indices, sdf_cfg, res_overrides - ) - num_hydro += hydro_from_visual - - hydro_msg = f", {num_hydro} hydroelastic shape(s)" if sdf_cfg.k_hydro is not None else "" - logger.info( - f"SDF config: {num_added} collision shape(s) added, {num_patched} existing shape(s) patched{hydro_msg}. " - f"(max_resolution={sdf_cfg.max_resolution}, narrow_band={sdf_cfg.narrow_band_range})" - ) -``` - -- [ ] **Step 5: Run pre-commit** - -```bash -./isaaclab.sh -f -``` - -- [ ] **Step 6: Commit** - -```bash -git add source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py -git commit -m "Add SDF manager methods for shape preparation" -``` - ---- - -### Task 4: Integrate SDF into manager lifecycle - -**Files:** -- Modify: `source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py` - -- [ ] **Step 1: Call `_apply_sdf_config` in `instantiate_builder_from_stage`** - -In `instantiate_builder_from_stage()`, add a call to `_apply_sdf_config` just before `cls.set_builder(builder)` (around line 527): - -```python - cls._apply_sdf_config(builder) - cls.set_builder(builder) -``` - -- [ ] **Step 2: Force collision pipeline in `initialize_solver` when SDF is configured** - -In `initialize_solver()`, after the existing `if isinstance(cls._solver, SolverMuJoCo):` / `else:` block that sets `cls._needs_collision_pipeline` (after line 618), add: - -```python - # Force Newton pipeline when collision_cfg or SDF is configured - if cfg.collision_cfg is not None and not cls._needs_collision_pipeline: - logger.warning("collision_cfg set — enabling Newton collision pipeline.") - cls._needs_collision_pipeline = True - - sdf_cfg = getattr(cfg, "sdf_cfg", None) - has_sdf = ( - sdf_cfg is not None - and (sdf_cfg.body_patterns is not None or sdf_cfg.shape_patterns is not None) - and (sdf_cfg.max_resolution is not None or sdf_cfg.target_voxel_size is not None) - ) - if has_sdf and not cls._needs_collision_pipeline: - logger.warning("SDF collision requires Newton collision pipeline. Overriding use_mujoco_contacts.") - cls._needs_collision_pipeline = True -``` - -- [ ] **Step 3: Add hydroelastic warning in `_initialize_contacts`** - -In `_initialize_contacts()`, after the pipeline is created (after the `cls._collision_pipeline = CollisionPipeline(...)` lines, around line 542), add a warning check: - -```python - # Warn if hydroelastic was requested but no shapes qualify - hydro_requested = ( - cls._collision_cfg is not None - and cls._collision_cfg.sdf_hydroelastic_config is not None - ) - if hydro_requested and cls._collision_pipeline.hydroelastic_sdf is None: - logger.warning( - "HydroelasticSDFCfg was set but no hydroelastic shape pairs found. " - "Ensure shapes have SDF built (via SDFCfg with k_hydro set) and that " - "both shapes in each contact pair have the HYDROELASTIC flag." - ) -``` - -This goes after the `else: cls._collision_pipeline = CollisionPipeline(...)` branch but before the `if cls._contacts is None:` line, so it applies regardless of whether `_collision_cfg` was set or not. The check should be at the same indent level as the `if cls._collision_cfg is not None:` block: - -```python - if cls._needs_collision_pipeline: - # Newton collision pipeline: create pipeline and generate contacts - if cls._collision_pipeline is None: - if cls._collision_cfg is not None: - cls._collision_pipeline = CollisionPipeline(cls._model, **cls._collision_cfg.to_pipeline_args()) - else: - cls._collision_pipeline = CollisionPipeline(cls._model, broad_phase="explicit") - - # Warn if hydroelastic was requested but no shapes qualify - hydro_requested = ( - cls._collision_cfg is not None - and cls._collision_cfg.sdf_hydroelastic_config is not None - ) - if hydro_requested and cls._collision_pipeline.hydroelastic_sdf is None: - logger.warning( - "HydroelasticSDFCfg was set but no hydroelastic shape pairs found. " - "Ensure shapes have SDF built (via SDFCfg with k_hydro set) and that " - "both shapes in each contact pair have the HYDROELASTIC flag." - ) - - if cls._contacts is None: - cls._contacts = cls._collision_pipeline.contacts() -``` - -- [ ] **Step 4: Run pre-commit** - -```bash -./isaaclab.sh -f -``` - -- [ ] **Step 5: Commit** - -```bash -git add source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py -git commit -m "Integrate SDF config into manager lifecycle" -``` - ---- - -### Task 5: Cloner integration - -**Files:** -- Modify: `source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py` - -- [ ] **Step 1: Add imports** - -Add `re` and `GeoType` imports at the top of `newton_replicate.py`: - -```python -import re -``` - -(after `from __future__ import annotations`) - -And update the newton import: - -```python -from newton import GeoType, ModelBuilder, solvers -``` - -And add the `PhysicsManager` import: - -```python -from isaaclab.physics import PhysicsManager -``` - -- [ ] **Step 2: Add SDF pattern skip and prototype SDF application** - -In `_build_newton_builder_from_mapping`, after the `env0_pos = positions[0]` line (line 64), add SDF pattern compilation: - -```python - # SDF collision requires original triangle meshes for mesh.build_sdf(). - # Convex hull approximation destroys the source geometry, so shapes - # matching SDF patterns must be excluded from approximation here. - # _apply_sdf_config() builds the SDF on each prototype after approximation. - cfg = PhysicsManager._cfg - sdf_cfg = getattr(cfg, "sdf_cfg", None) if cfg is not None else None - body_pats = [re.compile(x) for x in sdf_cfg.body_patterns] if sdf_cfg and sdf_cfg.body_patterns else None - shape_pats = [re.compile(x) for x in sdf_cfg.shape_patterns] if sdf_cfg and sdf_cfg.shape_patterns else None - has_sdf_patterns = body_pats is not None or shape_pats is not None -``` - -Then replace the existing `if simplify_meshes:` block (line 76-77): - -```python - if simplify_meshes: - p.approximate_meshes("convex_hull", keep_visual_shapes=True) - protos[src_path] = p -``` - -with: - -```python - if simplify_meshes: - if has_sdf_patterns: - sdf_bodies: set[int] = set() - if body_pats is not None: - for bi in range(len(p.body_label)): - if any(pat.search(p.body_label[bi]) for pat in body_pats): - sdf_bodies.add(bi) - - approx_indices = [] - for i in range(len(p.shape_type)): - if p.shape_type[i] != GeoType.MESH: - continue - # Skip shapes that will use SDF (matched by body or shape pattern) - if p.shape_body[i] in sdf_bodies: - continue - if shape_pats is not None: - lbl = p.shape_label[i] if i < len(p.shape_label) else "" - if any(pat.search(lbl) for pat in shape_pats): - continue - approx_indices.append(i) - if approx_indices: - p.approximate_meshes("convex_hull", shape_indices=approx_indices, keep_visual_shapes=True) - else: - p.approximate_meshes("convex_hull", keep_visual_shapes=True) - # Build SDF on prototype before add_builder copies it N times. - # Mesh objects are shared by reference, so SDF is built once and - # all environments inherit it. - NewtonManager._apply_sdf_config(p) - protos[src_path] = p -``` - -- [ ] **Step 3: Run pre-commit** - -```bash -./isaaclab.sh -f -``` - -- [ ] **Step 4: Commit** - -```bash -git add source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py -git commit -m "Skip convex hull for SDF shapes and apply SDF on cloner prototypes" -``` - ---- - -### Task 6: Tests - -**Files:** -- Create: `source/isaaclab_newton/test/physics/test_sdf_config.py` - -- [ ] **Step 1: Create test file** - -Create `source/isaaclab_newton/test/physics/test_sdf_config.py` with the following content. Tests use `unittest.mock` to avoid needing a running Newton simulation: - -```python -# Copyright (c) 2022-2026, 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 SDF collision configuration and application logic.""" - -import re -from unittest.mock import MagicMock, patch - -from newton import GeoType, ModelBuilder, ShapeFlags - - -class TestBuildSdfOnMesh: - """Tests for NewtonManager._build_sdf_on_mesh.""" - - @staticmethod - def _make_sdf_cfg(max_resolution=256, narrow_band_range=(-0.1, 0.1), target_voxel_size=None): - cfg = MagicMock() - cfg.max_resolution = max_resolution - cfg.narrow_band_range = narrow_band_range - cfg.target_voxel_size = target_voxel_size - return cfg - - def test_none_mesh_is_noop(self): - """Passing None as mesh should not raise.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - sdf_cfg = self._make_sdf_cfg() - NewtonManager._build_sdf_on_mesh(None, sdf_cfg, None, "test_label") - - def test_builds_sdf_with_max_resolution(self): - """SDF is built on mesh with max_resolution and narrow_band_range.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - mesh = MagicMock() - mesh.sdf = None - sdf_cfg = self._make_sdf_cfg(max_resolution=128) - - NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, None, "test_label") - - mesh.build_sdf.assert_called_once_with(narrow_band_range=(-0.1, 0.1), max_resolution=128) - - def test_clears_existing_sdf_before_rebuild(self): - """Existing SDF on mesh is cleared before building a new one.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - mesh = MagicMock() - mesh.sdf = "existing_sdf" - sdf_cfg = self._make_sdf_cfg() - - NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, None, "test_label") - - mesh.clear_sdf.assert_called_once() - mesh.build_sdf.assert_called_once() - - def test_target_voxel_size_passed_alongside_resolution(self): - """When target_voxel_size is set, it is passed alongside max_resolution.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - mesh = MagicMock() - mesh.sdf = None - sdf_cfg = self._make_sdf_cfg(max_resolution=256, target_voxel_size=0.005) - - NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, None, "test_label") - - call_kwargs = mesh.build_sdf.call_args[1] - assert call_kwargs["target_voxel_size"] == 0.005 - assert call_kwargs["max_resolution"] == 256 - - def test_resolution_override_by_pattern(self): - """Per-pattern resolution override is applied when label matches.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - mesh = MagicMock() - mesh.sdf = None - sdf_cfg = self._make_sdf_cfg(max_resolution=256) - res_overrides = [(re.compile(".*elbow.*"), 128)] - - NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, "/World/Robot/elbow_link/collision") - - call_kwargs = mesh.build_sdf.call_args[1] - assert call_kwargs["max_resolution"] == 128 - - def test_resolution_override_no_match_uses_global(self): - """When label doesn't match any override, global max_resolution is used.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - mesh = MagicMock() - mesh.sdf = None - sdf_cfg = self._make_sdf_cfg(max_resolution=256) - res_overrides = [(re.compile(".*elbow.*"), 128)] - - NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, "/World/Robot/wrist_link/collision") - - call_kwargs = mesh.build_sdf.call_args[1] - assert call_kwargs["max_resolution"] == 256 - - def test_resolution_override_first_match_wins(self): - """First matching pattern in res_overrides determines resolution.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - mesh = MagicMock() - mesh.sdf = None - sdf_cfg = self._make_sdf_cfg(max_resolution=256) - res_overrides = [ - (re.compile(".*link.*"), 64), - (re.compile(".*elbow.*"), 128), - ] - - NewtonManager._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, "/World/Robot/elbow_link/collision") - - call_kwargs = mesh.build_sdf.call_args[1] - assert call_kwargs["max_resolution"] == 64 # ".*link.*" matches first - - -class TestApplySdfConfig: - """Tests for NewtonManager._apply_sdf_config shape index collection and patching.""" - - @staticmethod - def _make_builder(bodies, shapes): - """Create a minimal ModelBuilder-like mock. - - Args: - bodies: List of body label strings. - shapes: List of dicts with keys: body_idx, label, geo_type, flags, source. - """ - builder = MagicMock(spec=ModelBuilder) - builder.body_label = bodies - builder.shape_count = len(shapes) - builder.shape_type = [s["geo_type"] for s in shapes] - builder.shape_body = [s["body_idx"] for s in shapes] - builder.shape_label = [s["label"] for s in shapes] - builder.shape_flags = [s["flags"] for s in shapes] - builder.shape_source = [s.get("source") for s in shapes] - builder.shape_margin = [0.0] * len(shapes) - builder.shape_material_kh = [0.0] * len(shapes) - return builder - - @staticmethod - def _make_cfg( - body_patterns=None, - shape_patterns=None, - max_resolution=256, - k_hydro=None, - hydroelastic_shape_patterns=None, - ): - cfg = MagicMock() - cfg.sdf_cfg = MagicMock() - cfg.sdf_cfg.max_resolution = max_resolution - cfg.sdf_cfg.target_voxel_size = None - cfg.sdf_cfg.narrow_band_range = (-0.1, 0.1) - cfg.sdf_cfg.margin = None - cfg.sdf_cfg.body_patterns = body_patterns - cfg.sdf_cfg.shape_patterns = shape_patterns - cfg.sdf_cfg.pattern_resolutions = None - cfg.sdf_cfg.use_visual_meshes = False - cfg.sdf_cfg.k_hydro = k_hydro - cfg.sdf_cfg.hydroelastic_shape_patterns = hydroelastic_shape_patterns - return cfg - - def test_no_sdf_cfg_is_noop(self): - """_apply_sdf_config returns early when sdf_cfg is None.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - builder = MagicMock(spec=ModelBuilder) - with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: - pm._cfg = MagicMock() - pm._cfg.sdf_cfg = None - NewtonManager._apply_sdf_config(builder) - # No crash, no calls - assert not builder.method_calls - - def test_no_patterns_warns(self): - """_apply_sdf_config warns when no patterns are set.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - builder = MagicMock(spec=ModelBuilder) - cfg = self._make_cfg(body_patterns=None, shape_patterns=None) - with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: - pm._cfg = cfg - with patch("isaaclab_newton.physics.newton_manager.logger") as mock_logger: - NewtonManager._apply_sdf_config(builder) - mock_logger.warning.assert_called() - - def test_body_pattern_collects_shapes(self): - """Shapes under matching bodies are collected for SDF.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - bodies = ["/World/Robot/elbow", "/World/Robot/wrist"] - shapes = [ - {"body_idx": 0, "label": "/World/Robot/elbow/col", "geo_type": GeoType.MESH, - "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, - {"body_idx": 1, "label": "/World/Robot/wrist/col", "geo_type": GeoType.MESH, - "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, - ] - builder = self._make_builder(bodies, shapes) - cfg = self._make_cfg(body_patterns=[".*elbow.*"]) - - with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: - pm._cfg = cfg - NewtonManager._apply_sdf_config(builder) - - # Only elbow shape should have build_sdf called - shapes[0]["source"].build_sdf.assert_called_once() - shapes[1]["source"].build_sdf.assert_not_called() - - def test_hydroelastic_flag_set_when_k_hydro(self): - """HYDROELASTIC flag is set on matched shapes when k_hydro is provided.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - bodies = ["/World/Robot/elbow"] - shapes = [ - {"body_idx": 0, "label": "/World/Robot/elbow/col", "geo_type": GeoType.MESH, - "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, - ] - builder = self._make_builder(bodies, shapes) - cfg = self._make_cfg(body_patterns=[".*elbow.*"], k_hydro=1e10) - - with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: - pm._cfg = cfg - NewtonManager._apply_sdf_config(builder) - - assert builder.shape_flags[0] & ShapeFlags.HYDROELASTIC - assert builder.shape_material_kh[0] == 1e10 - - def test_hydroelastic_shape_patterns_filter(self): - """hydroelastic_shape_patterns limits which shapes get HYDROELASTIC flag.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - bodies = ["/World/Robot/elbow", "/World/Robot/wrist"] - shapes = [ - {"body_idx": 0, "label": "/World/Robot/elbow/col", "geo_type": GeoType.MESH, - "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, - {"body_idx": 1, "label": "/World/Robot/wrist/col", "geo_type": GeoType.MESH, - "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, - ] - builder = self._make_builder(bodies, shapes) - cfg = self._make_cfg( - body_patterns=[".*"], - k_hydro=1e10, - hydroelastic_shape_patterns=[".*elbow.*"], - ) - - with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: - pm._cfg = cfg - NewtonManager._apply_sdf_config(builder) - - # Both get SDF built - shapes[0]["source"].build_sdf.assert_called_once() - shapes[1]["source"].build_sdf.assert_called_once() - # Only elbow gets hydroelastic - assert builder.shape_flags[0] & ShapeFlags.HYDROELASTIC - assert not (builder.shape_flags[1] & ShapeFlags.HYDROELASTIC) - - def test_shape_pattern_matching(self): - """shape_patterns directly matches shape labels.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - bodies = ["/World/Robot/body"] - shapes = [ - {"body_idx": 0, "label": "/World/Robot/body/Gear_col", "geo_type": GeoType.MESH, - "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, - {"body_idx": 0, "label": "/World/Robot/body/frame_col", "geo_type": GeoType.MESH, - "flags": ShapeFlags.COLLIDE_SHAPES, "source": MagicMock(sdf=None)}, - ] - builder = self._make_builder(bodies, shapes) - cfg = self._make_cfg(shape_patterns=[".*Gear.*"]) - - with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: - pm._cfg = cfg - NewtonManager._apply_sdf_config(builder) - - shapes[0]["source"].build_sdf.assert_called_once() - shapes[1]["source"].build_sdf.assert_not_called() - - def test_non_mesh_shapes_skipped(self): - """Non-mesh shapes are never collected for SDF.""" - from isaaclab_newton.physics.newton_manager import NewtonManager - - bodies = ["/World/Robot/elbow"] - shapes = [ - {"body_idx": 0, "label": "/World/Robot/elbow/box", "geo_type": GeoType.BOX, - "flags": ShapeFlags.COLLIDE_SHAPES, "source": None}, - ] - builder = self._make_builder(bodies, shapes) - cfg = self._make_cfg(body_patterns=[".*elbow.*"]) - - with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: - pm._cfg = cfg - NewtonManager._apply_sdf_config(builder) - # No build_sdf calls (box shape has no source to call on) -``` - -- [ ] **Step 2: Run tests** - -```bash -./isaaclab.sh -p -m pytest source/isaaclab_newton/test/physics/test_sdf_config.py -v -``` - -Expected: All tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add source/isaaclab_newton/test/physics/test_sdf_config.py -git commit -m "Add tests for SDF config and shape preparation" -``` - ---- - -### Task 7: Changelog and version bump - -**Files:** -- Modify: `source/isaaclab_newton/docs/CHANGELOG.rst` -- Modify: `source/isaaclab_newton/config/extension.toml` - -- [ ] **Step 1: Bump version to 0.5.12** - -In `source/isaaclab_newton/config/extension.toml`, change: -``` -version = "0.5.11" -``` -to: -``` -version = "0.5.12" -``` - -- [ ] **Step 2: Add new changelog version** - -In `source/isaaclab_newton/docs/CHANGELOG.rst`, add a new version heading before `0.5.11`: - -```rst -0.5.12 (2026-04-10) -~~~~~~~~~~~~~~~~~~~ - -Added -^^^^^ - -* Added :class:`~isaaclab_newton.physics.SDFCfg` for configuring SDF-based mesh - collisions via Newton's ``mesh.build_sdf()`` API. Supports per-body and per-shape - regex pattern matching, per-pattern resolution overrides, and optional creation of - collision shapes from visual meshes. -* Added hydroelastic shape enablement fields - (:attr:`~isaaclab_newton.physics.SDFCfg.k_hydro`, - :attr:`~isaaclab_newton.physics.SDFCfg.hydroelastic_shape_patterns`) on - :class:`~isaaclab_newton.physics.SDFCfg`. -* Added missing hydroelastic pipeline parameters to - :class:`~isaaclab_newton.physics.HydroelasticSDFCfg`: ``moment_matching``, - ``buffer_mult_broad``, ``buffer_mult_iso``, ``buffer_mult_contact``, ``grid_size``. -* Added SDF pattern skip in the Newton cloner to preserve original triangle - meshes for shapes that will use SDF collision. - - -``` - -- [ ] **Step 3: Run pre-commit** - -```bash -./isaaclab.sh -f -``` - -- [ ] **Step 4: Commit** - -```bash -git add source/isaaclab_newton/docs/CHANGELOG.rst source/isaaclab_newton/config/extension.toml -git commit -m "Add SDF changelog entries and bump to 0.5.12" -``` - ---- - -### Task 8: Final validation - -- [ ] **Step 1: Run all tests** - -```bash -./isaaclab.sh -p -m pytest source/isaaclab_newton/test/physics/test_sdf_config.py -v -``` - -Expected: All tests pass. - -- [ ] **Step 2: Run pre-commit on all files** - -```bash -./isaaclab.sh -f -``` - -Expected: All checks pass. - -- [ ] **Step 3: Verify diff is clean** - -```bash -git diff -git status -``` - -Expected: No unstaged changes. diff --git a/docs/superpowers/specs/2026-04-10-newton-sdf-hydroelastic-config-design.md b/docs/superpowers/specs/2026-04-10-newton-sdf-hydroelastic-config-design.md deleted file mode 100644 index 7be30b84f909..000000000000 --- a/docs/superpowers/specs/2026-04-10-newton-sdf-hydroelastic-config-design.md +++ /dev/null @@ -1,156 +0,0 @@ -# Newton SDF & Hydroelastic Configuration - -**Date:** 2026-04-10 -**Built on:** PR #5219 (`expose_newton_collision_pipeline`) -**Ports from:** PR #5160 (`vidur/feature/sdf-collision`) -**Target:** New PR → `develop` - -## Summary - -Port SDF collision and hydroelastic shape preparation from PR #5160 into -PR #5219's config design. #5219 owns the collision pipeline config layer; -this PR adds the shape-preparation machinery that makes hydroelastic -contacts actually work end-to-end. - -## Design Decisions - -**Keep #5219's config hierarchy.** `HydroelasticSDFCfg` (pipeline processing -params) stays nested under `NewtonCollisionPipelineCfg.sdf_hydroelastic_config`. - -**Add `SDFCfg` as a new top-level field on `NewtonCfg`.** Shape-level concerns -(which meshes get SDF, resolution, hydroelastic flag enablement) live here, -separate from pipeline configuration. - -**Flatten hydroelastic shape enablement into `SDFCfg`.** #5160 used a nested -`HydroelasticCfg` object. We put `k_hydro` and `hydroelastic_shape_patterns` -directly on `SDFCfg` since they're simple fields that control shape flags, -not a separate subsystem. - -## Config Hierarchy - -``` -NewtonCfg -├── solver_cfg: NewtonSolverCfg -├── collision_cfg: NewtonCollisionPipelineCfg | None -│ ├── broad_phase, reduce_contacts, rigid_contact_max, ... -│ └── sdf_hydroelastic_config: HydroelasticSDFCfg | None -│ ├── reduce_contacts, normal_matching, anchor_contact -│ ├── moment_matching, margin_contact_area -│ ├── buffer_fraction, buffer_mult_broad/iso/contact -│ ├── grid_size, output_contact_surface -│ └── (maps 1:1 to HydroelasticSDF.Config via to_pipeline_args()) -└── sdf_cfg: SDFCfg | None - ├── max_resolution: int | None - ├── target_voxel_size: float | None - ├── narrow_band_range: tuple[float, float] - ├── margin: float | None - ├── body_patterns: list[str] | None - ├── shape_patterns: list[str] | None - ├── pattern_resolutions: dict[str, int] | None - ├── use_visual_meshes: bool - ├── k_hydro: float (shape-level stiffness) - └── hydroelastic_shape_patterns: list[str] | None -``` - -## Components - -### 1. Config additions (`newton_collision_cfg.py`) - -**`SDFCfg`** — new configclass in the existing file. - -Fields ported from #5160's `SDFCfg`: -- `max_resolution`, `target_voxel_size`, `narrow_band_range`, `margin` -- `body_patterns`, `shape_patterns`, `pattern_resolutions` -- `use_visual_meshes` - -Shape-level hydroelastic fields (flattened from #5160's `HydroelasticCfg`): -- `k_hydro: float = 1e10` — stiffness applied to shapes via `shape_material_kh` -- `hydroelastic_shape_patterns: list[str] | None = None` — if None, all - SDF shapes get HYDROELASTIC flag; if set, only matching shapes - -**`HydroelasticSDFCfg`** — add missing pipeline params from #5160: -- `moment_matching: bool = False` -- `buffer_mult_broad: int = 1` -- `buffer_mult_iso: int = 1` -- `buffer_mult_contact: int = 1` -- `grid_size: int = 256 * 8 * 128` - -**`NewtonCfg`** — add `sdf_cfg: SDFCfg | None = None` in `newton_manager_cfg.py`. - -### 2. Manager methods (`newton_manager.py`) - -**`_build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, label)`** — static method. -Builds SDF on a mesh. Clears existing SDF first. Applies per-pattern -resolution overrides. Passes `narrow_band_range`, `max_resolution`, -`target_voxel_size` to `mesh.build_sdf()`. - -**`_apply_sdf_config(builder)`** — classmethod. -1. Read `sdf_cfg` from `PhysicsManager._cfg`; return early if None. -2. Validate that at least one of `max_resolution`/`target_voxel_size` is set. -3. Compile body/shape/hydroelastic regex patterns. -4. Collect matching shape indices from builder. -5. For each matching collision shape: build SDF, optionally set HYDROELASTIC - flag + `k_hydro`. -6. If `use_visual_meshes`, call `_create_sdf_collision_from_visual()`. -7. Log summary. - -**`_create_sdf_collision_from_visual(builder, sdf_shape_indices, sdf_cfg, res_overrides)`** -— classmethod. For matched bodies that lack collision geometry, creates a -collision shape from the first visual mesh with SDF built on it. - -**`initialize_solver()` changes:** -- After determining `_needs_collision_pipeline`, force it `True` when - `collision_cfg is not None` or when `sdf_cfg` has valid patterns + resolution. -- Log a warning when overriding. - -**`_initialize_contacts()` changes:** -- After creating pipeline, if hydroelastic was configured - (`collision_cfg.sdf_hydroelastic_config is not None`) but - `pipeline.hydroelastic_sdf is None`, log a warning. - -**`instantiate_builder_from_stage()` change:** -- Call `cls._apply_sdf_config(builder)` before `cls.set_builder(builder)`. - -### 3. Cloner integration (`newton_replicate.py`) - -In `_build_newton_builder_from_mapping()`: -1. Read `sdf_cfg` from `PhysicsManager._cfg`. -2. Compile body/shape patterns if present. -3. When `simplify_meshes` is True and SDF patterns exist, skip convex hull - approximation for shapes matching SDF patterns (preserves triangle meshes). -4. After prototype building, call `NewtonManager._apply_sdf_config(prototype)` - on each prototype before `add_builder` replication. - -### 4. Exports (`__init__.pyi`) - -Add `SDFCfg` to `__all__` and import list. - -### 5. Tests (`test_sdf_config.py`) - -Port from #5160, adapted for new config structure: -- `TestBuildSdfOnMesh` — None mesh, max_resolution, clear existing SDF, - target_voxel_size precedence, pattern resolution overrides -- `TestApplySdfConfig` — shape index collection by body/shape patterns, - hydroelastic flag setting, visual mesh fallback, edge cases - -### 6. Changelog - -Add to existing `0.5.11` entry (or bump to `0.5.12`): -- Added `SDFCfg` for SDF mesh collision configuration -- Added SDF pattern skip in Newton cloner -- Added missing hydroelastic pipeline params to `HydroelasticSDFCfg` - -## Execution Order - -1. Config additions (SDFCfg, HydroelasticSDFCfg fields, NewtonCfg.sdf_cfg) -2. Manager methods (_build_sdf_on_mesh, _apply_sdf_config, _create_sdf_collision_from_visual) -3. Manager integration (initialize_solver, _initialize_contacts, instantiate_builder_from_stage) -4. Cloner integration (newton_replicate.py) -5. Exports (__init__.pyi) -6. Tests -7. Changelog + version bump - -## Out of Scope - -- Visualizer additions from #5160 (newton_visualizer.py changes) -- Runtime SDF rebuild / dynamic pattern updates From 9d630a51bef325d90d720ea7ade966a30f25fd4d Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 19:00:07 +0200 Subject: [PATCH 14/20] Fix docstring/code contradiction and remove dead code Fix max_resolution/target_voxel_size docstrings to accurately describe that both are forwarded to Newton. Remove unreachable collision_cfg force-override block in initialize_solver. --- .../isaaclab_newton/physics/newton_collision_cfg.py | 9 ++++++--- .../isaaclab_newton/physics/newton_manager.py | 6 +----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py index dd502aad7204..35a0094911ef 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py @@ -241,8 +241,10 @@ class SDFCfg: max_resolution: int | None = None """Maximum voxel dimension for the SDF grid. - Must be divisible by 8. Typical values: 128, 256, 512. - Ignored when :attr:`target_voxel_size` is set. + Must be divisible by 8. Typical values: 128, 256, 512. When both this + and :attr:`target_voxel_size` are set, both are forwarded to Newton's + ``mesh.build_sdf()``; Newton uses :attr:`target_voxel_size` to derive + the actual resolution. Defaults to ``None``. """ @@ -250,7 +252,8 @@ class SDFCfg: target_voxel_size: float | None = None """Target voxel size [m] for the SDF grid. - When set, takes precedence over :attr:`max_resolution`. + When both this and :attr:`max_resolution` are set, both are forwarded to + Newton and this value is used to derive the actual resolution. Defaults to ``None``. """ diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index d70c314bfc74..80d2bff72ea3 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -814,11 +814,7 @@ def initialize_solver(cls) -> None: else: cls._needs_collision_pipeline = True - # Force Newton pipeline when collision_cfg or SDF is configured - if cfg.collision_cfg is not None and not cls._needs_collision_pipeline: - logger.warning("collision_cfg set — enabling Newton collision pipeline.") - cls._needs_collision_pipeline = True - + # Force Newton pipeline when SDF is configured sdf_cfg = getattr(cfg, "sdf_cfg", None) has_sdf = ( sdf_cfg is not None From 87af9c5cfea73aac11d63e34387f821ac4e06dbe Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 19:03:21 +0200 Subject: [PATCH 15/20] Address important review findings - Pass hydro_patterns to _create_sdf_collision_from_visual so it respects hydroelastic_shape_patterns filter (was unconditionally setting HYDROELASTIC) - Replace getattr(cfg, "sdf_cfg", None) with direct attribute access in all 3 locations - Add regex validation with actionable ValueError on invalid patterns - Log warning and return False from _build_sdf_on_mesh when mesh is None; only count successful SDF builds in num_patched - Fix :attr: -> :class: for SDFCfg reference in docstring --- .../cloner/newton_replicate.py | 17 +++- .../isaaclab_newton/physics/newton_manager.py | 93 ++++++++++++------- 2 files changed, 74 insertions(+), 36 deletions(-) diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py index 69f255587cf9..0e166eb5afdd 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py @@ -21,6 +21,17 @@ from isaaclab_newton.physics import NewtonManager +def _compile_sdf_patterns(patterns: list[str]) -> list[re.Pattern]: + """Compile regex patterns with validation, raising on invalid regex.""" + compiled = [] + for i, p in enumerate(patterns): + try: + compiled.append(re.compile(p)) + except re.error as e: + raise ValueError(f"Invalid regex in SDFCfg pattern[{i}]: {p!r} — {e}") from e + return compiled + + def _build_newton_builder_from_mapping( stage: Usd.Stage, sources: list[str], @@ -70,9 +81,9 @@ def _build_newton_builder_from_mapping( # matching SDF patterns must be excluded from approximation here. # _apply_sdf_config() builds the SDF on each prototype after approximation. cfg = PhysicsManager._cfg - sdf_cfg = getattr(cfg, "sdf_cfg", None) if cfg is not None else None - body_pats = [re.compile(x) for x in sdf_cfg.body_patterns] if sdf_cfg and sdf_cfg.body_patterns else None - shape_pats = [re.compile(x) for x in sdf_cfg.shape_patterns] if sdf_cfg and sdf_cfg.shape_patterns else None + sdf_cfg = cfg.sdf_cfg if cfg is not None else None # type: ignore[union-attr] + body_pats = _compile_sdf_patterns(sdf_cfg.body_patterns) if sdf_cfg and sdf_cfg.body_patterns else None + shape_pats = _compile_sdf_patterns(sdf_cfg.shape_patterns) if sdf_cfg and sdf_cfg.shape_patterns else None has_sdf_patterns = body_pats is not None or shape_pats is not None protos: dict[str, ModelBuilder] = {} diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 80d2bff72ea3..de4d8d52eedf 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -393,7 +393,7 @@ def add_model_change(cls, change: SolverNotifyFlags) -> None: cls._model_changes.add(change) @staticmethod - def _build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, label: str): + def _build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, label: str) -> bool: """Build SDF on a mesh, resolving per-pattern resolution overrides. Args: @@ -401,9 +401,13 @@ def _build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, label: str): sdf_cfg: The active :class:`SDFCfg` instance. res_overrides: Compiled ``(pattern, resolution)`` pairs, or ``None``. label: Shape label used for pattern resolution matching. + + Returns: + ``True`` if SDF was built, ``False`` if skipped (no mesh source). """ if mesh is None: - return + logger.warning(f"SDF: shape '{label}' matched but has no mesh source. Skipping SDF build.") + return False if mesh.sdf is not None: mesh.clear_sdf() resolution = sdf_cfg.max_resolution @@ -418,10 +422,11 @@ def _build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, label: str): if sdf_cfg.target_voxel_size is not None: sdf_kwargs["target_voxel_size"] = sdf_cfg.target_voxel_size mesh.build_sdf(**sdf_kwargs) + return True @classmethod def _create_sdf_collision_from_visual( - cls, builder: ModelBuilder, sdf_shape_indices: set[int], sdf_cfg, res_overrides + cls, builder: ModelBuilder, sdf_shape_indices: set[int], sdf_cfg, res_overrides, hydro_patterns=None ): """Create collision shapes from visual meshes for matched bodies lacking collision geometry. @@ -430,6 +435,8 @@ def _create_sdf_collision_from_visual( sdf_shape_indices: Shape indices that matched SDF patterns. sdf_cfg: The active :class:`SDFCfg` instance. res_overrides: Compiled ``(pattern, resolution)`` pairs, or ``None``. + hydro_patterns: Compiled hydroelastic shape patterns, or ``None`` + (meaning all shapes get hydroelastic if ``k_hydro`` is set). Returns: Tuple of ``(num_added, num_hydro)`` counts. @@ -442,19 +449,6 @@ def _create_sdf_collision_from_visual( if builder.shape_flags[si] & ShapeFlags.COLLIDE_SHAPES and builder.shape_body[si] in matched_bodies: bodies_with_collision.add(builder.shape_body[si]) - shape_cfg_kwargs: dict = dict( - density=0.0, - has_shape_collision=True, - has_particle_collision=True, - is_visible=False, - ) - if sdf_cfg.margin is not None: - shape_cfg_kwargs["margin"] = sdf_cfg.margin - if sdf_cfg.k_hydro is not None: - shape_cfg_kwargs["is_hydroelastic"] = True - shape_cfg_kwargs["kh"] = sdf_cfg.k_hydro - sdf_shape_cfg = ModelBuilder.ShapeConfig(**shape_cfg_kwargs) - num_added = 0 num_hydro = 0 for body_idx in matched_bodies - bodies_with_collision: @@ -471,17 +465,35 @@ def _create_sdf_collision_from_visual( mesh = builder.shape_source[visual_si] cls._build_sdf_on_mesh(mesh, sdf_cfg, res_overrides, builder.shape_label[visual_si]) + # Determine hydroelastic for this shape (respecting pattern filter) + shape_lbl = builder.shape_label[visual_si] + enable_hydro = False + if sdf_cfg.k_hydro is not None: + enable_hydro = hydro_patterns is None or any(p.search(shape_lbl) for p in hydro_patterns) + + shape_cfg_kwargs: dict = dict( + density=0.0, + has_shape_collision=True, + has_particle_collision=True, + is_visible=False, + ) + if sdf_cfg.margin is not None: + shape_cfg_kwargs["margin"] = sdf_cfg.margin + if enable_hydro: + shape_cfg_kwargs["is_hydroelastic"] = True + shape_cfg_kwargs["kh"] = sdf_cfg.k_hydro + body_lbl = builder.body_label[body_idx] builder.add_shape_mesh( body=body_idx, xform=builder.shape_transform[visual_si], mesh=mesh, scale=builder.shape_scale[visual_si], - cfg=sdf_shape_cfg, + cfg=ModelBuilder.ShapeConfig(**shape_cfg_kwargs), label=f"{body_lbl}/sdf_collision", ) num_added += 1 - if sdf_cfg.k_hydro is not None: + if enable_hydro: num_hydro += 1 return num_added, num_hydro @@ -490,7 +502,7 @@ def _create_sdf_collision_from_visual( def _apply_sdf_config(cls, builder: ModelBuilder): """Apply SDF collision and optional hydroelastic flags to matching mesh shapes. - Reads :attr:`SDFCfg` from the active physics config. Collects shapes + Reads :class:`SDFCfg` from the active physics config. Collects shapes matching body/shape regex patterns, builds SDF on their meshes, and optionally sets the ``HYDROELASTIC`` flag with :attr:`SDFCfg.k_hydro`. @@ -502,7 +514,7 @@ def _apply_sdf_config(cls, builder: ModelBuilder): cfg = PhysicsManager._cfg if cfg is None: return - sdf_cfg = getattr(cfg, "sdf_cfg", None) + sdf_cfg = cfg.sdf_cfg # type: ignore[union-attr] if sdf_cfg is None: return @@ -510,17 +522,31 @@ def _apply_sdf_config(cls, builder: ModelBuilder): logger.warning("SDFCfg provided but neither max_resolution nor target_voxel_size is set. SDF disabled.") return - # Compile patterns - body_patterns = [re.compile(p) for p in sdf_cfg.body_patterns] if sdf_cfg.body_patterns else None - shape_patterns = [re.compile(p) for p in sdf_cfg.shape_patterns] if sdf_cfg.shape_patterns else None - res_overrides = ( - [(re.compile(p), r) for p, r in sdf_cfg.pattern_resolutions.items()] - if sdf_cfg.pattern_resolutions - else None - ) + # Compile patterns (with validation) + def _compile(patterns: list[str] | None, field: str) -> list[re.Pattern] | None: + if not patterns: + return None + compiled = [] + for i, p in enumerate(patterns): + try: + compiled.append(re.compile(p)) + except re.error as e: + raise ValueError(f"Invalid regex in SDFCfg.{field}[{i}]: {p!r} — {e}") from e + return compiled + + body_patterns = _compile(sdf_cfg.body_patterns, "body_patterns") + shape_patterns = _compile(sdf_cfg.shape_patterns, "shape_patterns") + res_overrides = None + if sdf_cfg.pattern_resolutions: + res_overrides = [] + for p, r in sdf_cfg.pattern_resolutions.items(): + try: + res_overrides.append((re.compile(p), r)) + except re.error as e: + raise ValueError(f"Invalid regex in SDFCfg.pattern_resolutions key {p!r} — {e}") from e hydro_patterns = None - if sdf_cfg.k_hydro is not None and sdf_cfg.hydroelastic_shape_patterns is not None: - hydro_patterns = [re.compile(p) for p in sdf_cfg.hydroelastic_shape_patterns] + if sdf_cfg.k_hydro is not None: + hydro_patterns = _compile(sdf_cfg.hydroelastic_shape_patterns, "hydroelastic_shape_patterns") if body_patterns is None and shape_patterns is None: logger.warning("SDFCfg has no body_patterns or shape_patterns set. No shapes will receive SDF.") @@ -551,7 +577,8 @@ def _apply_sdf_config(cls, builder: ModelBuilder): for si in sdf_shape_indices: if not (builder.shape_flags[si] & ShapeFlags.COLLIDE_SHAPES): continue - cls._build_sdf_on_mesh(builder.shape_source[si], sdf_cfg, res_overrides, builder.shape_label[si]) + if not cls._build_sdf_on_mesh(builder.shape_source[si], sdf_cfg, res_overrides, builder.shape_label[si]): + continue if sdf_cfg.margin is not None: builder.shape_margin[si] = sdf_cfg.margin if sdf_cfg.k_hydro is not None: @@ -566,7 +593,7 @@ def _apply_sdf_config(cls, builder: ModelBuilder): num_added = 0 if sdf_cfg.use_visual_meshes: num_added, hydro_from_visual = cls._create_sdf_collision_from_visual( - builder, sdf_shape_indices, sdf_cfg, res_overrides + builder, sdf_shape_indices, sdf_cfg, res_overrides, hydro_patterns ) num_hydro += hydro_from_visual @@ -815,7 +842,7 @@ def initialize_solver(cls) -> None: cls._needs_collision_pipeline = True # Force Newton pipeline when SDF is configured - sdf_cfg = getattr(cfg, "sdf_cfg", None) + sdf_cfg = cfg.sdf_cfg # type: ignore[union-attr] has_sdf = ( sdf_cfg is not None and (sdf_cfg.body_patterns is not None or sdf_cfg.shape_patterns is not None) From 1d09ba155193bb9ec5ab9fc89e34a06863f75d3f Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Fri, 10 Apr 2026 19:04:59 +0200 Subject: [PATCH 16/20] Add tests for _create_sdf_collision_from_visual --- .../test/physics/test_sdf_config.py | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/source/isaaclab_newton/test/physics/test_sdf_config.py b/source/isaaclab_newton/test/physics/test_sdf_config.py index a58b3e2c6d31..b2ed547753e8 100644 --- a/source/isaaclab_newton/test/physics/test_sdf_config.py +++ b/source/isaaclab_newton/test/physics/test_sdf_config.py @@ -325,3 +325,163 @@ def test_non_mesh_shapes_skipped(self): with patch("isaaclab_newton.physics.newton_manager.PhysicsManager") as pm: pm._cfg = cfg NewtonManager._apply_sdf_config(builder) + + +class TestCreateSdfCollisionFromVisual: + """Tests for NewtonManager._create_sdf_collision_from_visual.""" + + @staticmethod + def _make_builder(bodies, shapes): + """Create a minimal ModelBuilder-like mock.""" + builder = MagicMock(spec=ModelBuilder) + builder.body_label = bodies + builder.shape_count = len(shapes) + builder.shape_type = [s["geo_type"] for s in shapes] + builder.shape_body = [s["body_idx"] for s in shapes] + builder.shape_label = [s["label"] for s in shapes] + builder.shape_flags = [s["flags"] for s in shapes] + builder.shape_source = [s.get("source") for s in shapes] + builder.shape_transform = [s.get("xform", (0, 0, 0, 0, 0, 0, 1)) for s in shapes] + builder.shape_scale = [s.get("scale", (1, 1, 1)) for s in shapes] + builder.shape_margin = [0.0] * len(shapes) + builder.shape_material_kh = [0.0] * len(shapes) + return builder + + @staticmethod + def _make_sdf_cfg(k_hydro=None, margin=None): + cfg = MagicMock() + cfg.max_resolution = 256 + cfg.target_voxel_size = None + cfg.narrow_band_range = (-0.1, 0.1) + cfg.margin = margin + cfg.k_hydro = k_hydro + return cfg + + def test_creates_collision_from_visual_mesh(self): + """A body with only a visual mesh (no COLLIDE_SHAPES) gets a new collision shape.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/arm"] + mesh = MagicMock(sdf=None) + shapes = [ + { + "body_idx": 0, + "label": "/World/Robot/arm/visual", + "geo_type": GeoType.MESH, + "flags": 0, + "source": mesh, + }, + ] + builder = self._make_builder(bodies, shapes) + sdf_cfg = self._make_sdf_cfg() + + num_added, num_hydro = NewtonManager._create_sdf_collision_from_visual(builder, {0}, sdf_cfg, None) + + assert num_added == 1 + assert num_hydro == 0 + builder.add_shape_mesh.assert_called_once() + mesh.build_sdf.assert_called_once() + + def test_skips_body_with_existing_collision(self): + """A body that already has a COLLIDE_SHAPES shape is not given a visual-mesh collision.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/arm"] + shapes = [ + { + "body_idx": 0, + "label": "/World/Robot/arm/collision", + "geo_type": GeoType.MESH, + "flags": ShapeFlags.COLLIDE_SHAPES, + "source": MagicMock(sdf=None), + }, + ] + builder = self._make_builder(bodies, shapes) + sdf_cfg = self._make_sdf_cfg() + + num_added, _ = NewtonManager._create_sdf_collision_from_visual(builder, {0}, sdf_cfg, None) + + assert num_added == 0 + builder.add_shape_mesh.assert_not_called() + + def test_warns_when_no_visual_mesh_source(self): + """A body with no mesh source logs a warning and is skipped.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/arm"] + shapes = [ + { + "body_idx": 0, + "label": "/World/Robot/arm/visual", + "geo_type": GeoType.MESH, + "flags": 0, + "source": None, + }, + ] + builder = self._make_builder(bodies, shapes) + sdf_cfg = self._make_sdf_cfg() + + with patch("isaaclab_newton.physics.newton_manager.logger") as mock_logger: + num_added, _ = NewtonManager._create_sdf_collision_from_visual(builder, {0}, sdf_cfg, None) + mock_logger.warning.assert_called() + + assert num_added == 0 + + def test_k_hydro_sets_hydroelastic_on_new_shape(self): + """When k_hydro is set, the new collision shape gets is_hydroelastic=True.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/arm"] + mesh = MagicMock(sdf=None) + shapes = [ + { + "body_idx": 0, + "label": "/World/Robot/arm/visual", + "geo_type": GeoType.MESH, + "flags": 0, + "source": mesh, + }, + ] + builder = self._make_builder(bodies, shapes) + sdf_cfg = self._make_sdf_cfg(k_hydro=1e10) + + num_added, num_hydro = NewtonManager._create_sdf_collision_from_visual(builder, {0}, sdf_cfg, None) + + assert num_added == 1 + assert num_hydro == 1 + call_kwargs = builder.add_shape_mesh.call_args[1] + assert call_kwargs["cfg"].is_hydroelastic is True + + def test_hydro_patterns_filter_respected(self): + """hydroelastic patterns filter which visual-mesh shapes get HYDROELASTIC.""" + from isaaclab_newton.physics.newton_manager import NewtonManager + + bodies = ["/World/Robot/arm", "/World/Robot/leg"] + mesh_arm = MagicMock(sdf=None) + mesh_leg = MagicMock(sdf=None) + shapes = [ + { + "body_idx": 0, + "label": "/World/Robot/arm/visual", + "geo_type": GeoType.MESH, + "flags": 0, + "source": mesh_arm, + }, + { + "body_idx": 1, + "label": "/World/Robot/leg/visual", + "geo_type": GeoType.MESH, + "flags": 0, + "source": mesh_leg, + }, + ] + builder = self._make_builder(bodies, shapes) + sdf_cfg = self._make_sdf_cfg(k_hydro=1e10) + hydro_patterns = [re.compile(".*arm.*")] + + num_added, num_hydro = NewtonManager._create_sdf_collision_from_visual( + builder, {0, 1}, sdf_cfg, None, hydro_patterns + ) + + assert num_added == 2 + assert num_hydro == 1 # only arm matches hydro pattern From 1e08da89666be3aff1a1a1b9a59c7b26f6ff98e4 Mon Sep 17 00:00:00 2001 From: Miguel Zamora M Date: Thu, 21 May 2026 15:34:47 +0200 Subject: [PATCH 17/20] isaaclab_newton: add collision_decimation knob for mid-tick re-collide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds ``NewtonCfg.collision_decimation`` (default ``0`` = legacy one-collide-per-tick behaviour). - When positive, ``_run_solver_substeps`` re-invokes the collision pipeline every ``collision_decimation`` substeps so contact normals reflect the bodies' just-integrated poses instead of the poses from the top of the tick. - The last substep is intentionally skipped — its contact set would only affect the next tick, which the top-of-tick ``collide()`` in ``_simulate_physics_only`` / ``_simulate_full`` owns. - ``NewtonCfg.__post_init__`` warns when ``collision_decimation >= num_substeps`` (silently equivalent to ``0``). ## Test plan - [x] ``./isaaclab.sh -p -m pytest source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py`` — 39/39 pass - [x] ``./isaaclab.sh -f`` — pre-commit clean --- .../isaaclab_newton/physics/newton_manager.py | 13 ++++++++++++- .../isaaclab_newton/physics/newton_manager_cfg.py | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 500d07b726e7..21a37d45d0b7 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -199,6 +199,7 @@ class NewtonManager(PhysicsManager): _solver_dt: float = 1.0 / 200.0 _num_substeps: int = 1 _decimation: int = 1 + _collision_decimation: int = 0 _num_envs: int | None = None # Newton model and state @@ -1218,6 +1219,7 @@ def initialize_solver(cls) -> None: with Timer(name="newton_initialize_solver", msg="Initialize solver took:"): NewtonManager._num_substeps = cfg.num_substeps # type: ignore[union-attr] + NewtonManager._collision_decimation = cfg.collision_decimation # type: ignore[union-attr] NewtonManager._solver_dt = cls.get_physics_dt() / cls._num_substeps NewtonManager._collision_cfg = cfg.collision_cfg # type: ignore[union-attr] @@ -1439,10 +1441,17 @@ def _capture_relaxed_graph(cls, device: str): @classmethod def _run_solver_substeps(cls, contacts) -> None: """Run ``num_substeps`` solver iterations, handling double-buffered state swap.""" + collide_every = cls._collision_decimation + # Last substep is skipped: its contact set would only feed the next tick's + # top-of-loop collide(), not this one. + collide_mid_loop = collide_every > 0 and cls._needs_collision_pipeline and contacts is not None + if cls._use_single_state: - for _ in range(cls._num_substeps): + for i in range(cls._num_substeps): cls._step_solver(cls._state_0, cls._state_0, cls._control, contacts, cls._solver_dt) cls._state_0.clear_forces() + if collide_mid_loop and (i + 1) % collide_every == 0 and i + 1 < cls._num_substeps: + 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] @@ -1453,6 +1462,8 @@ def _run_solver_substeps(cls, contacts) -> None: else: NewtonManager._state_0, NewtonManager._state_1 = cls._state_1, cls._state_0 cls._state_0.clear_forces() + if collide_mid_loop and (i + 1) % collide_every == 0 and i + 1 < cls._num_substeps: + cls._collision_pipeline.collide(cls._state_0, contacts) @classmethod def _update_sensors(cls, contacts) -> None: diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py index 6ff646aff57b..feb9b28db386 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py @@ -7,6 +7,7 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING from isaaclab.physics import PhysicsCfg @@ -17,6 +18,8 @@ if TYPE_CHECKING: from isaaclab_newton.physics import NewtonManager +logger = logging.getLogger(__name__) + @configclass class NewtonSolverCfg: @@ -97,6 +100,9 @@ class NewtonCfg(PhysicsCfg): num_substeps: int = 1 """Number of substeps to use for the solver.""" + collision_decimation: int = 0 + """Re-collide every N solver substeps within a physics tick (``0`` = once per tick).""" + debug_mode: bool = False """Whether to enable debug mode for the solver.""" @@ -149,3 +155,12 @@ def __post_init__(self): self.solver_cfg = MJWarpSolverCfg() self.class_type = self.solver_cfg.class_type + + # Mid-tick re-collide is silently disabled when collision_decimation >= num_substeps. + if self.collision_decimation > 0 and self.collision_decimation >= self.num_substeps: + logger.warning( + "NewtonCfg.collision_decimation=%d is >= num_substeps=%d; mid-tick re-collide is disabled. " + "Set 0 < collision_decimation < num_substeps to enable.", + self.collision_decimation, + self.num_substeps, + ) From c2df943719adf3523bb4ab99c9b31d94ce10181a Mon Sep 17 00:00:00 2001 From: Miguel Zamora M Date: Thu, 21 May 2026 16:11:44 +0200 Subject: [PATCH 18/20] isaaclab_newton: tests + changelog for collision_decimation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds ``test_newton_cfg_collision_decimation_warning`` (parametrized over ``(num_substeps, collision_decimation)``) for the ``__post_init__`` warning gate. - Adds ``test_collision_decimation_invokes_mid_loop_collide`` that wraps ``NewtonManager._collision_pipeline.collide`` with a counter, runs one physics tick with a sphere falling onto a ground plane, and asserts the call count equals ``1 + floor((num_substeps - 1) / collision_decimation)``. - Adds ``source/isaaclab_newton/changelog.d/mzamoramora-collision-decimation.minor.rst``. ## Test plan - [x] ``./isaaclab.sh -p -m pytest source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py`` — 39/39 pass (28 existing + 11 new) - [x] ``./isaaclab.sh -f`` — pre-commit clean --- ...mzamoramora-collision-decimation.minor.rst | 13 +++ .../test_newton_manager_abstraction.py | 88 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 source/isaaclab_newton/changelog.d/mzamoramora-collision-decimation.minor.rst diff --git a/source/isaaclab_newton/changelog.d/mzamoramora-collision-decimation.minor.rst b/source/isaaclab_newton/changelog.d/mzamoramora-collision-decimation.minor.rst new file mode 100644 index 000000000000..f2cc59363b0d --- /dev/null +++ b/source/isaaclab_newton/changelog.d/mzamoramora-collision-decimation.minor.rst @@ -0,0 +1,13 @@ +Added +^^^^^ + +* Added :attr:`~isaaclab_newton.physics.NewtonCfg.collision_decimation` to + re-invoke the Newton collision pipeline every ``N`` solver substeps within + a physics tick. Defaults to ``0`` (legacy: one collide per tick). When set + to ``0 < N < num_substeps``, the substep loop in + :meth:`~isaaclab_newton.physics.NewtonManager._run_solver_substeps` calls + the collision pipeline again at the matching substep boundaries so contact + normals reflect the bodies' just-integrated poses. The last substep is + intentionally skipped — its contact set would only feed the next tick. + :meth:`~isaaclab_newton.physics.NewtonCfg.__post_init__` warns when + ``collision_decimation >= num_substeps`` (the gate is silently bypassed). 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 f8ac77586cc0..8458f466632c 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -134,6 +134,29 @@ def test_newton_cfg_post_init_propagates_class_type( assert cfg.class_type.__name__ == expected_manager.__name__ +@pytest.mark.parametrize( + "num_substeps, collision_decimation, should_warn", + [ + (8, 0, False), # Default: feature disabled, no warning. + (8, 1, False), # Valid: re-collide every substep. + (8, 2, False), # Valid: re-collide every 2 substeps. + (8, 7, False), # Valid edge: one mid-loop re-collide at i=6. + (8, 8, True), # Equal to num_substeps: gate never fires. + (8, 16, True), # Larger than num_substeps: gate never fires. + ], +) +def test_newton_cfg_collision_decimation_warning(num_substeps, collision_decimation, should_warn, caplog): + """``NewtonCfg.__post_init__`` warns when ``collision_decimation >= num_substeps``.""" + import logging + + with caplog.at_level(logging.WARNING, logger="isaaclab_newton.physics.newton_manager_cfg"): + cfg = NewtonCfg(num_substeps=num_substeps, collision_decimation=collision_decimation) + warned = any("collision_decimation" in rec.getMessage() for rec in caplog.records) + assert warned is should_warn + # Cfg field round-trips regardless of warning. + assert cfg.collision_decimation == collision_decimation + + # --------------------------------------------------------------------------- # Manager class hierarchy and factory contracts # --------------------------------------------------------------------------- @@ -272,3 +295,68 @@ def test_mjwarp_internal_contacts_with_collision_cfg_raises(): with pytest.raises(ValueError, match="collision_cfg cannot be set"): sim.reset() + + +@pytest.mark.parametrize( + "num_substeps, collision_decimation, expected_mid_loop_collides", + [ + (8, 0, 0), # Feature disabled. + (8, 2, 3), # Re-collide after substeps 2, 4, 6 (skip last). + (8, 4, 1), # Re-collide after substep 4 only. + (8, 7, 1), # Re-collide after substep 7 only. + (8, 8, 0), # Gated off (>= num_substeps). + ], +) +def test_collision_decimation_invokes_mid_loop_collide(num_substeps, collision_decimation, expected_mid_loop_collides): + """``_run_solver_substeps`` re-invokes ``collide`` at the expected substeps. + + Wraps :attr:`NewtonManager._collision_pipeline.collide` with a counter and + runs one physics tick. The collide-call count is ``1`` (top-of-tick) plus + one per matching mid-loop substep, excluding the last substep. + + The scene has a free-joint sphere falling onto a ground plane so the + broadphase actually generates pairs — guards against a future change + that skips ``collide()`` when there are no collidable shapes. + """ + sim_cfg = SimulationCfg( + dt=1.0 / 120.0, + device="cuda:0", + gravity=(0.0, 0.0, -9.81), + physics=NewtonCfg( + solver_cfg=MJWarpSolverCfg(use_mujoco_contacts=False), + num_substeps=num_substeps, + collision_decimation=collision_decimation, + 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_free(child=body) + builder.add_shape_sphere(body=body, radius=0.05) + builder.add_ground_plane() + # Lift the sphere to 0.5 m above the plane so the scene is non-degenerate. + # joint_q for a free joint is [tx, ty, tz, qx, qy, qz, qw]. + builder.joint_q[-7:] = [0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 1.0] + NewtonManager.set_builder(builder) + sim.reset() + + # Wrap collide() with a counter — must run after sim.reset() so the + # pipeline is allocated, and use_cuda_graph=False so the wrapped + # Python callable isn't bypassed by a captured graph. + calls = {"n": 0} + original_collide = NewtonManager._collision_pipeline.collide + + def counting_collide(state, contacts): + calls["n"] += 1 + return original_collide(state, contacts) + + NewtonManager._collision_pipeline.collide = counting_collide + try: + sim.step(render=False) + finally: + NewtonManager._collision_pipeline.collide = original_collide + + # Expect: 1 (top-of-tick) + expected_mid_loop_collides. + assert calls["n"] == 1 + expected_mid_loop_collides From a028b0eb5297b9418d8d92a9f6b2396ce8e0f395 Mon Sep 17 00:00:00 2001 From: Miguel Zamora M Date: Tue, 12 May 2026 14:10:34 +0200 Subject: [PATCH 19/20] Newton: fix RigidObjectData shape for kinematic single-body assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``RigidObjectData._create_simulation_bindings`` crashed with ``IndexError: tuple index out of range`` whenever the rigid object was fixed-base AND its root view only contained a single body — e.g. a kinematic-enabled rigid object built from a USD that authors a ``PhysicsFixedJoint``. Newton's ``get_root_transforms`` returns a 2D ``(count, links)`` array in that scenario, but the legacy ``is_fixed_base`` branch unconditionally indexed ``[:, 0, 0]`` assuming a 3D ``(count, links, 1)`` layout. Dispatch on actual ``ndim`` so both fixed-base multi-link articulations and single-body kinematic rigid objects are handled. ``_create_buffers`` similarly allocated the no-velocity fallback for ``_sim_bind_body_com_vel_w`` as 1D, which the ``derive_body_acceleration_from_body_com_velocities`` kernel rejected at launch time (expects 2D). Allocate it as ``(num_instances, 1)`` to match the kernel signature. --- ...mzamoramora-rigid-object-kinematic-fix.rst | 16 ++++++++++ .../assets/rigid_object/rigid_object_data.py | 32 ++++++++++++------- 2 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 source/isaaclab_newton/changelog.d/mzamoramora-rigid-object-kinematic-fix.rst diff --git a/source/isaaclab_newton/changelog.d/mzamoramora-rigid-object-kinematic-fix.rst b/source/isaaclab_newton/changelog.d/mzamoramora-rigid-object-kinematic-fix.rst new file mode 100644 index 000000000000..b858c3c9fafc --- /dev/null +++ b/source/isaaclab_newton/changelog.d/mzamoramora-rigid-object-kinematic-fix.rst @@ -0,0 +1,16 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_newton.assets.RigidObjectData` crashing with + ``IndexError: tuple index out of range`` for kinematic-enabled + single-body fixed-base rigid objects. The ``is_fixed_base`` branch + in ``_create_simulation_bindings`` indexed ``[:, 0, 0]`` assuming a + 3D ``(count, links, 1)`` layout, but Newton returns a 2D + ``(count, links)`` array when the view contains a single body. + Dispatch on actual ``ndim`` instead so both multi-link fixed-base + articulations and single-body kinematic rigid objects are handled + correctly. Also fixes the matching no-velocity fallback in + ``_create_buffers``: ``_sim_bind_body_com_vel_w`` is now allocated + as ``(num_instances, 1)`` to match the + ``derive_body_acceleration_from_body_com_velocities`` kernel's + 2D signature. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object_data.py b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object_data.py index 43e719d3a580..84d180bb34a8 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object_data.py @@ -804,18 +804,22 @@ def _create_simulation_bindings(self) -> None: self._num_bodies = self._root_view.link_count # -- root properties - if self._root_view.is_fixed_base: - self._sim_bind_root_link_pose_w = self._root_view.get_root_transforms(SimulationManager.get_state_0())[ - :, 0, 0 - ] - else: - self._sim_bind_root_link_pose_w = self._root_view.get_root_transforms(SimulationManager.get_state_0())[:, 0] + # Newton's ``get_root_transforms`` / ``get_root_velocities`` return + # either a 2D ``(count, links_per_env)`` array (single-body + # kinematic-enabled rigid objects, e.g. Factory's bolt) or a 3D + # ``(count, links_per_env, 1)`` array (multi-link fixed-base + # articulations). Dispatch on actual ``ndim`` so we don't crash + # with ``IndexError: tuple index out of range`` when a fixed-base + # rigid object yields the 2D layout. + root_xforms = self._root_view.get_root_transforms(SimulationManager.get_state_0()) + self._sim_bind_root_link_pose_w = root_xforms[:, 0, 0] if root_xforms.ndim >= 3 else root_xforms[:, 0] self._sim_bind_root_com_vel_w = self._root_view.get_root_velocities(SimulationManager.get_state_0()) if self._sim_bind_root_com_vel_w is not None: - if self._root_view.is_fixed_base: - self._sim_bind_root_com_vel_w = self._sim_bind_root_com_vel_w[:, 0, 0] - else: - self._sim_bind_root_com_vel_w = self._sim_bind_root_com_vel_w[:, 0] + self._sim_bind_root_com_vel_w = ( + self._sim_bind_root_com_vel_w[:, 0, 0] + if self._sim_bind_root_com_vel_w.ndim >= 3 + else self._sim_bind_root_com_vel_w[:, 0] + ) # -- body properties self._sim_bind_body_com_pos_b = self._root_view.get_attribute("body_com", SimulationManager.get_model())[:, 0] self._sim_bind_body_link_pose_w = self._root_view.get_link_transforms(SimulationManager.get_state_0())[:, 0] @@ -854,11 +858,17 @@ def _create_buffers(self) -> None: "Failed to get root com velocity. If the rigid object is fixed, this is expected. " "Setting root com velocity to zeros." ) + # ``_sim_bind_root_com_vel_w`` is consumed as a 1D array by + # ``get_root_link_vel_from_root_com_vel`` (kernel signature has + # ``com_vel: wp.array(dtype=wp.spatial_vectorf)``), so the fallback + # stays 1D. ``_sim_bind_body_com_vel_w`` feeds the 2D-typed + # ``derive_body_acceleration_from_body_com_velocities`` kernel, so + # the fallback is 2D ``(num_instances, 1)`` to match. self._sim_bind_root_com_vel_w = wp.zeros( (self._num_instances,), dtype=wp.spatial_vectorf, device=self.device ) self._sim_bind_body_com_vel_w = wp.zeros( - (self._num_instances,), dtype=wp.spatial_vectorf, device=self.device + (self._num_instances, 1), dtype=wp.spatial_vectorf, device=self.device ) # -- default root pose and velocity self._default_root_pose = wp.zeros((self._num_instances,), dtype=wp.transformf, device=self.device) From e0f975534ae8b815fd0c6806a0a0846be1220495 Mon Sep 17 00:00:00 2001 From: Miguel Zamora M Date: Tue, 26 May 2026 14:41:17 +0200 Subject: [PATCH 20/20] newton_manager: add sanitize_world_state hook for NaN recovery Add classmethod `NewtonManager.sanitize_world_state(env_ids)` that zeroes per-world MJWarp solver scratch (qacc_warmstart, qfrc_*, cacc, cfrc_*) and Newton State velocity/force buffers (joint_qd, body_qd, body_f, body_qdd, body_parent_f) for the given worlds, then runs eval_fk to re-derive body_q. Generalizes the in-tree Factory NaN scrubber so any task on Newton can recover NaN-divergent worlds in place via: if has_nan_envs: NewtonManager.sanitize_world_state(nan_env_ids) Compared to mjwarp.reset_data: closes the gap on derived qfrc_* / cacc / cfrc_int / cfrc_ext that reset_data doesn't touch. cfrc_int in particular is read-modify-written via wp.atomic_add in mujoco_warp/_src/smooth.py:_cfrc_backward, so a stale NaN there survives the next rne() call and re-contaminates qfrc_bias. No-op for solvers without mjw_data (XPBD, Featherstone, Kamino) since those don't exhibit the MuJoCo warm-start contamination pattern. --- .../isaaclab_newton/physics/newton_manager.py | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 5b33a5f5c02c..77d3fc6c7835 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -15,6 +15,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING +import torch import warp as wp # Load CUDA runtime for relaxed-mode graph capture (RTX-compatible). @@ -232,6 +233,10 @@ class NewtonManager(PhysicsManager): _world_reset_mask: wp.array | None = None # (num_envs,) wp.int32 — for SolverKamino.reset(world_mask=...) _fk_reset_mask: wp.array | None = None # (articulation_count,) wp.bool — for eval_fk(mask=...) + # Per-world mask of NaN-divergent envs queued for sanitize at next reset. + # Populated by :meth:`sanitize_nan_envs`, drained by :meth:`sanitize_pending_nan_envs`. + _nan_env_mask_pending_reset: torch.Tensor | None = None + # Newton actuator adapter (owns actuators and double-buffered states) _adapter: NewtonActuatorAdapter | None = None # In-graph hooks invoked after the actuator step and before the solver @@ -679,6 +684,7 @@ def clear(cls): # Per-world reset masks NewtonManager._world_reset_mask = None NewtonManager._fk_reset_mask = None + NewtonManager._nan_env_mask_pending_reset = None NewtonManager._graph = None NewtonManager._graph_capture_pending = False NewtonManager._newton_stage_path = None @@ -1687,6 +1693,141 @@ def _update_sensors(cls, contacts) -> None: for sensor in cls._newton_contact_sensors.values(): sensor.update(cls._state_0, eval_contacts) + # ------------------------------------------------------------------ + # NaN recovery + # ------------------------------------------------------------------ + + @classmethod + def sanitize_world_state(cls, env_ids) -> None: + """Reset solver internals + Newton State buffers for the given worlds. + + Used by tasks that detect NaN-divergent worlds and want to recover + them in place (vs throwing the episode away). Zeroes per-world + MJWarp solver scratch buffers (``qacc_warmstart``, ``qfrc_*``, + ``cacc``, ``cfrc_*``) and Newton State velocity/force buffers + (``joint_qd``, ``body_qd``, ``body_f``, ``body_qdd``, + ``body_parent_f``) at the indexed worlds, then runs + :func:`newton.eval_fk` to re-derive ``state.body_q`` from + ``joint_q``. + + ``mjwarp.reset_data`` covers ``qacc_warmstart`` and the contact + arrays but leaves the derived ``qfrc_*`` family and the COM-frame + ``cfrc_int``/``cacc``/``cfrc_ext`` untouched. Empirically those + latter buffers re-divergence the same world on the next solver + step (``cfrc_int`` is read-modify-written via ``wp.atomic_add`` in + ``mujoco_warp/_src/smooth.py:_cfrc_backward``, so a stale NaN + survives the next ``rne()`` call). This implementation walks all + 17 named MJWarp fields explicitly to close that gap. See + ``newton-physics/newton#1266`` for the upstream discussion. + + No-op for solvers without a ``mjw_data`` attribute (XPBD, + Featherstone, Kamino) — those solvers do not exhibit the MuJoCo + warm-start contamination pattern this addresses. + + Args: + env_ids: 1-D ``int`` torch tensor of world IDs to sanitize, on + the simulation device. + """ + import newton as _newton # noqa: PLC0415 + + if cls._model is None or cls._solver is None or cls._num_envs is None: + return + if env_ids.numel() == 0: + return + + mjw_data = getattr(cls._solver, "mjw_data", None) + if mjw_data is None: + return # not the MJWarp backend; nothing to scrub + + env_ids_list = env_ids.tolist() + for field_name in ( + "qacc_warmstart", + "qacc", + "qacc_smooth", + "qfrc_applied", + "qfrc_bias", + "qfrc_spring", + "qfrc_damper", + "qfrc_gravcomp", + "qfrc_fluid", + "qfrc_passive", + "qfrc_actuator", + "qfrc_smooth", + "qfrc_constraint", + "qfrc_inverse", + "cacc", + "cfrc_int", + "cfrc_ext", + ): + arr = getattr(mjw_data, field_name, None) + if arr is None: + continue + t = wp.to_torch(arr) + for env_id in env_ids_list: + t[env_id] = 0.0 + + state = cls._state_0 + model = cls._model + num_envs = cls._num_envs + nd_per_env = model.joint_dof_count // num_envs + nb_per_env = model.body_count // num_envs + for buf_name, per_env in ( + ("joint_qd", nd_per_env), + ("body_qd", nb_per_env), + ("body_f", nb_per_env), + ("body_qdd", nb_per_env), + ("body_parent_f", nb_per_env), + ): + buf = getattr(state, buf_name, None) + if buf is None: + continue + wp.to_torch(buf).view(num_envs, per_env, -1)[env_ids] = 0.0 + + _newton.eval_fk(model, state.joint_q, state.joint_qd, state) + body_q_prev = getattr(state, "body_q_prev", None) + if body_q_prev is not None: + bq = wp.to_torch(state.body_q).view(num_envs, nb_per_env, -1) + wp.to_torch(body_q_prev).view(num_envs, nb_per_env, -1)[env_ids] = bq[env_ids] + + @classmethod + def flag_nan_envs(cls, mask: torch.Tensor) -> None: + """Flag NaN-divergent envs for sanitization at the next episode reset. + + Call from a task's per-step NaN detection (e.g. ``torch.isnan`` over + obs/state tensors). Pure bookkeeping: the mask is ORed into + :attr:`_nan_env_mask_pending_reset`; no solver state is touched yet. + :meth:`sanitize_pending_nan_envs` drains the queue at the next reset. + + Tasks that need to keep training between detection and reset should + ``torch.nan_to_num`` their obs/state/reward themselves — flagging is + the only side effect here. + + No-op when ``mask.any()`` is False. + + Args: + mask: 1-D ``bool`` torch tensor of shape ``(num_worlds,)``; True for + envs just detected as NaN-divergent. + """ + if not mask.any(): + return + if cls._nan_env_mask_pending_reset is None: + cls._nan_env_mask_pending_reset = torch.zeros_like(mask) + cls._nan_env_mask_pending_reset |= mask + + @classmethod + def sanitize_pending_nan_envs(cls) -> None: + """Drain the pending-reset NaN queue and sanitize those envs. + + Call from a task's reset hook (e.g. ``_reset_idx``) before re-init. + No-op when no envs were queued via :meth:`flag_nan_envs` since the + last call. + """ + if cls._nan_env_mask_pending_reset is None or not cls._nan_env_mask_pending_reset.any(): + return + nan_ids = cls._nan_env_mask_pending_reset.nonzero(as_tuple=False).squeeze(-1) + cls.sanitize_world_state(nan_ids) + cls._nan_env_mask_pending_reset.zero_() + # ------------------------------------------------------------------ # Composite stepping routines # ------------------------------------------------------------------