From 20e7deaf5d9c639302767c71e0f1871def512b07 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Fri, 15 May 2026 12:42:10 +0200 Subject: [PATCH 01/26] Feat: Add Cables as spawnable shape, with cable material. Spawned as BasisCurve in USD. Currently only supported in Newton backend as add_rod_graph --- scripts/demos/deformables.py | 39 +- .../isaaclab/changelog.d/mym-cable.minor.rst | 8 + source/isaaclab/isaaclab/sim/__init__.pyi | 4 + .../isaaclab/sim/spawners/__init__.pyi | 4 + .../isaaclab/sim/spawners/shapes/__init__.pyi | 6 +- .../isaaclab/sim/spawners/shapes/shapes.py | 64 ++++ .../sim/spawners/shapes/shapes_cfg.py | 36 ++ source/isaaclab/test/sim/test_spawn_cable.py | 117 ++++++ .../changelog.d/mym-cable.minor.rst | 21 ++ .../isaaclab_contrib/cable/cable_object.py | 344 ++++++++++++++++++ .../cable/cable_object_cfg.py | 37 ++ .../deformable/deformable_object.py | 25 -- .../deformable/newton_manager_cfg.py | 6 + .../deformable/vbd_manager.py | 41 ++- .../test/cable/test_cable_object.py | 273 ++++++++++++++ .../changelog.d/mym-cable.minor.rst | 8 + .../cloner/newton_replicate.py | 33 +- .../physics/newton_manager_cfg.py | 7 + .../sim/spawners/materials/__init__.pyi | 2 + .../materials/physics_materials_cfg.py | 35 ++ 20 files changed, 1041 insertions(+), 69 deletions(-) create mode 100644 source/isaaclab/changelog.d/mym-cable.minor.rst create mode 100644 source/isaaclab/test/sim/test_spawn_cable.py create mode 100644 source/isaaclab_contrib/changelog.d/mym-cable.minor.rst create mode 100644 source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/cable/cable_object_cfg.py create mode 100644 source/isaaclab_contrib/test/cable/test_cable_object.py create mode 100644 source/isaaclab_newton/changelog.d/mym-cable.minor.rst diff --git a/scripts/demos/deformables.py b/scripts/demos/deformables.py index 9da0ba196597..ea4a472adf29 100644 --- a/scripts/demos/deformables.py +++ b/scripts/demos/deformables.py @@ -46,6 +46,9 @@ if args_cli.backend == "newton": from isaaclab_newton.sim.schemas import NewtonDeformableBodyPropertiesCfg as DeformableBodyPropertiesCfg + from isaaclab_newton.sim.spawners.materials import ( + NewtonCableMaterialCfg as CableMaterialCfg, + ) from isaaclab_newton.sim.spawners.materials import ( NewtonDeformableBodyMaterialCfg as VolumeDeformableMaterialCfg, ) @@ -152,9 +155,18 @@ def design_scene() -> tuple[dict, list[list[float]]]: "cloth": cfg_cloth, "usd": cfg_usd, } + if args_cli.backend == "newton": + cfg_cable = sim_utils.CableCfg( + positions=[(0.1 * i, 0.0, 0.0) for i in range(10)], + width=0.03, + visual_material=sim_utils.PreviewSurfaceCfg(), + physics_material=CableMaterialCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + ) + objects_cfg["cable"] = cfg_cable # Create separate groups of deformable objects - origins = define_origins(num_origins=12, radius=1.5, center_height=2.0) + origins = define_origins(num_origins=16, radius=1.5, center_height=2.0) print("[INFO]: Spawning objects...") # Iterate over all the origins, spawn objects, and create a view for all the deformables # note: since we manually spawned random deformable meshes above, we don't need to @@ -190,6 +202,16 @@ def design_scene() -> tuple[dict, list[list[float]]]: init_state=DeformableObjectCfg.InitialStateCfg(pos=origin), ) scene_entities[f"Surface{idx:02d}"] = DeformableObject(cfg=cfg) + elif obj_name in ["cable"]: + from isaaclab_contrib.cable import CableObject, CableObjectCfg + + prim_path = f"/World/Origin/Cable{idx:02d}" + cfg = CableObjectCfg( + prim_path=prim_path, + spawn=obj_cfg, + init_state=CableObjectCfg.InitialStateCfg(pos=origin), + ) + scene_entities[f"Cable{idx:02d}"] = CableObject(cfg=cfg) else: prim_path = f"/World/Origin/Volume{idx:02d}" cfg = DeformableObjectCfg( @@ -216,13 +238,14 @@ def run_simulator(sim: sim_utils.SimulationContext, entities: dict[str, Deformab if count % int(3.0 / sim_dt) == 0: # reset counters count = 0 - # reset deformable object state - for _, deform_body in enumerate(entities.values()): - # root state - nodal_state = deform_body.data.default_nodal_state_w.torch.clone() - deform_body.write_nodal_state_to_sim_index(nodal_state) - # reset the internal state - deform_body.reset() + # reset object state. Deformables snap back to their default nodal state; + # cables are articulations without a free root joint, so they have no + # equivalent "snap-back" — only the internal buffers are reset. + for body in entities.values(): + if hasattr(body.data, "default_nodal_state_w"): + nodal_state = body.data.default_nodal_state_w.torch.clone() + body.write_nodal_state_to_sim_index(nodal_state) + body.reset() print("[INFO]: Resetting deformable object state...") # perform step sim.step() diff --git a/source/isaaclab/changelog.d/mym-cable.minor.rst b/source/isaaclab/changelog.d/mym-cable.minor.rst new file mode 100644 index 000000000000..8410f11820ba --- /dev/null +++ b/source/isaaclab/changelog.d/mym-cable.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added :class:`~isaaclab.sim.spawners.shapes.CableCfg` and + :func:`~isaaclab.sim.spawners.shapes.spawn_cable` for authoring 1D cable / rod + prims as ``UsdGeomBasisCurves``. Physics is materialized by the Newton + replicate hook in the contrib package; see + :class:`~isaaclab_contrib.cable.CableObject`. diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 0c787cc64c67..a43212bc81aa 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -114,11 +114,13 @@ __all__ = [ "FisheyeCameraCfg", "PinholeCameraCfg", "SensorFrameCfg", + "spawn_cable", "spawn_capsule", "spawn_cone", "spawn_cuboid", "spawn_cylinder", "spawn_sphere", + "CableCfg", "CapsuleCfg", "ConeCfg", "CuboidCfg", @@ -304,11 +306,13 @@ from .spawners import ( FisheyeCameraCfg, PinholeCameraCfg, SensorFrameCfg, + spawn_cable, spawn_capsule, spawn_cone, spawn_cuboid, spawn_cylinder, spawn_sphere, + CableCfg, CapsuleCfg, ConeCfg, CuboidCfg, diff --git a/source/isaaclab/isaaclab/sim/spawners/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/__init__.pyi index c936d166ba3c..32111ae25576 100644 --- a/source/isaaclab/isaaclab/sim/spawners/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/__init__.pyi @@ -56,11 +56,13 @@ __all__ = [ "FisheyeCameraCfg", "PinholeCameraCfg", "SensorFrameCfg", + "spawn_cable", "spawn_capsule", "spawn_cone", "spawn_cuboid", "spawn_cylinder", "spawn_sphere", + "CableCfg", "CapsuleCfg", "ConeCfg", "CuboidCfg", @@ -128,11 +130,13 @@ from .meshes import ( ) from .sensors import spawn_camera, spawn_sensor_frame, FisheyeCameraCfg, PinholeCameraCfg, SensorFrameCfg from .shapes import ( + spawn_cable, spawn_capsule, spawn_cone, spawn_cuboid, spawn_cylinder, spawn_sphere, + CableCfg, CapsuleCfg, ConeCfg, CuboidCfg, diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/shapes/__init__.pyi index 04c7330c7b59..5692d22fa947 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/__init__.pyi @@ -4,11 +4,13 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "spawn_cable", "spawn_capsule", "spawn_cone", "spawn_cuboid", "spawn_cylinder", "spawn_sphere", + "CableCfg", "CapsuleCfg", "ConeCfg", "CuboidCfg", @@ -17,5 +19,5 @@ __all__ = [ "SphereCfg", ] -from .shapes import spawn_capsule, spawn_cone, spawn_cuboid, spawn_cylinder, spawn_sphere -from .shapes_cfg import CapsuleCfg, ConeCfg, CuboidCfg, CylinderCfg, ShapeCfg, SphereCfg +from .shapes import spawn_cable, spawn_capsule, spawn_cone, spawn_cuboid, spawn_cylinder, spawn_sphere +from .shapes_cfg import CableCfg, CapsuleCfg, ConeCfg, CuboidCfg, CylinderCfg, ShapeCfg, SphereCfg diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index 9e8eafc1c578..90fd56d7ffdc 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -233,6 +233,70 @@ def spawn_cone( return stage.GetPrimAtPath(prim_path) +@clone +def spawn_cable( + prim_path: str, + cfg: shapes_cfg.CableCfg, + translation: tuple[float, float, float] | None = None, + orientation: tuple[float, float, float, float] | None = None, + **kwargs, +) -> Usd.Prim: + """Create a ``UsdGeomBasisCurves`` cable prim. + + Authors a USD curve under ``{prim_path}/geometry/mesh`` (same layout as the + other shape spawners) and binds the visual / physics material via the shared + helper. Cable physics is materialized later by the Newton replicate hook + calling :meth:`newton.ModelBuilder.add_rod_graph`; ``rigid_props`` and + ``mass_props`` are rejected up front because they don't apply to cables. + + .. note:: + This function is decorated with :func:`clone` that resolves prim path into list of paths + if the input prim path is a regex pattern. This is done to support spawning multiple assets + from a single and cloning the USD prim at the given path expression. + + Args: + prim_path: The prim path or regex pattern to spawn at. + cfg: Cable configuration. ``positions``, ``radius`` and ``physics_material`` + (a :class:`~isaaclab_newton.sim.spawners.materials.NewtonCableMaterialCfg`) + are required. + translation: World-space translation of the parent ``Xform``. + orientation: World-space orientation ``(x, y, z, w)`` of the parent ``Xform``. + **kwargs: Forwarded to the :func:`clone` decorator (e.g. ``clone_in_fabric``). + + Returns: + The spawned cable ``Xform`` prim. + + Raises: + ValueError: If ``cfg.physics_material`` is not a + ``NewtonCableMaterialCfg`` instance, or any of ``cfg.rigid_props`` or + ``cfg.mass_props`` is non-None. + """ + # Import here to avoid a hard dep on isaaclab_newton in core. + from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg + + # -- validate (rejects misconfiguration up-front) -- + if not isinstance(cfg.physics_material, NewtonCableMaterialCfg): + raise ValueError( + "CableCfg requires `physics_material` to be a NewtonCableMaterialCfg instance," + f" got {type(cfg.physics_material).__name__}." + ) + if cfg.rigid_props is not None: + raise ValueError("CableCfg does not support `rigid_props`.") + if cfg.mass_props is not None: + raise ValueError("CableCfg does not support `mass_props`.") + + n_points = len(cfg.positions) + attributes = { + "points": cfg.positions, + "curveVertexCounts": [n_points], + "widths": [cfg.width] * n_points, + "type": "linear", + } + stage = get_current_stage() + _spawn_geom_from_prim_type(prim_path, cfg, "BasisCurves", attributes, translation, orientation, stage=stage) + return stage.GetPrimAtPath(prim_path) + + """ Helper functions. """ diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py index b111bdbb2bf3..eebefd0691cc 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py @@ -120,3 +120,39 @@ class ConeCfg(ShapeCfg): """Height of the v (in m).""" axis: Literal["X", "Y", "Z"] = "Z" """Axis of the cone. Defaults to "Z".""" + + +@configclass +class CableCfg(ShapeCfg): + """Configuration parameters for a 1D cable / rod prim. + + Authors a ``UsdGeomBasisCurves`` prim at ``{prim_path}/curve`` from an + explicit list of control points. Physics is materialized at model-build time + by the Newton replicate hook calling :meth:`newton.ModelBuilder.add_rod_graph`. + + The cable's stretch/bend stiffness, damping, and density live on + ``physics_material`` (a :class:`~isaaclab_newton.sim.spawners.materials.NewtonCableMaterialCfg` instance from + :mod:`isaaclab_newton.sim.spawners.materials`, inherited slot from + :class:`ShapeCfg`). ``rigid_props``, ``mass_props``, ``collision_props`` are + inherited from :class:`ShapeCfg` but are not used by cables — :func:`spawn_cable` + raises ``ValueError`` if any is non-None. + """ + + func: Callable | str = "{DIR}.shapes:spawn_cable" + + positions: list[tuple[float, float, float]] = MISSING + """Control points in cable-local frame [m]. Must contain at least 2 points. + Adjacent pairs define one cable segment each.""" + + width: float = MISSING + """Capsule diameter for each segment [m].""" + + visual_material_path: str = "visual_material" + """Path for the visual material prim, relative to ``prim_path``. Overrides + :attr:`ShapeCfg.visual_material_path` so visual and physics materials don't + collide at the same sub-path (cables don't use a ``/geometry/`` intermediate + like mesh spawners do).""" + + physics_material_path: str = "physics_material" + """Path for the physics material prim, relative to ``prim_path``. Overrides + :attr:`ShapeCfg.physics_material_path`. See :attr:`visual_material_path`.""" diff --git a/source/isaaclab/test/sim/test_spawn_cable.py b/source/isaaclab/test/sim/test_spawn_cable.py new file mode 100644 index 000000000000..baba7a484d6a --- /dev/null +++ b/source/isaaclab/test/sim/test_spawn_cable.py @@ -0,0 +1,117 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +import pytest +from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg + +from pxr import UsdGeom + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext +from isaaclab.sim.spawners.shapes import CableCfg + +pytestmark = pytest.mark.isaacsim_ci + + +@pytest.fixture +def sim(): + sim_utils.create_new_stage() + sim = SimulationContext(SimulationCfg(dt=0.1)) + sim_utils.update_stage() + yield sim + sim._disable_app_control_on_stop_handle = True + sim.stop() + sim.clear_instance() + + +def _basic_material() -> NewtonCableMaterialCfg: + return NewtonCableMaterialCfg( + stretch_stiffness=1.0e9, + bend_stiffness=1.0e-3, + density=1500.0, + ) + + +def test_spawn_cable(sim): + cfg = CableCfg( + positions=[(0.0, 0.0, 0.0), (0.1, 0.0, 0.0), (0.2, 0.0, 0.0)], + width=0.01, + physics_material=_basic_material(), + ) + prim = cfg.func("/World/Cable", cfg) + assert prim.IsValid() + + curve_prim = sim.stage.GetPrimAtPath("/World/Cable/geometry/mesh") + assert curve_prim.IsValid() + curves = UsdGeom.BasisCurves(curve_prim) + points = list(curves.GetPointsAttr().Get()) + assert len(points) == 3 + counts = list(curves.GetCurveVertexCountsAttr().Get()) + assert counts == [3] + widths = list(curves.GetWidthsAttr().Get()) + assert widths == pytest.approx([0.01, 0.01, 0.01]) # cfg.width, broadcast per control point + assert curves.GetTypeAttr().Get() == "linear" + + +def test_spawn_cable_validation_wrong_material(sim): + from isaaclab.sim.spawners.materials import RigidBodyMaterialCfg + + cfg = CableCfg( + positions=[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], + width=0.01, + physics_material=RigidBodyMaterialCfg(), + ) + with pytest.raises(ValueError, match="NewtonCableMaterialCfg"): + cfg.func("/World/Cable", cfg) + + +def test_spawn_cable_validation_rigid_props_rejected(sim): + cfg = CableCfg( + positions=[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], + width=0.01, + physics_material=_basic_material(), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + ) + with pytest.raises(ValueError, match="rigid_props"): + cfg.func("/World/Cable", cfg) + + +def test_spawn_cable_authors_newton_material_attrs(sim): + cfg = CableCfg( + positions=[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], + width=0.01, + physics_material=_basic_material(), + ) + cfg.func("/World/Cable", cfg) + mat_prim = sim.stage.GetPrimAtPath("/World/Cable/geometry/physics_material") + assert mat_prim.IsValid() + # Material fields land under newton:* namespace (camelCase). + attr = mat_prim.GetAttribute("newton:stretchStiffness") + assert attr.IsValid() + assert attr.Get() == pytest.approx(1.0e9) + + +def test_spawn_cable_authors_visual_and_physics_at_distinct_paths(sim): + """When both visual and physics materials are configured, they author at + distinct sub-paths and neither overwrites the other.""" + from isaaclab.sim.spawners.materials import PreviewSurfaceCfg + + cfg = CableCfg( + positions=[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], + width=0.01, + physics_material=_basic_material(), + visual_material=PreviewSurfaceCfg(diffuse_color=(0.5, 0.5, 0.5)), + ) + cfg.func("/World/Cable", cfg) + assert sim.stage.GetPrimAtPath("/World/Cable/geometry/physics_material").IsValid() + assert sim.stage.GetPrimAtPath("/World/Cable/geometry/visual_material").IsValid() diff --git a/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst b/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst new file mode 100644 index 000000000000..31da8db5003d --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst @@ -0,0 +1,21 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_contrib.cable.CableObject` (subclass of + :class:`~isaaclab_newton.assets.articulation.Articulation`) and + :class:`~isaaclab_contrib.cable.CableObjectCfg` for runtime cable assets. +* Added :class:`~isaaclab_contrib.cable.CableRegistryEntry`, + :func:`~isaaclab_contrib.cable.add_cable_entry_to_builder`, + :func:`~isaaclab_contrib.cable.add_registered_cables_to_builder`, and + :func:`~isaaclab_contrib.cable.install_cable_builder_hooks` — + the replicate-hook plumbing that mirrors the deformable contrib pattern. + +Fixed +^^^^^ + +* Fixed an ``AttributeError`` in + :meth:`~isaaclab_contrib.deformable.vbd_manager.NewtonVBDManager._simulate_physics_only` + triggered in cable-only scenes (zero particles). Newton's ``SolverVBD`` skips + ``_init_particle_system`` for zero-particle scenes and leaves + ``particle_enable_self_contact`` unset; the manager now reads it with + ``getattr(..., False)`` to default to no-self-contact. diff --git a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py new file mode 100644 index 000000000000..e9d962a76c47 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py @@ -0,0 +1,344 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Cable / 1D-rod asset class, registry entry, and replicate-hook plumbing. + +The structure mirrors :mod:`isaaclab_contrib.deformable.deformable_object`. Cables +differ from deformables in two respects only: + +1. They subclass :class:`Articulation` (not :class:`BaseDeformableObject`) because + ``newton.ModelBuilder.add_rod_graph`` produces a Newton articulation, and + ``ArticulationView`` already covers state read/write. +2. Their material is consumed in-memory by the cable replicate hook (no USD + read-back), since :class:`CableObject` always holds the source cfg. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import newton +import warp as wp + + +@dataclass +class CableRegistryEntry: + """Mutable bridge between :class:`CableObject` and the replicate hook. + + Populated by :meth:`CableObject._register_cable` (reads the spawned + ``UsdGeomBasisCurves`` and its Newton physics material) and consumed by + :func:`add_cable_entry_to_builder`. Material-field semantics and defaults + mirror :class:`~isaaclab_newton.sim.spawners.materials.NewtonCableMaterialCfg`. + """ + + prim_path: str + node_positions: list[wp.vec3] + edges: list[tuple[int, int]] + radius: float + + init_pos: tuple[float, float, float] = (0.0, 0.0, 0.0) + init_rot: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0) + + stretch_stiffness: float = 1.0e9 + bend_stiffness: float = 0.0 + stretch_damping: float = 0.0 + bend_damping: float = 0.0 + density: float = 1500.0 + + +from isaaclab_newton.assets.articulation.articulation import Articulation # noqa: E402 +from isaaclab_newton.physics import NewtonManager as SimulationManager # noqa: E402 + +import isaaclab.sim as sim_utils # noqa: E402 +from isaaclab.sim.spawners.shapes import CableCfg # noqa: E402 + +if TYPE_CHECKING: + from .cable_object_cfg import CableObjectCfg + + +def add_cable_entry_to_builder( + builder, + entry: CableRegistryEntry, + env_idx: int, + env_position: list[float], + env_rotation: list[float] | tuple[float, float, float, float], +) -> None: + """Add one cable to a Newton ``ModelBuilder`` for one environment. + + Composes the env transform with the cable's init transform and applies it to + each control point, then calls :meth:`newton.ModelBuilder.add_rod_graph` with + the explicit stiffness / damping / density fields stored on the entry. + Density flows through :class:`newton.ModelBuilder.ShapeConfig` so Newton + computes per-segment mass from ``density * pi * r^2 * segment_length``. The + articulation is labelled ``"{entry.prim_path}/cable"`` so the cloner's + ``_rename_builder_labels`` rewrites the source prefix to each env's + destination prefix during replication. + + Args: + builder: The Newton ``ModelBuilder``. + entry: Registry entry describing the cable's geometry and material. + env_idx: Zero-based environment (world) index. + env_position: World translation ``[x, y, z]`` [m] for this environment. + env_rotation: World orientation as quaternion ``(x, y, z, w)`` for this environment. + """ + env_pos = wp.vec3(float(env_position[0]), float(env_position[1]), float(env_position[2])) + env_rot = wp.quat( + float(env_rotation[0]), + float(env_rotation[1]), + float(env_rotation[2]), + float(env_rotation[3]), + ) + init_pos = wp.vec3(float(entry.init_pos[0]), float(entry.init_pos[1]), float(entry.init_pos[2])) + init_rot = wp.quat( + float(entry.init_rot[0]), + float(entry.init_rot[1]), + float(entry.init_rot[2]), + float(entry.init_rot[3]), + ) + + # Compose: world = env_T ∘ init_T ∘ local + composed_pos = env_pos + wp.quat_rotate(env_rot, init_pos) + composed_rot = env_rot * init_rot + + world_nodes: list[wp.vec3] = [] + for node in entry.node_positions: + rotated = wp.quat_rotate(composed_rot, node) + world_nodes.append(composed_pos + rotated) + + shape_cfg = newton.ModelBuilder.ShapeConfig() + shape_cfg.density = float(entry.density) + + # ``label`` is load-bearing: Newton suffixes ``_articulation`` to produce + # ``{prim_path}/cable_articulation``, which is the path :class:`ArticulationView` + # searches for per env after the cloner rewrites the source prefix. + builder.add_rod_graph( + node_positions=world_nodes, + edges=entry.edges, + radius=entry.radius, + cfg=shape_cfg, + stretch_stiffness=entry.stretch_stiffness, + stretch_damping=entry.stretch_damping, + bend_stiffness=entry.bend_stiffness, + bend_damping=entry.bend_damping, + label=f"{entry.prim_path}/cable", + wrap_in_articulation=True, + ) + + +def add_registered_cables_to_builder( + builder, + world_idx: int, + env_position: list[float], + env_rotation: list[float] | tuple[float, float, float, float], +) -> None: + """Loop function for ``_per_world_builder_hooks``. + + Iterates :attr:`SimulationManager._cable_registry` and calls + :func:`add_cable_entry_to_builder` for each registered cable. + Mirrors :func:`isaaclab_contrib.deformable.deformable_object.add_registered_deformables_to_builder`. + """ + for entry in SimulationManager._cable_registry: + add_cable_entry_to_builder(builder, entry, world_idx, env_position, env_rotation) + + +def install_cable_builder_hooks() -> None: + """Set up the cable registry and per-world hook on ``SimulationManager``. + + Resets ``_cable_registry`` to an empty list on each call — install is intended + to be called once per scene setup, not per asset. + + Mirrors :func:`isaaclab_contrib.deformable.deformable_object.install_deformable_builder_hooks` + (see ``deformable_object.py:190-201``). + """ + SimulationManager._cable_registry = [] + if not hasattr(SimulationManager, "_per_world_builder_hooks"): + SimulationManager._per_world_builder_hooks = [] + if add_registered_cables_to_builder not in SimulationManager._per_world_builder_hooks: + SimulationManager._per_world_builder_hooks.append(add_registered_cables_to_builder) + + +class CableObject(Articulation): + """Cable / 1D-rod asset (Newton backend). + + Subclasses :class:`Articulation` so the cable's per-segment poses and + per-cable-joint state are exposed via :class:`ArticulationData` with no + parallel data class. + + Override surface beyond the base: + + - :meth:`__init__` defers to the base ``__init__`` and then calls + :meth:`_register_cable` (mirroring :meth:`DeformableObject._register_deformable`), + which builds a :class:`CableRegistryEntry` from cfg and appends it to the + cable registry. Caller must have called :func:`install_cable_builder_hooks` + before constructing any :class:`CableObject` (typical: from a solver manager + init, mirroring how the deformable contrib package wires things up). + """ + + cfg: CableObjectCfg + + def __init__(self, cfg: CableObjectCfg): + """Initialize the cable object. + + Args: + cfg: A configuration instance. + """ + super().__init__(cfg) + + # Read the cable's centerline / material from cfg and register in the + # cable registry. Mirrors :meth:`DeformableObject._register_deformable`. + self._registry_entry = self._register_cable() + + def _register_cable(self) -> CableRegistryEntry: + """Read cable geometry + material from the spawned USD prim and register on + :attr:`SimulationManager._cable_registry`. + + Mirrors :meth:`DeformableObject._register_deformable`: + + 1. Locate the spawned template prim (via ``cfg.spawn.spawn_path`` or + ``cfg.prim_path``). + 2. Find the single ``UsdGeomBasisCurves`` child authored by + :func:`spawn_cable` and read its ``points`` and ``widths`` attributes. + 3. Bake the template prim's xform into the per-node positions so the + replicate hook only needs to apply the env transform. + 4. Look up the bound Newton cable physics material and read each + ``newton:*`` attribute into the entry, falling back to the + :class:`CableRegistryEntry` field defaults when an attribute is + missing. + + Returns: + The registry entry (also appended to ``SimulationManager._cable_registry``). + + Raises: + ValueError: If ``cfg.spawn`` is not a :class:`~isaaclab.sim.spawners.shapes.CableCfg`, + the template prim has no ``UsdGeomBasisCurves`` child, the curve + is missing its ``widths`` attribute, or no Newton cable physics + material is bound to the curve prim (commonly because + :class:`UsdPhysics.CollisionAPI` was not applied — set + ``CableCfg.collision_props`` so :func:`spawn_cable` applies it). + RuntimeError: If the template prim cannot be located, or + :func:`install_cable_builder_hooks` has not been called before + constructing the :class:`CableObject`. + + Note: + ``pxr`` imports are deferred to this method (not module level) so + that ``resolve_task_config`` can import the env-cfg module before + Kit starts without polluting the ``pxr`` module cache. + """ + from pxr import Gf, UsdGeom, UsdPhysics, UsdShade + + if not isinstance(self.cfg.spawn, CableCfg): + raise ValueError( + f"CableObjectCfg requires `spawn` to be a CableCfg instance, got {type(self.cfg.spawn).__name__}." + ) + if not hasattr(SimulationManager, "_cable_registry"): + raise RuntimeError( + "CableObject requires `install_cable_builder_hooks()` to have been called" + " before constructing any CableObject instance (typically from the solver" + " manager init, mirroring the deformable contrib pattern)." + ) + + # Resolve the spawned template prim. ``spawn_path`` is set by InteractiveScene's + # template-based cloning flow; falls back to ``prim_path`` for direct envs that + # spawn straight at the cloned regex. + lookup_path = self.cfg.spawn.spawn_path if self.cfg.spawn.spawn_path is not None else self.cfg.prim_path + template_prim = sim_utils.find_first_matching_prim(lookup_path) + if template_prim is None: + raise RuntimeError(f"Failed to find cable template prim for expression: '{lookup_path}'.") + template_prim_path = template_prim.GetPrimPath() + + # Find the single UsdGeomBasisCurves child authored by spawn_cable. + curve_prims = sim_utils.get_all_matching_child_prims( + template_prim_path, lambda p: p.GetTypeName() == "BasisCurves" + ) + if len(curve_prims) != 1: + raise ValueError( + f"Expected exactly one UsdGeomBasisCurves prim under '{template_prim_path}', found {len(curve_prims)}." + ) + curve_prim = curve_prims[0] + curves = UsdGeom.BasisCurves(curve_prim) + + # Bake the curve prim's xform into the per-node positions so the replicate + # hook only needs to apply the env transform. + xform_cache = UsdGeom.XformCache() + curve_to_parent_frame = ( + xform_cache.GetLocalToWorldTransform(curve_prim) + * xform_cache.GetLocalToWorldTransform(template_prim.GetParent()).GetInverse() + ) + raw_points = curves.GetPointsAttr().Get() + node_positions: list[wp.vec3] = [] + for p in raw_points: + q = curve_to_parent_frame.Transform(Gf.Vec3d(float(p[0]), float(p[1]), float(p[2]))) + node_positions.append(wp.vec3(float(q[0]), float(q[1]), float(q[2]))) + + # Read the capsule width (per-control-point but broadcast equal by spawn_cable). + raw_widths = curves.GetWidthsAttr().Get() + if raw_widths is None or len(raw_widths) == 0: + raise ValueError(f"UsdGeomBasisCurves at '{curve_prim.GetPrimPath()}' is missing the `widths` attribute.") + radius = float(raw_widths[0]) / 2.0 + + # Linear edge chain. + edges = [(i, i + 1) for i in range(len(node_positions) - 1)] + + # Look up the bound Newton cable physics material via the standard + # MaterialBindingAPI on the curve prim. The material binding requires + # :class:`UsdPhysics.CollisionAPI` on the curve prim (see + # :func:`bind_physics_material`); the most common reason no material is + # found is that the user omitted ``CableCfg.collision_props`` so the + # spawner's bind silently no-op'd. + material_targets = ( + UsdShade.MaterialBindingAPI(curve_prim).GetDirectBindingRel("physics").GetTargets() + if curve_prim.HasAPI(UsdShade.MaterialBindingAPI) + else [] + ) + stage = curve_prim.GetStage() + material_prim = None + for mat_path in material_targets: + mat_prim = stage.GetPrimAtPath(mat_path) + if mat_prim.GetAttribute("newton:density").IsValid(): + material_prim = mat_prim + break + if material_prim is None: + has_collision_api = curve_prim.HasAPI(UsdPhysics.CollisionAPI) + hint = ( + "" + if has_collision_api + else ( + " Hint: the curve has no `UsdPhysics.CollisionAPI`, which `bind_physics_material`" + " requires; set `CableCfg.collision_props = sim_utils.CollisionPropertiesCfg()` so" + " `spawn_cable` applies the API (cables are currently Newton-only, and the API has" + " no PhysX runtime effect since the cable is in the cloner's `_cable_ignore_paths`)." + ) + ) + raise ValueError( + f"Could not find a Newton cable physics material bound to '{curve_prim.GetPrimPath()}'." + hint + ) + + def _get_material_attr(name: str, default): + attr = material_prim.GetAttribute(name) + return attr.Get() if attr.IsValid() else default + + stretch_stiffness = _get_material_attr("newton:stretchStiffness", CableRegistryEntry.stretch_stiffness) + bend_stiffness = _get_material_attr("newton:bendStiffness", CableRegistryEntry.bend_stiffness) + stretch_damping = _get_material_attr("newton:stretchDamping", CableRegistryEntry.stretch_damping) + bend_damping = _get_material_attr("newton:bendDamping", CableRegistryEntry.bend_damping) + density = _get_material_attr("newton:density", CableRegistryEntry.density) + + # init_pos/init_rot default to identity — the template xform is already baked + # into ``node_positions`` above, so the replicate hook only applies the env + # transform. Matches DeformableObject._register_deformable. + entry = CableRegistryEntry( + prim_path=self.cfg.prim_path, + node_positions=node_positions, + edges=edges, + radius=radius, + stretch_stiffness=float(stretch_stiffness), + bend_stiffness=float(bend_stiffness), + stretch_damping=float(stretch_damping), + bend_damping=float(bend_damping), + density=float(density), + ) + SimulationManager._cable_registry.append(entry) + return entry diff --git a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object_cfg.py new file mode 100644 index 000000000000..af53c77482e2 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object_cfg.py @@ -0,0 +1,37 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Configuration for the cable asset class.""" + +from __future__ import annotations + +from isaaclab.actuators import ActuatorBaseCfg +from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg +from isaaclab.utils import configclass + + +@configclass +class CableObjectCfg(ArticulationCfg): + """Configuration for a cable / 1D-rod asset (Newton backend). + + Inherits all of :class:`ArticulationCfg` and overrides two defaults so the + base :meth:`Articulation._initialize_impl` runs unchanged on cables: + + - ``articulation_root_prim_path = "/cable_articulation"`` — the sub-label + that :meth:`newton.ModelBuilder.add_rod_graph` produces under the cable's + source prim path (``f"{label}_articulation"`` where ``label`` is + ``"{prim_path}/cable"``). The base method composes this with + ``cfg.prim_path`` and uses the result as the label pattern for + :class:`newton.selection.ArticulationView`. + - ``actuators = {}`` — cables have no user-defined actuators (cable joint + stiffness is material-like, applied internally by the solver). The + inherited ``_process_actuators_cfg`` iterates an empty dict safely and + emits a harmless ``logger.warning("Not all actuators are configured!")`` + — expected and not suppressed in Phase 1. + """ + + class_type: type = "{DIR}.cable_object:CableObject" + articulation_root_prim_path: str | None = "/cable_articulation" + actuators: dict[str, ActuatorBaseCfg] = {} diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py index 3c7fd10eb03e..675bcfd744fd 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py @@ -181,38 +181,13 @@ def add_registered_deformables_to_builder( add_deformable_entry_to_builder(builder, entry, world_idx, env_position, env_rotation) -def color_registered_deformables(builder) -> None: - """Color the Newton builder when deformables were registered.""" - if SimulationManager._deformable_registry: - builder.color() - - def install_deformable_builder_hooks() -> None: """Install deformable builder hooks without removing hooks owned by other extensions.""" SimulationManager._deformable_registry = [] if not hasattr(SimulationManager, "_per_world_builder_hooks"): SimulationManager._per_world_builder_hooks = [] - if not hasattr(SimulationManager, "_post_replicate_hooks"): - SimulationManager._post_replicate_hooks = [] if add_registered_deformables_to_builder not in SimulationManager._per_world_builder_hooks: SimulationManager._per_world_builder_hooks.append(add_registered_deformables_to_builder) - if color_registered_deformables not in SimulationManager._post_replicate_hooks: - SimulationManager._post_replicate_hooks.append(color_registered_deformables) - - -def clear_deformable_builder_hooks() -> None: - """Clear deformable registry state and remove only deformable-owned builder hooks.""" - SimulationManager._deformable_registry = [] - if hasattr(SimulationManager, "_per_world_builder_hooks"): - SimulationManager._per_world_builder_hooks = [ - hook - for hook in SimulationManager._per_world_builder_hooks - if hook is not add_registered_deformables_to_builder - ] - if hasattr(SimulationManager, "_post_replicate_hooks"): - SimulationManager._post_replicate_hooks = [ - hook for hook in SimulationManager._post_replicate_hooks if hook is not color_registered_deformables - ] class DeformableObject(BaseDeformableObject): diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py index e9358ff0be23..99e2082a341f 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py @@ -31,6 +31,8 @@ class VBDSolverCfg(NewtonSolverCfg): solver_type: str = "vbd" + requires_graph_coloring: bool = True + iterations: int = 10 """Number of VBD iterations per substep.""" @@ -108,6 +110,8 @@ class CoupledMJWarpVBDSolverCfg(NewtonSolverCfg): solver_type: str = "coupledmjwarpvbd" + requires_graph_coloring: bool = True + rigid_solver_cfg: MJWarpSolverCfg = MJWarpSolverCfg() """Rigid-body sub-solver configuration for :class:`MJWarpSolverCfg`.""" @@ -145,6 +149,8 @@ class CoupledFeatherstoneVBDSolverCfg(NewtonSolverCfg): solver_type: str = "coupledfeatherstonevbd" + requires_graph_coloring: bool = True + rigid_solver_cfg: FeatherstoneSolverCfg = FeatherstoneSolverCfg() """Rigid-body sub-solver configuration for :class:`FeatherstoneSolverCfg`.""" diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py index 88380f078673..2f85c158a12b 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -19,11 +19,9 @@ from isaaclab.sim.utils.stage import get_current_stage -from .deformable_object import ( - add_deformable_entry_to_builder, - clear_deformable_builder_hooks, - install_deformable_builder_hooks, -) +from isaaclab_contrib.cable.cable_object import install_cable_builder_hooks + +from .deformable_object import install_deformable_builder_hooks from .newton_manager_cfg import VBDSolverCfg if TYPE_CHECKING: @@ -54,14 +52,10 @@ def initialize(cls, sim_context: SimulationContext) -> None: # Experimental deformable support registers callbacks here so the manager # and cloner can invoke them without hard-coding deformable logic. install_deformable_builder_hooks() + install_cable_builder_hooks() super().initialize(sim_context) - @classmethod - def _solver_specific_clear(cls): - """Clear VBD-specific state.""" - clear_deformable_builder_hooks() - @classmethod def _get_deformable_ignore_paths(cls) -> list[str]: """Return USD prim paths to skip when calling ``builder.add_usd``. @@ -199,9 +193,11 @@ def instantiate_builder_from_stage(cls): # No env Xforms — flat loading builder.add_usd(stage, ignore_paths=deformable_ignore_paths, schema_resolvers=schema_resolvers) - # Add deformable bodies from the registry (single world at origin). - for entry in cls._deformable_registry: - add_deformable_entry_to_builder(builder, entry, 0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]) + # Run per-world builder hooks for the single world at origin. + # Hooks include deformable and cable registries; each owns its own registration. + if hasattr(cls, "_per_world_builder_hooks"): + for hook in cls._per_world_builder_hooks: + hook(builder, 0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]) else: # Load everything except the env subtrees (ground plane, lights, etc.) ignore_paths = [path for _, path in env_paths] + deformable_ignore_paths @@ -246,9 +242,10 @@ def instantiate_builder_from_stage(cls): for proto_shape_idx in proto_shape_indices: local_site_map[label][col].append(offset + proto_shape_idx) - # Add deformable bodies from the registry into this world. - for entry in cls._deformable_registry: - add_deformable_entry_to_builder(builder, entry, col, list(pos), quat) + # Run per-world builder hooks for this world (deformables, cables, ...). + if hasattr(cls, "_per_world_builder_hooks"): + for hook in cls._per_world_builder_hooks: + hook(builder, col, list(pos), list(quat)) builder.end_world() @@ -258,9 +255,8 @@ def instantiate_builder_from_stage(cls): } NewtonManager._num_envs = len(env_paths) - # Call builder.color() if any deformable entries were added (required by VBD solver) - if cls._deformable_registry: - builder.color() + # run vbd builder coloring + builder.color() cls.set_builder(builder) @@ -280,6 +276,11 @@ def _build_solver(cls, model: Model, solver_cfg: VBDSolverCfg) -> None: @classmethod def _simulate_physics_only(cls) -> None: # Rebuild BVH once per step for solvers that require it (e.g. VBD cloth). - if hasattr(cls._solver, "rebuild_bvh"): + # Guard against Newton versions where ``SolverVBD`` did not initialize + # ``particle_enable_self_contact`` when ``model.particle_count == 0`` + # (rigid-body-only or cable-only scenes). In that case ``rebuild_bvh`` + # would raise ``AttributeError``; the call is a no-op anyway since there + # are no particles to rebuild BVH for. + if hasattr(cls._solver, "rebuild_bvh") and getattr(cls._solver, "particle_enable_self_contact", False): cls._solver.rebuild_bvh(cls._state_0) super()._simulate_physics_only() diff --git a/source/isaaclab_contrib/test/cable/test_cable_object.py b/source/isaaclab_contrib/test/cable/test_cable_object.py new file mode 100644 index 000000000000..fbea11b606dd --- /dev/null +++ b/source/isaaclab_contrib/test/cable/test_cable_object.py @@ -0,0 +1,273 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Tests for the cable asset, registry, and replicate-hook plumbing.""" + +import math + +import pytest +import warp as wp +from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg + +from isaaclab_contrib.cable.cable_object import CableRegistryEntry + + +def test_install_cable_builder_hooks_is_idempotent(monkeypatch): + """Repeated install must not duplicate registrations on _per_world_builder_hooks.""" + from isaaclab_newton.physics import NewtonManager as SimulationManager + + from isaaclab_contrib.cable.cable_object import ( + add_registered_cables_to_builder, + install_cable_builder_hooks, + ) + + # Reset state so the test is self-contained. + monkeypatch.setattr(SimulationManager, "_per_world_builder_hooks", [], raising=False) + monkeypatch.delattr(SimulationManager, "_cable_registry", raising=False) + + install_cable_builder_hooks() + install_cable_builder_hooks() + install_cable_builder_hooks() + + assert SimulationManager._cable_registry == [] + matches = [h for h in SimulationManager._per_world_builder_hooks if h is add_registered_cables_to_builder] + assert len(matches) == 1, "install_cable_builder_hooks must be idempotent" + + +def test_add_registered_cables_iterates_registry(monkeypatch): + """The loop function dispatches to add_cable_entry_to_builder per registry entry.""" + from isaaclab_newton.physics import NewtonManager as SimulationManager + + from isaaclab_contrib.cable.cable_object import add_registered_cables_to_builder + + monkeypatch.setattr(SimulationManager, "_per_world_builder_hooks", [], raising=False) + + calls = [] + + def _fake_entry_hook(builder, entry, env_idx, env_pos, env_rot): + calls.append((entry.prim_path, env_idx)) + + monkeypatch.setattr( + "isaaclab_contrib.cable.cable_object.add_cable_entry_to_builder", + _fake_entry_hook, + ) + entries = [ + CableRegistryEntry( + prim_path="/World/cable_a", + node_positions=[wp.vec3(0, 0, 0), wp.vec3(1, 0, 0)], + edges=[(0, 1)], + radius=0.005, + ), + CableRegistryEntry( + prim_path="/World/cable_b", + node_positions=[wp.vec3(0, 0, 0), wp.vec3(1, 0, 0)], + edges=[(0, 1)], + radius=0.005, + ), + ] + monkeypatch.setattr(SimulationManager, "_cable_registry", entries, raising=False) + + add_registered_cables_to_builder(builder=None, world_idx=3, env_position=[0, 0, 0], env_rotation=[0, 0, 0, 1]) + + assert calls == [("/World/cable_a", 3), ("/World/cable_b", 3)] + + +class _FakeBuilder: + """Records the arguments passed to add_rod_graph for assertion.""" + + def __init__(self): + self.calls = [] + + def add_rod_graph(self, **kwargs): + self.calls.append(kwargs) + return [], [] # body_indices, joint_indices — match Newton's signature + + +@pytest.mark.parametrize( + "env_rotation, env_position, init_pos, init_rot, expected_np0, expected_np1", + [ + # Identity case (was test 4): verifies field-forwarding + translation composition. + ( + [0.0, 0.0, 0.0, 1.0], # env identity + [1.0, 0.0, 0.0], # env_t = (1, 0, 0) + (0.0, 0.0, 1.0), # init_t = (0, 0, 1) + (0.0, 0.0, 0.0, 1.0), # init identity + (1.0, 0.0, 1.0), # node[0] world = env_t + init_t = (1, 0, 1) + (1.1, 0.0, 1.0), # node[1] world = (1.1, 0, 1) + ), + # 90° CCW about Z (was test 5): verifies composed rotation. + ( + [0.0, 0.0, math.sqrt(2.0) / 2.0, math.sqrt(2.0) / 2.0], + [0.0, 0.0, 0.0], + (0.0, 1.0, 0.0), # init_t = (0, 1, 0) + (0.0, 0.0, 0.0, 1.0), + (-1.0, 0.0, 0.0), # R_z(90°)·(0, 1, 0) = (-1, 0, 0) + (-1.0, 0.1, 0.0), # node[1] = (-1, 0, 0) + R_z(90°)·(0.1, 0, 0) = (-1, 0.1, 0) + ), + ], + ids=["identity", "env_rotation_z90"], +) +def test_add_cable_entry_to_builder(env_rotation, env_position, init_pos, init_rot, expected_np0, expected_np1): + """add_cable_entry_to_builder transforms positions correctly and forwards + all material/geometry params to add_rod_graph.""" + from isaaclab_contrib.cable.cable_object import add_cable_entry_to_builder + + entry = CableRegistryEntry( + prim_path="/World/Cable", + node_positions=[wp.vec3(0.0, 0.0, 0.0), wp.vec3(0.1, 0.0, 0.0)], + edges=[(0, 1)], + radius=0.005, + init_pos=init_pos, + init_rot=init_rot, + stretch_stiffness=2.0e9, + bend_stiffness=1.0e-3, + stretch_damping=0.0, + bend_damping=1.0e-4, + density=1200.0, + ) + builder = _FakeBuilder() + add_cable_entry_to_builder(builder, entry, env_idx=0, env_position=env_position, env_rotation=env_rotation) + + assert len(builder.calls) == 1 + call = builder.calls[0] + + np0 = call["node_positions"][0] + np1 = call["node_positions"][1] + assert float(np0[0]) == pytest.approx(expected_np0[0], abs=1e-5) + assert float(np0[1]) == pytest.approx(expected_np0[1], abs=1e-5) + assert float(np0[2]) == pytest.approx(expected_np0[2], abs=1e-5) + assert float(np1[0]) == pytest.approx(expected_np1[0], abs=1e-5) + assert float(np1[1]) == pytest.approx(expected_np1[1], abs=1e-5) + assert float(np1[2]) == pytest.approx(expected_np1[2], abs=1e-5) + + # Field forwarding (only need to assert once; same across all rows). + assert call["edges"] == [(0, 1)] + assert call["radius"] == pytest.approx(0.005) + assert call["stretch_stiffness"] == pytest.approx(2.0e9) + assert call["bend_stiffness"] == pytest.approx(1.0e-3) + assert call["bend_damping"] == pytest.approx(1.0e-4) + assert call["label"] == "/World/Cable/cable" + assert float(call["cfg"].density) == pytest.approx(1200.0) + + +def test_cable_object_cfg_defaults(): + """CableObjectCfg overrides actuators and articulation_root_prim_path.""" + import isaaclab.sim as sim_utils + + from isaaclab_contrib.cable import CableObjectCfg + + cfg = CableObjectCfg( + prim_path="/World/Cable", + spawn=sim_utils.CableCfg( + positions=[(0.0, 0.0, 0.0), (0.1, 0.0, 0.0), (0.2, 0.0, 0.0)], + width=0.01, + physics_material=NewtonCableMaterialCfg(), + ), + ) + assert cfg.articulation_root_prim_path == "/cable_articulation" + assert cfg.actuators == {} + + +@pytest.mark.parametrize( + "setup_registry, spawn, expected_exc, expected_match", + [ + # spawn=None → ValueError mentioning "CableCfg" + (True, None, ValueError, "CableCfg"), + # registry not installed → RuntimeError mentioning "install_cable_builder_hooks" + (False, "valid", RuntimeError, "install_cable_builder_hooks"), + ], + ids=["spawn_none", "hooks_not_installed"], +) +def test_cable_object_init_failure_paths(monkeypatch, setup_registry, spawn, expected_exc, expected_match): + """CableObject.__init__ raises clear errors on invalid cfg or missing setup.""" + from isaaclab_newton.assets.articulation.articulation import Articulation + from isaaclab_newton.physics import NewtonManager as SimulationManager + + import isaaclab.sim as sim_utils + + from isaaclab_contrib.cable import CableObject, CableObjectCfg + + if setup_registry: + monkeypatch.setattr(SimulationManager, "_cable_registry", [], raising=False) + else: + monkeypatch.delattr(SimulationManager, "_cable_registry", raising=False) + monkeypatch.setattr(Articulation, "__init__", lambda self, cfg: setattr(self, "cfg", cfg)) + + # "valid" sentinel → construct a real CableCfg + if spawn == "valid": + spawn_value = sim_utils.CableCfg( + positions=[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], + width=0.01, + physics_material=NewtonCableMaterialCfg(), + ) + else: + spawn_value = spawn + + cfg = CableObjectCfg(prim_path="/World/Cable", spawn=spawn_value) + with pytest.raises(expected_exc, match=expected_match): + CableObject(cfg) + + +def test_cable_replicate_body_count(): + """Spawn 2 cables in env_0, replicate to 4 envs, verify total body count. + + Each cable has 3 control points → 2 segments per cable. + Total cable bodies in builder = 4 envs × 2 cables × 2 segments = 16. + """ + from isaaclab_newton.physics import FeatherstoneSolverCfg, NewtonCfg + from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg as _NewtonCableMaterialCfg + + import isaaclab.sim as sim_utils + from isaaclab.scene import InteractiveScene, InteractiveSceneCfg + from isaaclab.sim import SimulationCfg, build_simulation_context + from isaaclab.utils import configclass + + from isaaclab_contrib.cable import CableObjectCfg + from isaaclab_contrib.cable.cable_object import install_cable_builder_hooks + + cable_spawn = sim_utils.CableCfg( + positions=[(0.0, 0.0, 0.0), (0.1, 0.0, 0.0), (0.2, 0.0, 0.0)], + width=0.01, + physics_material=_NewtonCableMaterialCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + ) + + @configclass + class _SceneCfg(InteractiveSceneCfg): + num_envs: int = 4 + env_spacing: float = 1.0 + cable_a: CableObjectCfg = CableObjectCfg(prim_path="{ENV_REGEX_NS}/CableA", spawn=cable_spawn) + cable_b: CableObjectCfg = CableObjectCfg(prim_path="{ENV_REGEX_NS}/CableB", spawn=cable_spawn) + + # Cables need install_cable_builder_hooks called once before scene init. + # This mirrors how NewtonVBDManager.initialize() calls + # install_deformable_builder_hooks() before the deformable scene is set up. + install_cable_builder_hooks() + + newton_sim_cfg = SimulationCfg( + physics=NewtonCfg(solver_cfg=FeatherstoneSolverCfg()), + ) + + with build_simulation_context(device="cuda:0", sim_cfg=newton_sim_cfg, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + InteractiveScene(_SceneCfg()) + sim.reset() # triggers newton_physics_replicate, materializing cable bodies + + from isaaclab_newton.physics import NewtonManager + + model = NewtonManager.get_model() + + # Newton labels each cable body as "{prim_path}_cable_edge_body_{i}" before + # label renaming and "{env_dest}/cable_edge_body_{i}" after. + # Both forms contain the substring "cable_edge_body_". + cable_body_count = sum(1 for label in model.body_label if "cable_edge_body_" in label) + assert cable_body_count == 16, f"expected 16 cable bodies, got {cable_body_count}" diff --git a/source/isaaclab_newton/changelog.d/mym-cable.minor.rst b/source/isaaclab_newton/changelog.d/mym-cable.minor.rst new file mode 100644 index 000000000000..937426be6908 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/mym-cable.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.spawners.materials.NewtonCableMaterialCfg` + for cable rod material parameters (stretch / bend stiffness, damping, density). +* Added a per-cable ignore-paths block in :func:`newton_physics_replicate` so + ``add_usd`` skips cable ``BasisCurves`` prims (materialized via the per-world + builder hook instead). diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py index e99b1eb7abdd..9d3969a6df16 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py @@ -69,17 +69,22 @@ def _build_newton_builder_from_mapping( import re _deformable_ignore_paths: list[str] = [] + registry_entries = [] if hasattr(NewtonManager, "_deformable_registry"): - for entry in NewtonManager._deformable_registry: - pat = re.compile(entry.prim_path.replace(".*", "[^/]*") + "$") - for src_path in sources: - # Check if any prim under this source matches the deformable pattern - prim = stage.GetPrimAtPath(src_path) - if prim.IsValid(): - for child in Usd.PrimRange(prim): - child_path = str(child.GetPath()) - if pat.match(child_path): - _deformable_ignore_paths.append(child_path) + registry_entries.extend(NewtonManager._deformable_registry) + if hasattr(NewtonManager, "_cable_registry"): + registry_entries.extend(NewtonManager._cable_registry) + + for entry in registry_entries: + pat = re.compile(entry.prim_path.replace(".*", "[^/]*") + "$") + for src_path in sources: + # Check if any prim under this source matches the deformable pattern + prim = stage.GetPrimAtPath(src_path) + if prim.IsValid(): + for child in Usd.PrimRange(prim): + child_path = str(child.GetPath()) + if pat.match(child_path): + _deformable_ignore_paths.append(child_path) protos: dict[str, ModelBuilder] = {} for src_path in sources: @@ -136,10 +141,10 @@ def _build_newton_builder_from_mapping( # end the world context builder.end_world() - # Run post-replicate hooks (e.g. builder.color() for deformable coloring). - if hasattr(NewtonManager, "_post_replicate_hooks"): - for hook in NewtonManager._post_replicate_hooks: - hook(builder) + # Run graph coloring when solver requires it (e.g. VBD) + solver_cfg = getattr(NewtonManager._cfg, "solver_cfg", None) + if getattr(solver_cfg, "requires_graph_coloring", False): + builder.color() site_index_map = { **global_site_map, 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..bfcec02cfe5f 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py @@ -40,6 +40,13 @@ class NewtonSolverCfg: override it. """ + requires_graph_coloring: bool = False + """Whether the solver requires graph coloring of the model's vertices. + + This is used by the model builder to determine whether to run the coloring + pass on the model's vertices. The coloring is used to parallelize vertex updates in solvers like VBD. + """ + solver_type: str = "None" """Solver type metadata (deprecated). diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi index c3f13216f781..2da8d68fbbb9 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi @@ -5,6 +5,7 @@ __all__ = [ "spawn_deformable_body_material", + "NewtonCableMaterialCfg", "NewtonDeformableBodyMaterialCfg", "NewtonDeformableMaterialCfg", "NewtonSurfaceDeformableBodyMaterialCfg", @@ -12,6 +13,7 @@ __all__ = [ from .physics_materials import spawn_deformable_body_material from .physics_materials_cfg import ( + NewtonCableMaterialCfg, NewtonDeformableBodyMaterialCfg, NewtonDeformableMaterialCfg, NewtonSurfaceDeformableBodyMaterialCfg, diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py index f7b63a8701c6..8d867e15dbd3 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py @@ -10,6 +10,7 @@ from isaaclab.sim.spawners.materials.physics_materials_cfg import ( DeformableBodyMaterialBaseCfg, + PhysicsMaterialCfg, SurfaceDeformableBodyMaterialBaseCfg, ) from isaaclab.utils.configclass import configclass @@ -77,3 +78,37 @@ class NewtonSurfaceDeformableBodyMaterialCfg(SurfaceDeformableBodyMaterialBaseCf edge_kd: float = 1e-2 """Bending damping [N*m*s]. Used by Newton backend for cloth meshes.""" + + +@configclass +class NewtonCableMaterialCfg(PhysicsMaterialCfg): + """Newton-specific physics material for cable rods. + + Authored as a ``UsdShade.Material`` prim with ``newton:*`` attributes via the + generic :func:`spawn_deformable_body_material` helper. :class:`CableObject` + reads these fields directly from ``cfg.physics_material`` when constructing + the registry entry. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = None + _usd_field_exceptions: ClassVar[dict] = {} + + func: Callable | str = "isaaclab.sim.spawners.materials.physics_materials:spawn_deformable_body_material" + + stretch_stiffness: float = 1.0e9 + """Material-like axial stiffness EA [N]; normalized internally by segment length.""" + + bend_stiffness: float = 0.0 + """Material-like bend/twist stiffness EI [N*m^2]; normalized internally by segment length.""" + + stretch_damping: float = 0.0 + """Per-joint stretch damping [N*s/m].""" + + bend_damping: float = 0.0 + """Per-joint bend/twist damping [N*m*s/rad].""" + + density: float = 1500.0 + """Material density [kg/m^3]. Converted to per-segment mass via the capsule + shape's volume (``pi * radius^2 * segment_length * density``) by the cable + replicate hook before calling :meth:`newton.ModelBuilder.add_rod_graph`.""" From 7eae557191e7f743d733e940ecf34e7bc935594c Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Fri, 15 May 2026 13:07:40 +0200 Subject: [PATCH 02/26] Style: Add comments on cable being newton only --- .../isaaclab/sim/spawners/shapes/shapes.py | 7 +++++++ .../sim/spawners/shapes/shapes_cfg.py | 19 ++++++++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index 90fd56d7ffdc..e29fca3ab086 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -249,6 +249,13 @@ def spawn_cable( calling :meth:`newton.ModelBuilder.add_rod_graph`; ``rigid_props`` and ``mass_props`` are rejected up front because they don't apply to cables. + .. warning:: + Cables are currently **only supported on the Newton physics backend**. The + spawner itself only authors USD (which works on any backend), but the + resulting cable will not be simulated under PhysX — + :class:`~isaaclab_contrib.cable.CableObject` will refuse to register if + the active backend is not Newton. + .. note:: This function is decorated with :func:`clone` that resolves prim path into list of paths if the input prim path is a regex pattern. This is done to support spawning multiple assets diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py index eebefd0691cc..b077d5ff8c62 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py @@ -130,12 +130,21 @@ class CableCfg(ShapeCfg): explicit list of control points. Physics is materialized at model-build time by the Newton replicate hook calling :meth:`newton.ModelBuilder.add_rod_graph`. + .. note:: + Cables are currently **only supported on the Newton physics backend**. + ``physics_material`` must be a + :class:`~isaaclab_newton.sim.spawners.materials.NewtonCableMaterialCfg` + (``ValueError`` otherwise), and any :class:`~isaaclab_contrib.cable.CableObject` + built from this cfg only registers in Newton's solver pipeline. + The cable's stretch/bend stiffness, damping, and density live on - ``physics_material`` (a :class:`~isaaclab_newton.sim.spawners.materials.NewtonCableMaterialCfg` instance from - :mod:`isaaclab_newton.sim.spawners.materials`, inherited slot from - :class:`ShapeCfg`). ``rigid_props``, ``mass_props``, ``collision_props`` are - inherited from :class:`ShapeCfg` but are not used by cables — :func:`spawn_cable` - raises ``ValueError`` if any is non-None. + ``physics_material`` (inherited slot from :class:`ShapeCfg`). ``rigid_props`` + and ``mass_props`` are inherited from :class:`ShapeCfg` but are not used by + cables — :func:`spawn_cable` raises ``ValueError`` if either is non-None. + ``collision_props`` is required because :func:`spawn_cable` relies on + :class:`UsdPhysics.CollisionAPI` to author a usable physics-material binding; + the cable is in the Newton cloner's ``_cable_ignore_paths`` so this has no + PhysX runtime effect. """ func: Callable | str = "{DIR}.shapes:spawn_cable" From ddff8945af2cc0822cf887ab4b4f3c5388fa4489 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Fri, 15 May 2026 16:25:42 +0200 Subject: [PATCH 03/26] Feat: Visualization in kit for cables, fabric syncing only supported on cpu for now --- .../isaaclab_contrib/cable/cable_object.py | 14 ++- .../deformable/vbd_manager.py | 108 +++++++++++++++++- .../test/cable/test_cable_object.py | 30 ++++- .../isaaclab_newton/physics/newton_manager.py | 3 + 4 files changed, 151 insertions(+), 4 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py index e9d962a76c47..6355ec18bb7d 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py +++ b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py @@ -17,7 +17,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING import newton @@ -48,6 +48,10 @@ class CableRegistryEntry: bend_damping: float = 0.0 density: float = 1500.0 + # Filled by :func:`add_cable_entry_to_builder`. + body_offsets: list[int] = field(default_factory=list) + last_edge_length: float = 0.0 + from isaaclab_newton.assets.articulation.articulation import Articulation # noqa: E402 from isaaclab_newton.physics import NewtonManager as SimulationManager # noqa: E402 @@ -84,6 +88,10 @@ def add_cable_entry_to_builder( env_position: World translation ``[x, y, z]`` [m] for this environment. env_rotation: World orientation as quaternion ``(x, y, z, w)`` for this environment. """ + if env_idx == 0: + entry.body_offsets.clear() + entry.last_edge_length = 0.0 + env_pos = wp.vec3(float(env_position[0]), float(env_position[1]), float(env_position[2])) env_rot = wp.quat( float(env_rotation[0]), @@ -114,6 +122,7 @@ def add_cable_entry_to_builder( # ``label`` is load-bearing: Newton suffixes ``_articulation`` to produce # ``{prim_path}/cable_articulation``, which is the path :class:`ArticulationView` # searches for per env after the cloner rewrites the source prefix. + entry.body_offsets.append(builder.body_count) builder.add_rod_graph( node_positions=world_nodes, edges=entry.edges, @@ -126,6 +135,9 @@ def add_cable_entry_to_builder( label=f"{entry.prim_path}/cable", wrap_in_articulation=True, ) + if env_idx == 0: + u, v = entry.edges[-1] + entry.last_edge_length = float(wp.length(entry.node_positions[v] - entry.node_positions[u])) def add_registered_cables_to_builder( diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py index 2f85c158a12b..e45b204e8572 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -17,6 +17,7 @@ from newton._src.usd.schemas import SchemaResolverNewton, SchemaResolverPhysx from newton.solvers import SolverVBD +from isaaclab.physics import PhysicsManager from isaaclab.sim.utils.stage import get_current_stage from isaaclab_contrib.cable.cable_object import install_cable_builder_hooks @@ -30,12 +31,39 @@ logger = logging.getLogger(__name__) +@wp.kernel(enable_backward=False) +def _sync_cable_curve_points( + fabric_points: wp.fabricarrayarray(dtype=wp.vec3f), + fabric_world_matrices: wp.fabricarray(dtype=wp.mat44d), + body_offsets: wp.fabricarray(dtype=wp.uint32), + body_counts: wp.fabricarray(dtype=wp.uint32), + last_edge_lengths: wp.fabricarray(dtype=wp.float32), + body_q: wp.array(ndim=1, dtype=wp.transformf), +): + """Reconstruct ``UsdGeomBasisCurves`` control points from cable body transforms.""" + i = wp.tid() + offset = int(body_offsets[i]) + count = int(body_counts[i]) + inv_world = wp.inverse(wp.transpose(wp.mat44f(fabric_world_matrices[i]))) + + for j in range(count): + node_world = wp.transform_get_translation(body_q[offset + j]) + fabric_points[i][j] = wp.transform_point(inv_world, node_world) + + tail_world = wp.transform_point(body_q[offset + count - 1], wp.vec3(0.0, 0.0, float(last_edge_lengths[i]))) + fabric_points[i][count] = wp.transform_point(inv_world, tail_world) + + class NewtonVBDManager(NewtonManager): """:class:`NewtonManager` specialization for the VBD solver. Always uses Newton's :class:`CollisionPipeline` for contact handling. """ + _newton_cable_body_offset_attr = "newton:cableBodyOffset" + _newton_cable_body_count_attr = "newton:cableBodyCount" + _newton_cable_last_edge_length_attr = "newton:cableLastEdgeLength" + @classmethod def initialize(cls, sim_context: SimulationContext) -> None: """Initialize the manager with simulation context. @@ -93,8 +121,6 @@ def start_simulation(cls) -> None: # Apply global model parameters from :class:`NewtonModelCfg` to the finalized model. # Sets ``soft_contact_ke/kd/mu`` and optionally overrides per-shape # ``shape_material_ke/kd/mu`` on the Newton model. - from isaaclab.physics import PhysicsManager - cfg = PhysicsManager._cfg if cfg is not None and hasattr(cfg, "model_cfg") and cfg.model_cfg is not None: model = cls._model @@ -150,6 +176,84 @@ def start_simulation(cls) -> None: cls._mark_particles_dirty() cls.sync_particles_to_usd() + if not cls._clone_physics_only and cls._cable_registry: + import re + + import usdrt + + if NewtonManager._usdrt_stage is None: + NewtonManager._usdrt_stage = get_current_stage(fabric=True) + + stage = get_current_stage() + for entry in cls._cable_registry: + curve_template_path = f"{entry.prim_path}/geometry/mesh" + for inst_idx, body_offset in enumerate(entry.body_offsets): + resolved = re.sub(r"(?<=[Ee]nv_)\.\*", str(inst_idx), curve_template_path) + resolved = re.sub(r"\.\*", str(inst_idx), resolved) + curve_prim = stage.GetPrimAtPath(resolved) + usd_points = curve_prim.GetAttribute("points").Get() + fab_prim = NewtonManager._usdrt_stage.GetPrimAtPath(curve_prim.GetPath().pathString) + # Pre-seed Fabric ``points``: without this Hydra reads an empty array on frame 0. + fab_prim.GetAttribute("points").Set( + usdrt.Vt.Vec3fArray([usdrt.Gf.Vec3f(float(p[0]), float(p[1]), float(p[2])) for p in usd_points]) + ) + fab_prim.CreateAttribute(cls._newton_cable_body_offset_attr, usdrt.Sdf.ValueTypeNames.UInt, True) + fab_prim.GetAttribute(cls._newton_cable_body_offset_attr).Set(int(body_offset)) + fab_prim.CreateAttribute(cls._newton_cable_body_count_attr, usdrt.Sdf.ValueTypeNames.UInt, True) + fab_prim.GetAttribute(cls._newton_cable_body_count_attr).Set(len(entry.edges)) + fab_prim.CreateAttribute( + cls._newton_cable_last_edge_length_attr, usdrt.Sdf.ValueTypeNames.Float, True + ) + fab_prim.GetAttribute(cls._newton_cable_last_edge_length_attr).Set(float(entry.last_edge_length)) + + @classmethod + def sync_curves_to_usd(cls) -> None: + """Update cable ``UsdGeomBasisCurves.points`` from Newton ``body_q``. + + Runs on the CPU Fabric device: Kit's FSD does not stream the GPU Fabric + ``points`` bucket back to Hydra for runtime-spawned ``UsdGeomBasisCurves`` + (see ``scripts/debug/basis_curves_fsd_gpu_bug_repro.py``). + """ + if cls._usdrt_stage is None: + return + import usdrt + + selection = cls._usdrt_stage.SelectPrims( + require_attrs=[ + (usdrt.Sdf.ValueTypeNames.Point3fArray, "points", usdrt.Usd.Access.ReadWrite), + (usdrt.Sdf.ValueTypeNames.UInt, cls._newton_cable_body_offset_attr, usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.UInt, cls._newton_cable_body_count_attr, usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.Float, cls._newton_cable_last_edge_length_attr, usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read), + ], + device="cpu", + ) + if selection.GetCount() == 0: + return + + # wp.launch requires inputs on the same device as the launch. + body_q_cpu = wp.empty_like(cls._state_0.body_q, device="cpu") + wp.copy(body_q_cpu, cls._state_0.body_q) + + wp.launch( + _sync_cable_curve_points, + dim=selection.GetCount(), + inputs=[ + wp.fabricarrayarray(data=selection, attrib="points", dtype=wp.vec3f), + wp.fabricarray(data=selection, attrib="omni:fabric:worldMatrix"), + wp.fabricarray(data=selection, attrib=cls._newton_cable_body_offset_attr), + wp.fabricarray(data=selection, attrib=cls._newton_cable_body_count_attr), + wp.fabricarray(data=selection, attrib=cls._newton_cable_last_edge_length_attr), + body_q_cpu, + ], + device="cpu", + ) + + @classmethod + def pre_render(cls) -> None: + super().pre_render() + cls.sync_curves_to_usd() + @classmethod def instantiate_builder_from_stage(cls): """Create builder from USD stage with special treatment for deformable diff --git a/source/isaaclab_contrib/test/cable/test_cable_object.py b/source/isaaclab_contrib/test/cable/test_cable_object.py index fbea11b606dd..4e052adc9cdd 100644 --- a/source/isaaclab_contrib/test/cable/test_cable_object.py +++ b/source/isaaclab_contrib/test/cable/test_cable_object.py @@ -86,10 +86,11 @@ class _FakeBuilder: def __init__(self): self.calls = [] + self.body_count = 0 def add_rod_graph(self, **kwargs): self.calls.append(kwargs) - return [], [] # body_indices, joint_indices — match Newton's signature + self.body_count += len(kwargs.get("edges", [])) @pytest.mark.parametrize( @@ -159,6 +160,33 @@ def test_add_cable_entry_to_builder(env_rotation, env_position, init_pos, init_r assert float(call["cfg"].density) == pytest.approx(1200.0) +def test_add_cable_entry_populates_body_offsets_and_last_edge_length(): + """``add_cable_entry_to_builder`` records per-env body offsets and the last edge length.""" + from isaaclab_contrib.cable.cable_object import add_cable_entry_to_builder + + class _BodyCountingBuilder: + def __init__(self): + self.body_count = 0 + + def add_rod_graph(self, *, edges, **_kwargs): + self.body_count += len(edges) + + entry = CableRegistryEntry( + prim_path="/World/Cable", + node_positions=[wp.vec3(0.0, 0.0, 0.0), wp.vec3(0.2, 0.0, 0.0), wp.vec3(0.5, 0.0, 0.0), wp.vec3(0.9, 0.0, 0.0)], + edges=[(0, 1), (1, 2), (2, 3)], + radius=0.005, + ) + builder = _BodyCountingBuilder() + builder.body_count = 7 + add_cable_entry_to_builder(builder, entry, env_idx=0, env_position=[0, 0, 0], env_rotation=[0, 0, 0, 1]) + builder.body_count += 5 + add_cable_entry_to_builder(builder, entry, env_idx=1, env_position=[1, 0, 0], env_rotation=[0, 0, 0, 1]) + + assert entry.body_offsets == [7, 15] + assert entry.last_edge_length == pytest.approx(0.4) + + def test_cable_object_cfg_defaults(): """CableObjectCfg overrides actuators and articulation_root_prim_path.""" import isaaclab.sim as sim_utils diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index f41afa85678b..bc849daf79ab 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -1013,6 +1013,9 @@ def start_simulation(cls) -> None: NewtonManager._usdrt_stage = get_current_stage(fabric=True) for i, prim_path in enumerate(body_paths): prim = cls._usdrt_stage.GetPrimAtPath(prim_path) + # Cable segment bodies have no per-body USD prim. + if not prim.IsValid(): + continue prim.CreateAttribute(cls._newton_index_attr, usdrt.Sdf.ValueTypeNames.UInt, True) prim.GetAttribute(cls._newton_index_attr).Set(i) # Tag with PhysicsRigidBodyAPI so cubric's eRigidBody mode From 28c3f3f866f2cbb87d3252ce998eb48f398b6c81 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Fri, 15 May 2026 17:08:07 +0200 Subject: [PATCH 04/26] Test: Mark cable state as dirty for fabric syncing --- .../sim/spawners/shapes/shapes_cfg.py | 7 +- .../changelog.d/mym-cable.minor.rst | 3 + .../isaaclab_contrib/cable/cable_object.py | 17 ++--- .../deformable/vbd_manager.py | 65 ++++++++++++++++--- 4 files changed, 71 insertions(+), 21 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py index b077d5ff8c62..102256a72b77 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py @@ -126,7 +126,7 @@ class ConeCfg(ShapeCfg): class CableCfg(ShapeCfg): """Configuration parameters for a 1D cable / rod prim. - Authors a ``UsdGeomBasisCurves`` prim at ``{prim_path}/curve`` from an + Authors a ``UsdGeomBasisCurves`` prim at ``{prim_path}/geometry/mesh`` from an explicit list of control points. Physics is materialized at model-build time by the Newton replicate hook calling :meth:`newton.ModelBuilder.add_rod_graph`. @@ -143,7 +143,7 @@ class CableCfg(ShapeCfg): cables — :func:`spawn_cable` raises ``ValueError`` if either is non-None. ``collision_props`` is required because :func:`spawn_cable` relies on :class:`UsdPhysics.CollisionAPI` to author a usable physics-material binding; - the cable is in the Newton cloner's ``_cable_ignore_paths`` so this has no + the cable geometry is ignored by the Newton USD importer so this has no PhysX runtime effect. """ @@ -159,8 +159,7 @@ class CableCfg(ShapeCfg): visual_material_path: str = "visual_material" """Path for the visual material prim, relative to ``prim_path``. Overrides :attr:`ShapeCfg.visual_material_path` so visual and physics materials don't - collide at the same sub-path (cables don't use a ``/geometry/`` intermediate - like mesh spawners do).""" + collide at the same sub-path under ``{prim_path}/geometry``.""" physics_material_path: str = "physics_material" """Path for the physics material prim, relative to ``prim_path``. Overrides diff --git a/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst b/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst index 31da8db5003d..d9652f7212b0 100644 --- a/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst +++ b/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst @@ -19,3 +19,6 @@ Fixed ``_init_particle_system`` for zero-particle scenes and leaves ``particle_enable_self_contact`` unset; the manager now reads it with ``getattr(..., False)`` to default to no-self-contact. +* Fixed Kit / Fabric viewport sync for Newton cables by updating + ``UsdGeomBasisCurves`` points from Newton cable body transforms at render + cadence. diff --git a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py index 6355ec18bb7d..56efa594c5d8 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py +++ b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py @@ -38,6 +38,7 @@ class CableRegistryEntry: node_positions: list[wp.vec3] edges: list[tuple[int, int]] radius: float + curve_prim_path: str = "" init_pos: tuple[float, float, float] = (0.0, 0.0, 0.0) init_rot: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0) @@ -261,15 +262,15 @@ def _register_cable(self) -> CableRegistryEntry: raise RuntimeError(f"Failed to find cable template prim for expression: '{lookup_path}'.") template_prim_path = template_prim.GetPrimPath() - # Find the single UsdGeomBasisCurves child authored by spawn_cable. - curve_prims = sim_utils.get_all_matching_child_prims( - template_prim_path, lambda p: p.GetTypeName() == "BasisCurves" - ) - if len(curve_prims) != 1: + # ``spawn_cable`` authors the curve at ``{prim_path}/geometry/mesh``. + stage = template_prim.GetStage() + expected_curve_prim_path = f"{template_prim_path}/geometry/mesh" + curve_prim = stage.GetPrimAtPath(expected_curve_prim_path) + if not curve_prim or not curve_prim.IsValid() or curve_prim.GetTypeName() != "BasisCurves": raise ValueError( - f"Expected exactly one UsdGeomBasisCurves prim under '{template_prim_path}', found {len(curve_prims)}." + f"Expected a UsdGeomBasisCurves prim at '{expected_curve_prim_path}', " + f"got '{curve_prim.GetTypeName() if curve_prim and curve_prim.IsValid() else None}'." ) - curve_prim = curve_prims[0] curves = UsdGeom.BasisCurves(curve_prim) # Bake the curve prim's xform into the per-node positions so the replicate @@ -305,7 +306,6 @@ def _register_cable(self) -> CableRegistryEntry: if curve_prim.HasAPI(UsdShade.MaterialBindingAPI) else [] ) - stage = curve_prim.GetStage() material_prim = None for mat_path in material_targets: mat_prim = stage.GetPrimAtPath(mat_path) @@ -343,6 +343,7 @@ def _get_material_attr(name: str, default): # transform. Matches DeformableObject._register_deformable. entry = CableRegistryEntry( prim_path=self.cfg.prim_path, + curve_prim_path=f"{self.cfg.prim_path}/geometry/mesh", node_positions=node_positions, edges=edges, radius=radius, diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py index e45b204e8572..f0014b6762cc 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -63,6 +63,8 @@ class NewtonVBDManager(NewtonManager): _newton_cable_body_offset_attr = "newton:cableBodyOffset" _newton_cable_body_count_attr = "newton:cableBodyCount" _newton_cable_last_edge_length_attr = "newton:cableLastEdgeLength" + _curves_dirty: bool = False + _cable_body_q_cpu = None @classmethod def initialize(cls, sim_context: SimulationContext) -> None: @@ -84,6 +86,24 @@ def initialize(cls, sim_context: SimulationContext) -> None: super().initialize(sim_context) + @classmethod + def clear(cls): + """Clear VBD-specific Fabric sync state.""" + super().clear() + cls._curves_dirty = False + cls._cable_body_q_cpu = None + + @classmethod + def _mark_curves_dirty(cls) -> None: + """Flag that cable curve points have changed and Fabric needs re-sync.""" + cls._curves_dirty = True + + @classmethod + def _mark_state_dirty(cls) -> None: + """Flag that all VBD state has changed and Fabric needs re-sync.""" + super()._mark_state_dirty() + cls._mark_curves_dirty() + @classmethod def _get_deformable_ignore_paths(cls) -> list[str]: """Return USD prim paths to skip when calling ``builder.add_usd``. @@ -185,16 +205,37 @@ def start_simulation(cls) -> None: NewtonManager._usdrt_stage = get_current_stage(fabric=True) stage = get_current_stage() + curves_registered = False for entry in cls._cable_registry: - curve_template_path = f"{entry.prim_path}/geometry/mesh" + curve_template_path = entry.curve_prim_path or f"{entry.prim_path}/geometry/mesh" for inst_idx, body_offset in enumerate(entry.body_offsets): resolved = re.sub(r"(?<=[Ee]nv_)\.\*", str(inst_idx), curve_template_path) resolved = re.sub(r"\.\*", str(inst_idx), resolved) curve_prim = stage.GetPrimAtPath(resolved) + if not curve_prim or not curve_prim.IsValid(): + logger.warning("[setup_fabric_cable_sync] curve prim not found at %s", resolved) + continue usd_points = curve_prim.GetAttribute("points").Get() + expected_points = len(entry.edges) + 1 + if usd_points is None or len(usd_points) != expected_points: + logger.warning( + "[setup_fabric_cable_sync] curve %s has %s points, expected %d; skipping.", + resolved, + 0 if usd_points is None else len(usd_points), + expected_points, + ) + continue fab_prim = NewtonManager._usdrt_stage.GetPrimAtPath(curve_prim.GetPath().pathString) + xformable_prim = usdrt.Rt.Xformable(fab_prim) + if not xformable_prim.HasWorldXform(): + xformable_prim.SetWorldXformFromUsd() # Pre-seed Fabric ``points``: without this Hydra reads an empty array on frame 0. - fab_prim.GetAttribute("points").Set( + fab_points_attr = fab_prim.GetAttribute("points") + if not fab_points_attr.IsValid(): + fab_points_attr = fab_prim.CreateAttribute( + "points", usdrt.Sdf.ValueTypeNames.Point3fArray, True + ) + fab_points_attr.Set( usdrt.Vt.Vec3fArray([usdrt.Gf.Vec3f(float(p[0]), float(p[1]), float(p[2])) for p in usd_points]) ) fab_prim.CreateAttribute(cls._newton_cable_body_offset_attr, usdrt.Sdf.ValueTypeNames.UInt, True) @@ -205,16 +246,20 @@ def start_simulation(cls) -> None: cls._newton_cable_last_edge_length_attr, usdrt.Sdf.ValueTypeNames.Float, True ) fab_prim.GetAttribute(cls._newton_cable_last_edge_length_attr).Set(float(entry.last_edge_length)) + curves_registered = True + if curves_registered: + cls._mark_curves_dirty() @classmethod def sync_curves_to_usd(cls) -> None: """Update cable ``UsdGeomBasisCurves.points`` from Newton ``body_q``. - Runs on the CPU Fabric device: Kit's FSD does not stream the GPU Fabric - ``points`` bucket back to Hydra for runtime-spawned ``UsdGeomBasisCurves`` - (see ``scripts/debug/basis_curves_fsd_gpu_bug_repro.py``). + Runs on the CPU Fabric device because Kit/Hydra reads that bucket for + runtime-spawned ``UsdGeomBasisCurves``. """ - if cls._usdrt_stage is None: + if cls._usdrt_stage is None or cls._state_0 is None or cls._state_0.body_q is None: + return + if not getattr(cls, "_cable_registry", None) or not cls._curves_dirty: return import usdrt @@ -232,8 +277,9 @@ def sync_curves_to_usd(cls) -> None: return # wp.launch requires inputs on the same device as the launch. - body_q_cpu = wp.empty_like(cls._state_0.body_q, device="cpu") - wp.copy(body_q_cpu, cls._state_0.body_q) + if cls._cable_body_q_cpu is None or cls._cable_body_q_cpu.shape != cls._state_0.body_q.shape: + cls._cable_body_q_cpu = wp.empty_like(cls._state_0.body_q, device="cpu") + wp.copy(cls._cable_body_q_cpu, cls._state_0.body_q) wp.launch( _sync_cable_curve_points, @@ -244,10 +290,11 @@ def sync_curves_to_usd(cls) -> None: wp.fabricarray(data=selection, attrib=cls._newton_cable_body_offset_attr), wp.fabricarray(data=selection, attrib=cls._newton_cable_body_count_attr), wp.fabricarray(data=selection, attrib=cls._newton_cable_last_edge_length_attr), - body_q_cpu, + cls._cable_body_q_cpu, ], device="cpu", ) + cls._curves_dirty = False @classmethod def pre_render(cls) -> None: From aa1cef15f58dcca38afd0d8d1dc8bde3376c721d Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Fri, 15 May 2026 17:09:57 +0200 Subject: [PATCH 05/26] Feat: Add a cable demo --- scripts/demos/cables.py | 157 ++++++++++++++++++ .../deformable/newton_manager_cfg.py | 9 + 2 files changed, 166 insertions(+) create mode 100644 scripts/demos/cables.py diff --git a/scripts/demos/cables.py b/scripts/demos/cables.py new file mode 100644 index 000000000000..19cefb59e831 --- /dev/null +++ b/scripts/demos/cables.py @@ -0,0 +1,157 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Spawn a pile of cables at varied z-axis rotations so they collide and settle on each other. + +.. code-block:: bash + + # Usage + ./isaaclab.sh -p scripts/demos/cables.py + ./isaaclab.sh -p scripts/demos/cables.py --num_cables 40 + +""" + +"""Launch Isaac Sim Simulator first.""" + + +import argparse + +from isaaclab.app import AppLauncher + +parser = argparse.ArgumentParser(description="Spawn a pile of cables at varied z-axis rotations.") +parser.add_argument("--num_cables", type=int, default=25, help="Number of cables to spawn.") +AppLauncher.add_app_launcher_args(parser) +parser.set_defaults(visualizer=["kit"]) +args_cli = parser.parse_args() + +app_launcher = AppLauncher(args_cli) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + +import math +import random + +import tqdm + +import isaaclab.sim as sim_utils +from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg + +from isaaclab_contrib.cable import CableObject, CableObjectCfg + + +def z_axis_quat(angle_rad: float) -> tuple[float, float, float, float]: + """Quaternion (x, y, z, w) for a rotation of ``angle_rad`` about +Z.""" + return (0.0, 0.0, math.sin(0.5 * angle_rad), math.cos(0.5 * angle_rad)) + + +def design_scene(num_cables: int) -> dict[str, CableObject]: + """Spawn a ground plane, a dome light, and a pile of randomly oriented cables.""" + ground_cfg = sim_utils.GroundPlaneCfg() + ground_cfg.func("/World/defaultGroundPlane", ground_cfg) + light_cfg = sim_utils.DomeLightCfg(intensity=3000.0, color=(0.75, 0.75, 0.75)) + light_cfg.func("/World/light", light_cfg) + + # Cable centerline: 10 control points along local +X, length ~0.9 m. + num_points = 10 + segment_length = 0.1 + cable_length = (num_points - 1) * segment_length + width = 0.03 + + # Pile footprint: small XY box, stacked Z so cables fall and intersect. + # Spacing is generous to avoid self-contact at spawn, and the base height is + # kept low so cables don't gain a lot of velocity before first contact. + xy_jitter = 0.6 + z_spacing = 2.5 * width + z_base = 0.1 + + print(f"[INFO]: Spawning {num_cables} cables...") + entities: dict[str, CableObject] = {} + for idx in tqdm.tqdm(range(num_cables)): + angle = random.uniform(0.0, 2.0 * math.pi) + cx = random.uniform(-xy_jitter, xy_jitter) - 0.5 * cable_length * math.cos(angle) + cy = random.uniform(-xy_jitter, xy_jitter) - 0.5 * cable_length * math.sin(angle) + cz = z_base + idx * z_spacing + + spawn_cfg = sim_utils.CableCfg( + positions=[(i * segment_length, 0.0, 0.0) for i in range(num_points)], + width=width, + visual_material=sim_utils.PreviewSurfaceCfg( + diffuse_color=(random.random(), random.random(), random.random()) + ), + physics_material=NewtonCableMaterialCfg( + stretch_stiffness=1e6, + bend_stiffness=1e-4, + stretch_damping=1e-4, + bend_damping=1e-4, + density=1000.0, + ), + collision_props=sim_utils.CollisionPropertiesCfg(), + ) + cfg = CableObjectCfg( + prim_path=f"/World/Origin/Cable{idx:03d}", + spawn=spawn_cfg, + init_state=CableObjectCfg.InitialStateCfg(pos=(cx, cy, cz), rot=z_axis_quat(angle)), + ) + entities[f"Cable{idx:03d}"] = CableObject(cfg=cfg) + + return entities + + +def run_simulator(sim: sim_utils.SimulationContext, entities: dict[str, CableObject]): + """Step the sim and periodically reset cable state.""" + sim_dt = sim.get_physics_dt() + reset_steps = int(3.0 / sim_dt) + count = 0 + + while simulation_app.is_running(): + if count % reset_steps == 0: + count = 0 + # Cables have no nodal snap-back; reset internal buffers only. + for cable in entities.values(): + cable.reset() + print("[INFO]: Resetting cable state...") + sim.step() + count += 1 + for cable in entities.values(): + cable.update(sim_dt) + + +def main(): + """Main entry point.""" + from isaaclab_newton.physics import NewtonCfg + + from isaaclab_contrib.deformable.newton_manager_cfg import NewtonModelCfg, VBDSolverCfg + + physics_cfg = NewtonCfg( + solver_cfg=VBDSolverCfg( + iterations=20, + rigid_body_contact_buffer_size=256, + rigid_contact_k_start=1.0e1, + ), + num_substeps=8, + ) + # Soften body-body contact: lower ke + nonzero kd damps out the + # spikes when many cable segments pile onto one segment. mu=1.0 keeps + # cables from sliding off the pile. + physics_cfg.model_cfg = NewtonModelCfg( + shape_material_ke=1.0e4, + shape_material_kd=1.0e1, + shape_material_mu=1.0, + ) + sim_cfg = sim_utils.SimulationCfg(dt=0.01, device=args_cli.device, physics=physics_cfg) + sim = sim_utils.SimulationContext(sim_cfg) + sim.set_camera_view([2.5, 2.5, 2.0], [0.0, 0.0, 0.3]) + + scene_entities = design_scene(num_cables=args_cli.num_cables) + sim.reset() + print("[INFO]: Setup complete...") + run_simulator(sim, scene_entities) + print("[INFO]: Simulation complete...") + + +if __name__ == "__main__": + main() + simulation_app.close() diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py index 99e2082a341f..f6949c849867 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py @@ -88,6 +88,15 @@ class VBDSolverCfg(NewtonSolverCfg): Used by the AVBD rigid contact solver. Increase to make rigid contacts stiffer. """ + rigid_body_contact_buffer_size: int = 64 + """Per-body body-body contact list capacity. + + Newton emits a ``Per-body rigid contact buffer overflowed N > M`` warning when + a single body sees more contacts than this in one step. Increase for dense + rigid-body pile-ups (e.g. many cable segments stacking on each other); + Newton's ``example_cable_pile.py`` uses 256. + """ + @configclass class CoupledMJWarpVBDSolverCfg(NewtonSolverCfg): From 942d74ce090d40b2527d003e4e06946a3049fec2 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Fri, 15 May 2026 18:01:32 +0200 Subject: [PATCH 06/26] Fix: Newton eval_fk does not work for cable joints. Temporarily patch this. --- scripts/demos/cables.py | 4 +- .../changelog.d/mym-cable.minor.rst | 5 ++ .../deformable/vbd_manager.py | 71 ++++++++++++++++++- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/scripts/demos/cables.py b/scripts/demos/cables.py index 19cefb59e831..d7db508fa1d1 100644 --- a/scripts/demos/cables.py +++ b/scripts/demos/cables.py @@ -35,9 +35,9 @@ import random import tqdm +from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg import isaaclab.sim as sim_utils -from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg from isaaclab_contrib.cable import CableObject, CableObjectCfg @@ -137,7 +137,7 @@ def main(): # spikes when many cable segments pile onto one segment. mu=1.0 keeps # cables from sliding off the pile. physics_cfg.model_cfg = NewtonModelCfg( - shape_material_ke=1.0e4, + shape_material_ke=1.0e3, shape_material_kd=1.0e1, shape_material_mu=1.0, ) diff --git a/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst b/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst index d9652f7212b0..6516bf1d2e62 100644 --- a/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst +++ b/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst @@ -22,3 +22,8 @@ Fixed * Fixed Kit / Fabric viewport sync for Newton cables by updating ``UsdGeomBasisCurves`` points from Newton cable body transforms at render cadence. +* Fixed cable explosion under the Kit visualizer by overriding + :meth:`~isaaclab_contrib.deformable.vbd_manager.NewtonVBDManager.forward` to + mask out cable articulations. Newton's ``eval_fk`` has no + :attr:`newton.JointType.CABLE` case and was collapsing rod segments onto + their parent anchors every time Kit triggered a pre-render FK pass. diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py index f0014b6762cc..f89894146dae 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -11,9 +11,10 @@ import logging from typing import TYPE_CHECKING +import numpy as np import warp as wp from isaaclab_newton.physics.newton_manager import NewtonManager -from newton import Model, ModelBuilder +from newton import JointType, Model, ModelBuilder, eval_fk from newton._src.usd.schemas import SchemaResolverNewton, SchemaResolverPhysx from newton.solvers import SolverVBD @@ -65,6 +66,13 @@ class NewtonVBDManager(NewtonManager): _newton_cable_last_edge_length_attr = "newton:cableLastEdgeLength" _curves_dirty: bool = False _cable_body_q_cpu = None + _non_cable_articulation_mask: wp.array | None = None + """(articulation_count,) wp.bool — False for articulations containing + :attr:`newton.JointType.CABLE` joints, True elsewhere. Used to skip cable + articulations in :meth:`forward` because Newton's ``eval_fk`` does not + handle cable joints (their relative transform falls through to identity, + collapsing rod segments onto their parent anchors). + """ @classmethod def initialize(cls, sim_context: SimulationContext) -> None: @@ -92,6 +100,7 @@ def clear(cls): super().clear() cls._curves_dirty = False cls._cable_body_q_cpu = None + cls._non_cable_articulation_mask = None @classmethod def _mark_curves_dirty(cls) -> None: @@ -250,6 +259,66 @@ def start_simulation(cls) -> None: if curves_registered: cls._mark_curves_dirty() + cls._build_non_cable_articulation_mask() + + @classmethod + def _build_non_cable_articulation_mask(cls) -> None: + """Build :attr:`_non_cable_articulation_mask` from finalized joint topology. + NOTE: Can be removed once Newton patches cable joints in eval_fk. + + Walks :attr:`newton.Model.joint_type` and :attr:`newton.Model.joint_articulation` + to find articulations that contain at least one :attr:`newton.JointType.CABLE` + joint, then allocates a device-resident boolean mask that is ``False`` for + those articulations and ``True`` elsewhere. Leaves the mask as ``None`` + when there are no cable articulations so :meth:`forward` can take the + unmasked fast path via ``super().forward()``. + """ + model = cls._model + if model is None or model.articulation_count == 0: + return + if model.joint_type is None or model.joint_articulation is None: + return + + joint_type_np = model.joint_type.numpy() + joint_articulation_np = model.joint_articulation.numpy() + cable_art_ids = { + int(joint_articulation_np[j]) + for j in range(len(joint_type_np)) + if int(joint_type_np[j]) == int(JointType.CABLE) and int(joint_articulation_np[j]) >= 0 + } + if not cable_art_ids: + return + + mask_np = np.ones(model.articulation_count, dtype=np.bool_) + for art_id in cable_art_ids: + mask_np[art_id] = False + cls._non_cable_articulation_mask = wp.array(mask_np, dtype=wp.bool, device=PhysicsManager._device) + + @classmethod + def forward(cls) -> None: + """Update articulation kinematics, skipping cable articulations. + NOTE: Can be removed once Newton patches cable joints in eval_fk. + + Newton's ``eval_fk`` has no case for :attr:`newton.JointType.CABLE`, so a + cable joint's relative transform falls through to the identity, snapping + each child segment onto its parent's joint anchor and destroying the + rod state that VBD integrated directly into ``body_q``. This override + passes :attr:`_non_cable_articulation_mask` so cable articulations are + excluded from the FK pass triggered by Kit-style visualizers (which set + :meth:`~isaaclab.visualizers.BaseVisualizer.requires_forward_before_step` + to ``True``). + """ + if cls._non_cable_articulation_mask is None: + super().forward() + return + eval_fk( + cls._model, + cls._state_0.joint_q, + cls._state_0.joint_qd, + cls._state_0, + cls._non_cable_articulation_mask, + ) + @classmethod def sync_curves_to_usd(cls) -> None: """Update cable ``UsdGeomBasisCurves.points`` from Newton ``body_q``. From 9a9d5f7435e5a44753eba3160041a226cdd80ed5 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Fri, 15 May 2026 18:29:43 +0200 Subject: [PATCH 07/26] Style: lint --- .../isaaclab_contrib/deformable/vbd_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py index f89894146dae..6da6ae894b47 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -263,7 +263,7 @@ def start_simulation(cls) -> None: @classmethod def _build_non_cable_articulation_mask(cls) -> None: - """Build :attr:`_non_cable_articulation_mask` from finalized joint topology. + """Build :attr:`_non_cable_articulation_mask` from finalized joint topology. NOTE: Can be removed once Newton patches cable joints in eval_fk. Walks :attr:`newton.Model.joint_type` and :attr:`newton.Model.joint_articulation` @@ -296,7 +296,7 @@ def _build_non_cable_articulation_mask(cls) -> None: @classmethod def forward(cls) -> None: - """Update articulation kinematics, skipping cable articulations. + """Update articulation kinematics, skipping cable articulations. NOTE: Can be removed once Newton patches cable joints in eval_fk. Newton's ``eval_fk`` has no case for :attr:`newton.JointType.CABLE`, so a From 15c48fdd9e953f2fd0d554ea114f4c9c68eb85d4 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Sat, 16 May 2026 18:39:59 +0200 Subject: [PATCH 08/26] Fix: Clear removed from coupled managers --- .../deformable/coupled_featherstone_vbd_manager.py | 6 ------ .../deformable/coupled_mjwarp_vbd_manager.py | 6 ------ 2 files changed, 12 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py index 312d9fc69f10..388a868712d1 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py @@ -21,7 +21,6 @@ from .deformable_object import ( add_deformable_entry_to_builder, - clear_deformable_builder_hooks, install_deformable_builder_hooks, ) from .kernels import _kernel_body_particle_reaction @@ -82,11 +81,6 @@ def step(cls) -> None: NewtonManager._model_changes = set() super().step() - @classmethod - def _solver_specific_clear(cls): - """Clear VBD-specific state.""" - clear_deformable_builder_hooks() - @classmethod def _get_deformable_ignore_paths(cls) -> list[str]: """Return USD prim paths to skip when calling ``builder.add_usd``. diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py index 5097284bafc7..916dc1414388 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py @@ -21,7 +21,6 @@ from .deformable_object import ( add_deformable_entry_to_builder, - clear_deformable_builder_hooks, install_deformable_builder_hooks, ) from .kernels import _kernel_body_particle_reaction @@ -82,11 +81,6 @@ def step(cls) -> None: NewtonManager._model_changes = set() super().step() - @classmethod - def _solver_specific_clear(cls): - """Clear VBD-specific state.""" - clear_deformable_builder_hooks() - @classmethod def _get_deformable_ignore_paths(cls) -> list[str]: """Return USD prim paths to skip when calling ``builder.add_usd``. From a286233ab018d3976e18e5270487aa1100826a1f Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Sat, 16 May 2026 19:03:23 +0200 Subject: [PATCH 09/26] Docs: Add documentation on cables --- docs/source/api/index.rst | 1 + docs/source/api/lab/isaaclab.sim.spawners.rst | 8 + .../lab_contrib/isaaclab_contrib.cable.rst | 55 ++++ .../isaaclab_newton.sim.spawners.rst | 14 + .../physical-backends/newton/index.rst | 1 + .../physical-backends/newton/using-cables.rst | 241 ++++++++++++++++++ .../isaaclab_contrib/cable/__init__.py | 17 ++ .../isaaclab_contrib/cable/__init__.pyi | 13 + 8 files changed, 350 insertions(+) create mode 100644 docs/source/api/lab_contrib/isaaclab_contrib.cable.rst create mode 100644 docs/source/overview/core-concepts/physical-backends/newton/using-cables.rst create mode 100644 source/isaaclab_contrib/isaaclab_contrib/cable/__init__.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/cable/__init__.pyi diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 2ff0cf6174be..079ef93482cf 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -83,6 +83,7 @@ The following modules are available in the ``isaaclab_contrib`` extension: actuators assets + cable controllers deformable mdp diff --git a/docs/source/api/lab/isaaclab.sim.spawners.rst b/docs/source/api/lab/isaaclab.sim.spawners.rst index d42fa13e524e..0eb95657768d 100644 --- a/docs/source/api/lab/isaaclab.sim.spawners.rst +++ b/docs/source/api/lab/isaaclab.sim.spawners.rst @@ -50,6 +50,7 @@ Shapes .. autosummary:: ShapeCfg + CableCfg CapsuleCfg ConeCfg CuboidCfg @@ -60,6 +61,13 @@ Shapes :members: :exclude-members: __init__, func +.. autofunction:: spawn_cable + +.. autoclass:: CableCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + .. autofunction:: spawn_capsule .. autoclass:: CapsuleCfg diff --git a/docs/source/api/lab_contrib/isaaclab_contrib.cable.rst b/docs/source/api/lab_contrib/isaaclab_contrib.cable.rst new file mode 100644 index 000000000000..6c842deb098f --- /dev/null +++ b/docs/source/api/lab_contrib/isaaclab_contrib.cable.rst @@ -0,0 +1,55 @@ +isaaclab_contrib.cable +====================== + +.. automodule:: isaaclab_contrib.cable + + .. rubric:: Classes + + .. autosummary:: + + cable_object.CableObject + cable_object.CableRegistryEntry + cable_object_cfg.CableObjectCfg + + .. rubric:: Replicate-hook plumbing + + .. autosummary:: + + cable_object.add_cable_entry_to_builder + cable_object.add_registered_cables_to_builder + cable_object.install_cable_builder_hooks + +Cable Object +------------ + +.. autoclass:: isaaclab_contrib.cable.cable_object.CableObject + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: isaaclab_contrib.cable.cable_object_cfg.CableObjectCfg + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__ + +Replicate-Hook Plumbing +----------------------- + +The cable registry / per-world builder hook mirrors the deformable contrib +pattern: :class:`CableObject` constructor appends a +:class:`CableRegistryEntry` to ``SimulationManager._cable_registry``, and the +hook installed by :func:`install_cable_builder_hooks` walks that registry once +per world during ``add_to_builder`` to call +:meth:`newton.ModelBuilder.add_rod_graph`. + +.. autoclass:: isaaclab_contrib.cable.cable_object.CableRegistryEntry + :members: + :show-inheritance: + :exclude-members: __init__ + +.. autofunction:: isaaclab_contrib.cable.cable_object.add_cable_entry_to_builder + +.. autofunction:: isaaclab_contrib.cable.cable_object.add_registered_cables_to_builder + +.. autofunction:: isaaclab_contrib.cable.cable_object.install_cable_builder_hooks diff --git a/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst b/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst index d60b953ce5c8..adb44cdf5760 100644 --- a/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst +++ b/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst @@ -10,6 +10,7 @@ isaaclab_newton.sim.spawners NewtonDeformableBodyMaterialCfg NewtonDeformableMaterialCfg NewtonSurfaceDeformableBodyMaterialCfg + NewtonCableMaterialCfg Deformable Materials -------------------- @@ -31,3 +32,16 @@ Newton provides the backend-specific deformable material cfgs. Deformable materi :members: :show-inheritance: :exclude-members: __init__, func + +Cable Material +-------------- + +Cable rod material parameters for :class:`~isaaclab.sim.spawners.shapes.CableCfg` +and :class:`~isaaclab_contrib.cable.CableObject`. Authored as a +``UsdShade.Material`` with ``newton:*`` attributes via the same +:func:`isaaclab.sim.spawners.materials.spawn_deformable_body_material` helper. + +.. autoclass:: NewtonCableMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func diff --git a/docs/source/overview/core-concepts/physical-backends/newton/index.rst b/docs/source/overview/core-concepts/physical-backends/newton/index.rst index 71e1ffed14e6..c30a38d27782 100644 --- a/docs/source/overview/core-concepts/physical-backends/newton/index.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/index.rst @@ -50,6 +50,7 @@ new backend, see :doc:`../../multi_backend_architecture`. mjwarp-solver kamino-solver using-vbd-solver + using-cables newton-manager-abstraction warp-environments warp-env-migration diff --git a/docs/source/overview/core-concepts/physical-backends/newton/using-cables.rst b/docs/source/overview/core-concepts/physical-backends/newton/using-cables.rst new file mode 100644 index 000000000000..cbec48ab323d --- /dev/null +++ b/docs/source/overview/core-concepts/physical-backends/newton/using-cables.rst @@ -0,0 +1,241 @@ +.. _newton-using-cables: + +Using Cables +============ + +Isaac Lab exposes 1D rod / cable assets through Newton's +:meth:`newton.ModelBuilder.add_rod_graph`. A cable is spawned as a +``UsdGeomBasisCurves`` prim, and the cable's physics (per-segment capsules, +inter-segment cable joints, stretch / bend stiffness, damping, density) is +materialized at Newton model-build time by a contrib replicate hook. + +Cable support is experimental. The spawner cfg, contrib asset class, registry +entry, and material defaults may change while Newton cable support is under +active development. + +.. note:: + Cables are currently **only supported on the Newton physics backend**. + The spawner authors valid USD on any backend (so the scene loads in PhysX + or PhysX-Fabric viewports), but the resulting cable is not registered with + a PhysX articulation. :class:`~isaaclab.sim.spawners.shapes.CableCfg` + requires ``physics_material`` to be a + :class:`~isaaclab_newton.sim.spawners.materials.NewtonCableMaterialCfg` + and rejects ``rigid_props`` / ``mass_props`` up front. + + +Quick Start: The Cable Demo +--------------------------- + +Before adding cables to a task, run the standalone demo to confirm that the +spawner, the cable replicate hook, the VBD solver, and the Kit / Fabric +viewport sync are all working in your environment: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/demos/cables.py + ./isaaclab.sh -p scripts/demos/cables.py --num_cables 40 + +The demo spawns a pile of randomly oriented cables onto a ground plane under +the Newton VBD solver. Source: ``scripts/demos/cables.py``. + + +Authoring a Cable +----------------- + +A cable is configured as a :class:`~isaaclab.sim.spawners.shapes.CableCfg` +plus a Newton-specific physics material. The cfg's ``positions`` field is a +list of at least two control points in the cable's local frame; adjacent pairs +become individual rod segments, each materialized as a capsule body of +diameter ``width`` and joined to its neighbour by a Newton cable joint. + +.. code-block:: python + + import isaaclab.sim as sim_utils + from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg + + cable_spawn = sim_utils.CableCfg( + positions=[(i * 0.1, 0.0, 0.0) for i in range(10)], + width=0.03, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.7, 0.2, 0.2)), + physics_material=NewtonCableMaterialCfg( + stretch_stiffness=1.0e6, + bend_stiffness=1.0e-4, + stretch_damping=1.0e-4, + bend_damping=1.0e-4, + density=1000.0, + ), + collision_props=sim_utils.CollisionPropertiesCfg(), + ) + +Wrap the spawner in a :class:`~isaaclab_contrib.cable.CableObjectCfg` to get a +runtime asset that can be reset and inspected through +:class:`~isaaclab_newton.assets.articulation.Articulation` state: + +.. code-block:: python + + from isaaclab_contrib.cable import CableObject, CableObjectCfg + + cable = CableObject( + cfg=CableObjectCfg( + prim_path="/World/Origin/Cable", + spawn=cable_spawn, + init_state=CableObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 0.5)), + ) + ) + +The :class:`~isaaclab_contrib.cable.CableObject` constructor appends a +:class:`~isaaclab_contrib.cable.CableRegistryEntry` to the contrib cable +registry. The Newton VBD manager installs a per-world builder hook that walks +this registry on each replicate and calls +:meth:`newton.ModelBuilder.add_rod_graph` so the cable is materialized once per +environment. See :doc:`newton-manager-abstraction` for the registry / hook +pattern that the deformable contrib package also follows. + + +Picking a Solver +---------------- + +Cables are integrated as Newton articulations, but they currently must be +simulated under a solver that knows how to step +:attr:`newton.JointType.CABLE` joints. The VBD manager in +:mod:`isaaclab_contrib.deformable` ships with that support: + +.. code-block:: python + + from isaaclab_newton.physics import NewtonCfg + from isaaclab_contrib.deformable.newton_manager_cfg import NewtonModelCfg, VBDSolverCfg + + physics_cfg = NewtonCfg( + solver_cfg=VBDSolverCfg(iterations=20), + num_substeps=8, + ) + physics_cfg.model_cfg = NewtonModelCfg( + shape_material_ke=1.0e3, + shape_material_kd=1.0e1, + shape_material_mu=1.0, + ) + +A cable-only scene can use a bare +:class:`~isaaclab_contrib.deformable.VBDSolverCfg`. Mixed rigid + cable scenes +(robot manipulating a cable) should use a coupled solver — see +:doc:`using-vbd-solver`. + + +Cable Material Parameters +------------------------- + +:class:`~isaaclab_newton.sim.spawners.materials.NewtonCableMaterialCfg` +exposes the rod material. Stiffness values are EA / EI quantities and are +normalized internally by Newton by the segment length. + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``stretch_stiffness`` + - Default: ``1.0e9`` [N]. Axial stiffness EA. Higher values reduce + cable elongation but require more solver iterations or substeps. + * - ``bend_stiffness`` + - Default: ``0.0`` [N·m^2]. Bending and twisting stiffness EI. ``0.0`` + produces a fully limp rope; increase for stiffer hoses or wires. + * - ``stretch_damping`` + - Default: ``0.0`` [N·s/m]. Per-joint axial damping. Increase to remove + post-contact stretch oscillations. + * - ``bend_damping`` + - Default: ``0.0`` [N·m·s/rad]. Per-joint bend / twist damping. + * - ``density`` + - Default: ``1500.0`` [kg/m^3]. Material density. The cable replicate + hook converts this to per-segment mass via the capsule volume + ``pi * radius^2 * segment_length * density`` and passes it through + :class:`newton.ModelBuilder.ShapeConfig` to + :meth:`newton.ModelBuilder.add_rod_graph`. + + +Spawner Parameters +------------------ + +:class:`~isaaclab.sim.spawners.shapes.CableCfg` fields specific to cables +(beyond the inherited :class:`~isaaclab.sim.spawners.shapes.ShapeCfg` slots): + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Parameter + - Description + * - ``positions`` + - List of control points in cable-local frame [m]. Must contain at least + two points. Adjacent pairs define one cable segment each, so a list of + ``N`` points produces ``N-1`` rod segments and ``N-2`` cable joints + plus one root joint anchoring the rod. + * - ``width`` + - Capsule diameter for every segment [m]. The same value is also written + to the ``UsdGeomBasisCurves`` ``widths`` attribute so the visual + thickness matches the physics. + * - ``visual_material_path`` + - Default: ``"visual_material"``. Sub-path under ``{prim_path}/geometry``. + Overrides :attr:`ShapeCfg.visual_material_path` so visual and physics + materials don't collide at the same sub-path. + * - ``physics_material_path`` + - Default: ``"physics_material"``. Same as above for the Newton physics + material. + +``rigid_props`` and ``mass_props`` are inherited from +:class:`~isaaclab.sim.spawners.shapes.ShapeCfg` but must remain ``None``: +:func:`~isaaclab.sim.spawners.shapes.spawn_cable` raises ``ValueError`` if +either is set, because cable mass and rigid-body properties come from the +material density and the rod-graph topology — not from per-prim USD physics +attributes. ``collision_props`` is required so that +:class:`UsdPhysics.CollisionAPI` can author a valid binding for the physics +material. + + +Kit / Fabric Visualization +-------------------------- + +The cable replicate hook places one ``UsdGeomBasisCurves`` prim per cable per +environment. The Newton VBD manager keeps these curves in sync with the +simulated body transforms by reconstructing the control points from +``newton.State.body_q`` every render frame. This sync runs on the **CPU Fabric +device** because Kit / Hydra reads curve points from the CPU Fabric bucket for +runtime-spawned ``UsdGeomBasisCurves``. If your visualizer skips curves at +runtime, prefer the default ``--visualizer kit`` flag used by the demo. + +A ``reset()`` call on a :class:`~isaaclab_contrib.cable.CableObject` does +**not** snap control points back to their initial positions: cables have no +nodal snap-back, only internal-buffer reset. To re-pose cables, write directly +into ``body_q`` or recreate the scene. + + +Limitations +----------- + +* Newton-only. PhysX has no cable joint, so the cable will load as inert + geometry under a PhysX backend. +* No actuators. :class:`~isaaclab_contrib.cable.CableObjectCfg` overrides + ``actuators`` to ``{}``; per-cable stiffness is treated as material, not as + a controllable joint. The inherited + ``logger.warning("Not all actuators are configured!")`` is expected and + harmless. +* :meth:`newton.eval_fk` has no + :attr:`newton.JointType.CABLE` case at present. The VBD manager + works around this by building a non-cable articulation mask in + :meth:`~isaaclab_contrib.deformable.vbd_manager.NewtonVBDManager._build_non_cable_articulation_mask` + and overriding + :meth:`~isaaclab_contrib.deformable.vbd_manager.NewtonVBDManager.forward` + so Kit-triggered pre-render FK passes don't collapse rod segments onto their + parent anchors. Once Newton patches cable joints in ``eval_fk``, that mask + and override can be removed. +* Self-contact between cable segments uses the rigid contact pipeline + (``shape_material_ke`` / ``kd`` / ``mu`` on + :class:`~isaaclab_contrib.deformable.NewtonModelCfg`), not VBD particle + self-contact. For dense cable piles, lower ``shape_material_ke``, raise + ``shape_material_kd``, and increase + :attr:`~isaaclab_contrib.deformable.VBDSolverCfg.rigid_body_contact_buffer_size` + before raising iterations. + +For implementation details of the cable registry, replicate hook, and Fabric +curve sync, see :class:`~isaaclab_contrib.cable.CableObject` and the +deformable contrib :doc:`newton-manager-abstraction` guide. diff --git a/source/isaaclab_contrib/isaaclab_contrib/cable/__init__.py b/source/isaaclab_contrib/isaaclab_contrib/cable/__init__.py new file mode 100644 index 000000000000..4afbbcbbb499 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/cable/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Sub-package for the cable / 1D-rod asset (Newton backend). + +Mirrors the structure of :mod:`isaaclab_contrib.deformable`: the asset class +and its cfg are re-exported at package level, while the replicate-hook +plumbing (registry entry and per-world builder hooks) stays accessible through +:mod:`isaaclab_contrib.cable.cable_object` for callers that wire it from +solver managers. +""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_contrib/isaaclab_contrib/cable/__init__.pyi b/source/isaaclab_contrib/isaaclab_contrib/cable/__init__.pyi new file mode 100644 index 000000000000..e00f27575235 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/cable/__init__.pyi @@ -0,0 +1,13 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +__all__ = [ + "CableObject", + "CableObjectCfg", + "CableRegistryEntry", +] + +from .cable_object import CableObject, CableRegistryEntry +from .cable_object_cfg import CableObjectCfg From 717f2bdacb5ed2715492beac34d6b9c9c53c8761 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Sat, 16 May 2026 19:28:02 +0200 Subject: [PATCH 10/26] Docs: fix --- docs/source/api/lab_contrib/isaaclab_contrib.cable.rst | 1 - .../isaaclab_contrib/isaaclab_contrib/cable/cable_object_cfg.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/source/api/lab_contrib/isaaclab_contrib.cable.rst b/docs/source/api/lab_contrib/isaaclab_contrib.cable.rst index 6c842deb098f..cab2b6af9f77 100644 --- a/docs/source/api/lab_contrib/isaaclab_contrib.cable.rst +++ b/docs/source/api/lab_contrib/isaaclab_contrib.cable.rst @@ -29,7 +29,6 @@ Cable Object .. autoclass:: isaaclab_contrib.cable.cable_object_cfg.CableObjectCfg :members: - :inherited-members: :show-inheritance: :exclude-members: __init__ diff --git a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object_cfg.py index af53c77482e2..a077b0d09181 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object_cfg.py @@ -9,7 +9,7 @@ from isaaclab.actuators import ActuatorBaseCfg from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass @configclass From b104f7dae87507cb3476e5196aca2f533d2e4e83 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Sat, 16 May 2026 20:01:32 +0200 Subject: [PATCH 11/26] Style: More assertions and cleanup --- .../isaaclab/sim/spawners/shapes/shapes.py | 15 +--- source/isaaclab/test/sim/test_spawn_cable.py | 12 ---- .../coupled_featherstone_vbd_manager.py | 7 ++ .../deformable/coupled_mjwarp_vbd_manager.py | 7 ++ .../deformable/vbd_manager.py | 47 ++++++++++--- .../test/cable/test_cable_object.py | 68 +++++++++++++++++++ 6 files changed, 123 insertions(+), 33 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index e29fca3ab086..0ecf3f10fb17 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -274,19 +274,10 @@ def spawn_cable( The spawned cable ``Xform`` prim. Raises: - ValueError: If ``cfg.physics_material`` is not a - ``NewtonCableMaterialCfg`` instance, or any of ``cfg.rigid_props`` or - ``cfg.mass_props`` is non-None. + ValueError: If ``cfg.rigid_props`` or ``cfg.mass_props`` is non-None. + The Newton-specific physics material is validated downstream by + :meth:`~isaaclab_contrib.cable.CableObject._register_cable`. """ - # Import here to avoid a hard dep on isaaclab_newton in core. - from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg - - # -- validate (rejects misconfiguration up-front) -- - if not isinstance(cfg.physics_material, NewtonCableMaterialCfg): - raise ValueError( - "CableCfg requires `physics_material` to be a NewtonCableMaterialCfg instance," - f" got {type(cfg.physics_material).__name__}." - ) if cfg.rigid_props is not None: raise ValueError("CableCfg does not support `rigid_props`.") if cfg.mass_props is not None: diff --git a/source/isaaclab/test/sim/test_spawn_cable.py b/source/isaaclab/test/sim/test_spawn_cable.py index baba7a484d6a..26d418ca7314 100644 --- a/source/isaaclab/test/sim/test_spawn_cable.py +++ b/source/isaaclab/test/sim/test_spawn_cable.py @@ -63,18 +63,6 @@ def test_spawn_cable(sim): assert curves.GetTypeAttr().Get() == "linear" -def test_spawn_cable_validation_wrong_material(sim): - from isaaclab.sim.spawners.materials import RigidBodyMaterialCfg - - cfg = CableCfg( - positions=[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], - width=0.01, - physics_material=RigidBodyMaterialCfg(), - ) - with pytest.raises(ValueError, match="NewtonCableMaterialCfg"): - cfg.func("/World/Cable", cfg) - - def test_spawn_cable_validation_rigid_props_rejected(sim): cfg = CableCfg( positions=[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py index 388a868712d1..8d0ca32993f0 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_featherstone_vbd_manager.py @@ -81,6 +81,13 @@ def step(cls) -> None: NewtonManager._model_changes = set() super().step() + @classmethod + def _solver_specific_clear(cls) -> None: + """Reset shared builder hooks and registries owned by the base manager.""" + NewtonManager._cable_registry = [] + NewtonManager._deformable_registry = [] + NewtonManager._per_world_builder_hooks = [] + @classmethod def _get_deformable_ignore_paths(cls) -> list[str]: """Return USD prim paths to skip when calling ``builder.add_usd``. diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py index 916dc1414388..5e698baa7e80 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/coupled_mjwarp_vbd_manager.py @@ -81,6 +81,13 @@ def step(cls) -> None: NewtonManager._model_changes = set() super().step() + @classmethod + def _solver_specific_clear(cls) -> None: + """Reset shared builder hooks and registries owned by the base manager.""" + NewtonManager._cable_registry = [] + NewtonManager._deformable_registry = [] + NewtonManager._per_world_builder_hooks = [] + @classmethod def _get_deformable_ignore_paths(cls) -> list[str]: """Return USD prim paths to skip when calling ``builder.add_usd``. diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py index 6da6ae894b47..66d62e61d911 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -95,12 +95,14 @@ def initialize(cls, sim_context: SimulationContext) -> None: super().initialize(sim_context) @classmethod - def clear(cls): - """Clear VBD-specific Fabric sync state.""" - super().clear() + def _solver_specific_clear(cls) -> None: + """Clear VBD-specific Fabric sync state and shared builder hooks.""" cls._curves_dirty = False cls._cable_body_q_cpu = None cls._non_cable_articulation_mask = None + NewtonManager._cable_registry = [] + NewtonManager._deformable_registry = [] + NewtonManager._per_world_builder_hooks = [] @classmethod def _mark_curves_dirty(cls) -> None: @@ -270,15 +272,33 @@ def _build_non_cable_articulation_mask(cls) -> None: to find articulations that contain at least one :attr:`newton.JointType.CABLE` joint, then allocates a device-resident boolean mask that is ``False`` for those articulations and ``True`` elsewhere. Leaves the mask as ``None`` - when there are no cable articulations so :meth:`forward` can take the + when there are no cables registered so :meth:`forward` can take the unmasked fast path via ``super().forward()``. + + Raises: + RuntimeError: If cables are registered but the finalized model is + missing the joint topology needed to build the mask, or contains + no :attr:`newton.JointType.CABLE` joints. Falling through to + ``super().forward()`` in those cases would corrupt cable + ``body_q`` silently each render. """ - model = cls._model - if model is None or model.articulation_count == 0: - return - if model.joint_type is None or model.joint_articulation is None: + if not cls._cable_registry: return + model = cls._model + if model is None or model.joint_type is None or model.joint_articulation is None: + raise RuntimeError( + "Cannot build non-cable articulation mask: cables are registered but Newton model" + " state is incomplete (missing model/joint_type/joint_articulation). Without the" + " mask, `forward()` calls eval_fk on cable joints and silently collapses rod" + " segments onto their parent anchors." + ) + if model.articulation_count == 0: + raise RuntimeError( + "Cannot build non-cable articulation mask: cables are registered but the finalized" + " model has zero articulations." + ) + joint_type_np = model.joint_type.numpy() joint_articulation_np = model.joint_articulation.numpy() cable_art_ids = { @@ -287,7 +307,10 @@ def _build_non_cable_articulation_mask(cls) -> None: if int(joint_type_np[j]) == int(JointType.CABLE) and int(joint_articulation_np[j]) >= 0 } if not cable_art_ids: - return + raise RuntimeError( + "Cannot build non-cable articulation mask: cables are registered but the finalized" + " model has no JointType.CABLE joints. The cable replicate hook likely did not run." + ) mask_np = np.ones(model.articulation_count, dtype=np.bool_) for art_id in cable_art_ids: @@ -309,6 +332,12 @@ def forward(cls) -> None: to ``True``). """ if cls._non_cable_articulation_mask is None: + if cls._cable_registry: + raise RuntimeError( + "Cables are registered but `_non_cable_articulation_mask` is None — refusing to" + " fall through to the unmasked eval_fk that would corrupt cable body_q. The mask" + " is built in `start_simulation()`; ensure it has run." + ) super().forward() return eval_fk( diff --git a/source/isaaclab_contrib/test/cable/test_cable_object.py b/source/isaaclab_contrib/test/cable/test_cable_object.py index 4e052adc9cdd..0dcd6e074ab6 100644 --- a/source/isaaclab_contrib/test/cable/test_cable_object.py +++ b/source/isaaclab_contrib/test/cable/test_cable_object.py @@ -299,3 +299,71 @@ class _SceneCfg(InteractiveSceneCfg): # Both forms contain the substring "cable_edge_body_". cable_body_count = sum(1 for label in model.body_label if "cable_edge_body_" in label) assert cable_body_count == 16, f"expected 16 cable bodies, got {cable_body_count}" + + +def test_forward_preserves_cable_body_q(): + """Regression test for the eval_fk cable patch (commit fd115a500f6). + + Newton's ``eval_fk`` has no case for :attr:`newton.JointType.CABLE`, so + without the patch any FK pass would collapse cable rod segments onto their + parent anchors. :meth:`NewtonVBDManager.forward` builds an articulation + mask in :meth:`start_simulation` that excludes cable articulations. + + To verify the test fails without the fix, force the mask to ``None`` after + ``start_simulation`` and observe that the new defensive check in + :meth:`forward` raises ``RuntimeError``; previously, the unmasked + ``eval_fk`` call would silently mutate ``body_q``. + """ + import numpy as np + from isaaclab_newton.physics import NewtonCfg + from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg as _NewtonCableMaterialCfg + + import isaaclab.sim as sim_utils + from isaaclab.scene import InteractiveScene, InteractiveSceneCfg + from isaaclab.sim import SimulationCfg, build_simulation_context + from isaaclab.utils import configclass + + from isaaclab_contrib.cable import CableObjectCfg + from isaaclab_contrib.deformable.newton_manager_cfg import VBDSolverCfg + from isaaclab_contrib.deformable.vbd_manager import NewtonVBDManager + + cable_spawn = sim_utils.CableCfg( + positions=[(0.0, 0.0, 0.0), (0.1, 0.0, 0.0), (0.2, 0.0, 0.0)], + width=0.01, + physics_material=_NewtonCableMaterialCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + ) + + @configclass + class _SceneCfg(InteractiveSceneCfg): + num_envs: int = 1 + env_spacing: float = 1.0 + cable: CableObjectCfg = CableObjectCfg(prim_path="{ENV_REGEX_NS}/Cable", spawn=cable_spawn) + + newton_sim_cfg = SimulationCfg(physics=NewtonCfg(solver_cfg=VBDSolverCfg())) + + with build_simulation_context(device="cuda:0", sim_cfg=newton_sim_cfg, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + InteractiveScene(_SceneCfg()) + sim.reset() # triggers replicate + start_simulation + _build_non_cable_articulation_mask + + # The mask must have been built since cables are registered. + assert NewtonVBDManager._non_cable_articulation_mask is not None, ( + "Expected _non_cable_articulation_mask to be built when cables are registered." + ) + + body_q_before = NewtonVBDManager._state_0.body_q.numpy().copy() + + # forward() is what Kit-style visualizers invoke each render. With the + # patch, cable articulations are excluded from the FK pass and body_q + # is bit-identical. Without the patch, JointType.CABLE relative + # transforms fall through to identity, snapping each rod segment onto + # its parent anchor. + NewtonVBDManager.forward() + + body_q_after = NewtonVBDManager._state_0.body_q.numpy() + np.testing.assert_array_equal( + body_q_after, + body_q_before, + err_msg="forward() altered body_q — cable mask did not exclude cable articulations.", + ) From 49f2219b040b7bc716b3d57a99682afa4198a980 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Mon, 18 May 2026 17:58:35 +0200 Subject: [PATCH 12/26] Feat: Set rod connections in USD --- .../isaaclab/sim/spawners/shapes/shapes.py | 12 +++- source/isaaclab/test/sim/test_spawn_cable.py | 6 ++ .../isaaclab_contrib/cable/cable_object.py | 71 +++++++++++-------- 3 files changed, 60 insertions(+), 29 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index 0ecf3f10fb17..86f61858d112 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING -from pxr import Usd +from pxr import Gf, Sdf, Usd from isaaclab.sim import schemas from isaaclab.sim.utils import bind_physics_material, bind_visual_material, clone, create_prim, get_current_stage @@ -292,6 +292,16 @@ def spawn_cable( } stage = get_current_stage() _spawn_geom_from_prim_type(prim_path, cfg, "BasisCurves", attributes, translation, orientation, stage=stage) + + # Author the edge topology as a custom ``int2[]`` attribute. Must be created + # explicitly because ``connections`` is not part of the UsdGeomBasisCurves + # schema; ``create_prim``'s attribute dict relies on ``GetAttribute().Set()`` + # which can't infer a type for non-schema attributes. + # TODO: Remove in the future once UsdGeomBasisCurves natively supports curve topology. For now, the Newton replicate hook expects this attribute to build the rod graph. + mesh_prim = stage.GetPrimAtPath(prim_path + "/geometry/mesh") + connections_attr = mesh_prim.CreateAttribute("connections", Sdf.ValueTypeNames.Int2Array, True) + connections_attr.Set([Gf.Vec2i(i, i + 1) for i in range(n_points - 1)]) + return stage.GetPrimAtPath(prim_path) diff --git a/source/isaaclab/test/sim/test_spawn_cable.py b/source/isaaclab/test/sim/test_spawn_cable.py index 26d418ca7314..87ff8e8fdff0 100644 --- a/source/isaaclab/test/sim/test_spawn_cable.py +++ b/source/isaaclab/test/sim/test_spawn_cable.py @@ -62,6 +62,12 @@ def test_spawn_cable(sim): assert widths == pytest.approx([0.01, 0.01, 0.01]) # cfg.width, broadcast per control point assert curves.GetTypeAttr().Get() == "linear" + # ``connections`` is authored as a custom int2[] attribute holding the linear edge chain. + connections_attr = curve_prim.GetAttribute("connections") + assert connections_attr.IsValid() + connections = [(int(e[0]), int(e[1])) for e in connections_attr.Get()] + assert connections == [(0, 1), (1, 2)] + def test_spawn_cable_validation_rigid_props_rejected(sim): cfg = CableCfg( diff --git a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py index 56efa594c5d8..87e1e1d0ba27 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py +++ b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py @@ -58,7 +58,6 @@ class CableRegistryEntry: from isaaclab_newton.physics import NewtonManager as SimulationManager # noqa: E402 import isaaclab.sim as sim_utils # noqa: E402 -from isaaclab.sim.spawners.shapes import CableCfg # noqa: E402 if TYPE_CHECKING: from .cable_object_cfg import CableObjectCfg @@ -212,25 +211,27 @@ def _register_cable(self) -> CableRegistryEntry: 1. Locate the spawned template prim (via ``cfg.spawn.spawn_path`` or ``cfg.prim_path``). - 2. Find the single ``UsdGeomBasisCurves`` child authored by - :func:`spawn_cable` and read its ``points`` and ``widths`` attributes. + 2. Walk the template prim's descendants and find the single + ``UsdGeomBasisCurves`` prim, then read its ``points`` and ``widths`` + attributes. This works for both :func:`spawn_cable` (which authors + the curve at ``{prim_path}/geometry/mesh``) and arbitrary curve + USDs loaded via :class:`~isaaclab.sim.spawners.UsdFileCfg`. 3. Bake the template prim's xform into the per-node positions so the replicate hook only needs to apply the env transform. - 4. Look up the bound Newton cable physics material and read each - ``newton:*`` attribute into the entry, falling back to the - :class:`CableRegistryEntry` field defaults when an attribute is - missing. + 4. Look up the bound Newton cable physics material on the curve prim + and read each ``newton:*`` attribute into the entry. If no Newton + material is bound, fall back to :class:`CableRegistryEntry` + defaults. Returns: The registry entry (also appended to ``SimulationManager._cable_registry``). Raises: - ValueError: If ``cfg.spawn`` is not a :class:`~isaaclab.sim.spawners.shapes.CableCfg`, - the template prim has no ``UsdGeomBasisCurves`` child, the curve - is missing its ``widths`` attribute, or no Newton cable physics - material is bound to the curve prim (commonly because - :class:`UsdPhysics.CollisionAPI` was not applied — set - ``CableCfg.collision_props`` so :func:`spawn_cable` applies it). + ValueError: If the template prim has no ``UsdGeomBasisCurves`` + descendant, or the curve is missing its ``widths`` attribute. + NotImplementedError: If more than one ``UsdGeomBasisCurves`` + descendant is found under the template prim — multi-curve + cables under a single :class:`CableObject` are not supported. RuntimeError: If the template prim cannot be located, or :func:`install_cable_builder_hooks` has not been called before constructing the :class:`CableObject`. @@ -240,12 +241,8 @@ def _register_cable(self) -> CableRegistryEntry: that ``resolve_task_config`` can import the env-cfg module before Kit starts without polluting the ``pxr`` module cache. """ - from pxr import Gf, UsdGeom, UsdPhysics, UsdShade + from pxr import Gf, Usd, UsdGeom, UsdPhysics, UsdShade - if not isinstance(self.cfg.spawn, CableCfg): - raise ValueError( - f"CableObjectCfg requires `spawn` to be a CableCfg instance, got {type(self.cfg.spawn).__name__}." - ) if not hasattr(SimulationManager, "_cable_registry"): raise RuntimeError( "CableObject requires `install_cable_builder_hooks()` to have been called" @@ -262,15 +259,22 @@ def _register_cable(self) -> CableRegistryEntry: raise RuntimeError(f"Failed to find cable template prim for expression: '{lookup_path}'.") template_prim_path = template_prim.GetPrimPath() - # ``spawn_cable`` authors the curve at ``{prim_path}/geometry/mesh``. + # Discover the cable's BasisCurves by descendant traversal so this works + # for both :func:`spawn_cable` (single curve at ``{prim_path}/geometry/mesh``) + # and arbitrary USDs loaded via :class:`UsdFileCfg`. stage = template_prim.GetStage() - expected_curve_prim_path = f"{template_prim_path}/geometry/mesh" - curve_prim = stage.GetPrimAtPath(expected_curve_prim_path) - if not curve_prim or not curve_prim.IsValid() or curve_prim.GetTypeName() != "BasisCurves": - raise ValueError( - f"Expected a UsdGeomBasisCurves prim at '{expected_curve_prim_path}', " - f"got '{curve_prim.GetTypeName() if curve_prim and curve_prim.IsValid() else None}'." + curve_prims = [ + descendant for descendant in Usd.PrimRange(template_prim) if descendant.GetTypeName() == "BasisCurves" + ] + if not curve_prims: + raise ValueError(f"No UsdGeomBasisCurves prim found under '{template_prim_path}'.") + if len(curve_prims) > 1: + paths = ", ".join(str(p.GetPrimPath()) for p in curve_prims) + raise NotImplementedError( + f"Found {len(curve_prims)} BasisCurves prims under '{template_prim_path}' ({paths}); " + "multi-curve cables under a single CableObject are not supported yet." ) + curve_prim = curve_prims[0] curves = UsdGeom.BasisCurves(curve_prim) # Bake the curve prim's xform into the per-node positions so the replicate @@ -292,8 +296,19 @@ def _register_cable(self) -> CableRegistryEntry: raise ValueError(f"UsdGeomBasisCurves at '{curve_prim.GetPrimPath()}' is missing the `widths` attribute.") radius = float(raw_widths[0]) / 2.0 - # Linear edge chain. - edges = [(i, i + 1) for i in range(len(node_positions) - 1)] + # Read the edge topology from the curve prim's ``int2[] connections`` + # attribute. :func:`~isaaclab.sim.spawners.shapes.spawn_cable` authors a + # linear chain; user-provided USDs (loaded via :class:`UsdFileCfg`) must + # also author this attribute. + connections_attr = curve_prim.GetAttribute("connections") + if not connections_attr.IsValid() or connections_attr.Get() is None: + raise ValueError( + f"UsdGeomBasisCurves at '{curve_prim.GetPrimPath()}' is missing the `connections`" + " attribute (expected `int2[]` listing each edge as a pair of control-point indices)." + " Author this attribute on the curve prim — `spawn_cable` writes it automatically;" + " user-imported curve USDs must add it explicitly." + ) + edges = [(int(e[0]), int(e[1])) for e in connections_attr.Get()] # Look up the bound Newton cable physics material via the standard # MaterialBindingAPI on the curve prim. The material binding requires @@ -343,7 +358,7 @@ def _get_material_attr(name: str, default): # transform. Matches DeformableObject._register_deformable. entry = CableRegistryEntry( prim_path=self.cfg.prim_path, - curve_prim_path=f"{self.cfg.prim_path}/geometry/mesh", + curve_prim_path=str(curve_prim.GetPrimPath()), node_positions=node_positions, edges=edges, radius=radius, From 1d134949b80682b8ee541430e0b0929c9fbbb7ba Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Mon, 18 May 2026 18:11:35 +0200 Subject: [PATCH 13/26] Feat: Add AVBD beta parameter --- .../isaaclab_contrib/deformable/newton_manager_cfg.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py index f6949c849867..e3e6016cc840 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py @@ -97,6 +97,15 @@ class VBDSolverCfg(NewtonSolverCfg): Newton's ``example_cable_pile.py`` uses 256. """ + rigid_avbd_beta: float = 1.0e2 + """Per-iteration AVBD penalty-stiffness ramp rate. + + Each iteration grows every constraint's penalty k by beta * |C| (with |C| the current constraint violation), + clamped to the slot's stiffness ceiling. Starting from a soft k_start and ramping toward the ceiling improves + Hessian conditioning and avoids overshoot when the iteration budget is small, while still enforcing the constraint + by the end of the step. Set to 0 (default) to disable ramping and pin k at the ceiling for the entire step. + """ + @configclass class CoupledMJWarpVBDSolverCfg(NewtonSolverCfg): From 330ec48127aeb7bd14f28eb126bf813657cfcb96 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Mon, 18 May 2026 18:16:24 +0200 Subject: [PATCH 14/26] Fix: Cables tend to self-collide, prevent this. --- .../isaaclab_contrib/cable/cable_object.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py index 87e1e1d0ba27..0aba2f14aa47 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py +++ b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py @@ -69,6 +69,7 @@ def add_cable_entry_to_builder( env_idx: int, env_position: list[float], env_rotation: list[float] | tuple[float, float, float, float], + cable_idx: int = 0, ) -> None: """Add one cable to a Newton ``ModelBuilder`` for one environment. @@ -81,12 +82,21 @@ def add_cable_entry_to_builder( ``_rename_builder_labels`` rewrites the source prefix to each env's destination prefix during replication. + All capsules of this cable share a unique negative ``collision_group`` + (``-(1 + cable_idx)``), which disables segment-vs-segment self-collision while + still letting them collide with the ground and other cables (Newton's group + rule: same negative group = filtered, negative-vs-positive = collides). + Args: builder: The Newton ``ModelBuilder``. entry: Registry entry describing the cable's geometry and material. env_idx: Zero-based environment (world) index. env_position: World translation ``[x, y, z]`` [m] for this environment. env_rotation: World orientation as quaternion ``(x, y, z, w)`` for this environment. + cable_idx: Zero-based index of this cable within + :attr:`SimulationManager._cable_registry`. Used to assign a unique + negative ``shape_collision_group`` per cable so segments don't + self-collide. """ if env_idx == 0: entry.body_offsets.clear() @@ -118,6 +128,10 @@ def add_cable_entry_to_builder( shape_cfg = newton.ModelBuilder.ShapeConfig() shape_cfg.density = float(entry.density) + # Unique negative collision group → cable's own capsules don't collide with + # each other (Newton: same negative group is filtered), while still colliding + # with the ground and other cables (negative-vs-positive collides). + shape_cfg.collision_group = -(1 + cable_idx) # ``label`` is load-bearing: Newton suffixes ``_articulation`` to produce # ``{prim_path}/cable_articulation``, which is the path :class:`ArticulationView` @@ -152,8 +166,8 @@ def add_registered_cables_to_builder( :func:`add_cable_entry_to_builder` for each registered cable. Mirrors :func:`isaaclab_contrib.deformable.deformable_object.add_registered_deformables_to_builder`. """ - for entry in SimulationManager._cable_registry: - add_cable_entry_to_builder(builder, entry, world_idx, env_position, env_rotation) + for cable_idx, entry in enumerate(SimulationManager._cable_registry): + add_cable_entry_to_builder(builder, entry, world_idx, env_position, env_rotation, cable_idx=cable_idx) def install_cable_builder_hooks() -> None: From 824f9b2136771292c8f102cce7cabca86a3222ed Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Mon, 18 May 2026 20:29:27 +0200 Subject: [PATCH 15/26] Fix: FK evaluated for cable joint after all, fix after start simulation --- .../changelog.d/mym-cable.minor.rst | 10 +++ .../deformable/newton_manager_cfg.py | 8 +- .../deformable/vbd_manager.py | 15 +++- .../test/cable/test_cable_object.py | 77 +++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) diff --git a/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst b/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst index 6516bf1d2e62..04b8f288f25e 100644 --- a/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst +++ b/source/isaaclab_contrib/changelog.d/mym-cable.minor.rst @@ -27,3 +27,13 @@ Fixed mask out cable articulations. Newton's ``eval_fk`` has no :attr:`newton.JointType.CABLE` case and was collapsing rod segments onto their parent anchors every time Kit triggered a pre-render FK pass. +* Fixed curved cables (e.g. loaded via :class:`~isaaclab.sim.UsdFileCfg`) + exploding on the first sim step. The unmasked ``eval_fk`` at the end of + :meth:`~isaaclab_newton.physics.NewtonManager.start_simulation` was + corrupting cable ``state_0.body_q`` (same ``JointType.CABLE`` fall-through + as above), so non-collinear cable layouts started the simulation collapsed + onto the root segment's local +Z axis. + :meth:`~isaaclab_contrib.deformable.vbd_manager.NewtonVBDManager.start_simulation` + now rebuilds ``state_0`` / ``state_1`` from ``model.state()`` after the base + finalize step, then re-runs the masked :meth:`forward` to seed non-cable + ``body_q`` without touching cables. diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py index e3e6016cc840..93489a8a7491 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py @@ -98,10 +98,10 @@ class VBDSolverCfg(NewtonSolverCfg): """ rigid_avbd_beta: float = 1.0e2 - """Per-iteration AVBD penalty-stiffness ramp rate. - - Each iteration grows every constraint's penalty k by beta * |C| (with |C| the current constraint violation), - clamped to the slot's stiffness ceiling. Starting from a soft k_start and ramping toward the ceiling improves + """Per-iteration AVBD penalty-stiffness ramp rate. + + Each iteration grows every constraint's penalty k by beta * |C| (with |C| the current constraint violation), + clamped to the slot's stiffness ceiling. Starting from a soft k_start and ramping toward the ceiling improves Hessian conditioning and avoids overshoot when the iteration budget is small, while still enforcing the constraint by the end of the step. Set to 0 (default) to disable ramping and pin k at the ceiling for the entire step. """ diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py index 66d62e61d911..2fc93866d0d4 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -149,6 +149,19 @@ def start_simulation(cls) -> None: """ super().start_simulation() + # Newton's ``eval_fk`` has no case for :attr:`newton.JointType.CABLE`, so the unmasked + # ``eval_fk`` at the end of :meth:`NewtonManager.start_simulation` collapsed every cable + # capsule onto its parent joint anchor (same failure mode that motivates the mask for + # later FK passes). Drop the corrupted states and rebuild them from ``model.body_q`` + # (untouched by ``eval_fk``), then re-run :meth:`forward` with the cable mask to seed + # non-cable ``body_q`` from joint coordinates without touching cables. + # NOTE: Can be removed once Newton patches cable joints in eval_fk. + cls._build_non_cable_articulation_mask() + if cls._non_cable_articulation_mask is not None and cls._model is not None: + cls._state_0 = cls._model.state() + cls._state_1 = cls._model.state() + cls.forward() + # Apply global model parameters from :class:`NewtonModelCfg` to the finalized model. # Sets ``soft_contact_ke/kd/mu`` and optionally overrides per-shape # ``shape_material_ke/kd/mu`` on the Newton model. @@ -261,8 +274,6 @@ def start_simulation(cls) -> None: if curves_registered: cls._mark_curves_dirty() - cls._build_non_cable_articulation_mask() - @classmethod def _build_non_cable_articulation_mask(cls) -> None: """Build :attr:`_non_cable_articulation_mask` from finalized joint topology. diff --git a/source/isaaclab_contrib/test/cable/test_cable_object.py b/source/isaaclab_contrib/test/cable/test_cable_object.py index 0dcd6e074ab6..d77c9d0040b6 100644 --- a/source/isaaclab_contrib/test/cable/test_cable_object.py +++ b/source/isaaclab_contrib/test/cable/test_cable_object.py @@ -367,3 +367,80 @@ class _SceneCfg(InteractiveSceneCfg): body_q_before, err_msg="forward() altered body_q — cable mask did not exclude cable articulations.", ) + + +def test_start_simulation_preserves_curved_cable_body_q(): + """Regression test for the cable body_q restoration after start_simulation's eval_fk. + + :meth:`NewtonManager.start_simulation` ends with an unmasked ``eval_fk`` to seed + ``state_0.body_q`` from joint coordinates. Newton's ``eval_fk`` has no case for + :attr:`newton.JointType.CABLE`, so cable joints fall through to identity and each + child capsule collapses onto its parent joint anchor — rotating curved cables onto + the root segment's local +Z axis. + + For a *straight* cable the corruption is invisible (eval_fk's identity output matches + the layout produced by ``add_rod_graph``), so a non-collinear node layout is required + to expose the bug. :meth:`NewtonVBDManager._restore_cable_body_q` undoes the corruption + by copying ``model.body_q`` (untouched by ``eval_fk``) back into ``state_0.body_q`` for + cable bodies. + """ + import numpy as np + from isaaclab_newton.physics import NewtonCfg + from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg as _NewtonCableMaterialCfg + + import isaaclab.sim as sim_utils + from isaaclab.scene import InteractiveScene, InteractiveSceneCfg + from isaaclab.sim import SimulationCfg, build_simulation_context + from isaaclab.utils import configclass + + from isaaclab_contrib.cable import CableObjectCfg + from isaaclab_contrib.deformable.newton_manager_cfg import VBDSolverCfg + from isaaclab_contrib.deformable.vbd_manager import NewtonVBDManager + + # Curved cable: three nodes whose edges (0->1 along +x, 1->2 along +y) point in + # different directions, so adjacent capsule orientations differ. eval_fk's identity + # output would collapse body[1] onto body[0]'s +Z axis (still pointing +x), but the + # rest pose has body[1] rotated to align +Z with +y. + cable_spawn = sim_utils.CableCfg( + positions=[(0.0, 0.0, 0.0), (0.1, 0.0, 0.0), (0.1, 0.1, 0.0)], + width=0.01, + physics_material=_NewtonCableMaterialCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + ) + + @configclass + class _SceneCfg(InteractiveSceneCfg): + num_envs: int = 1 + env_spacing: float = 1.0 + cable: CableObjectCfg = CableObjectCfg(prim_path="{ENV_REGEX_NS}/Cable", spawn=cable_spawn) + + newton_sim_cfg = SimulationCfg(physics=NewtonCfg(solver_cfg=VBDSolverCfg())) + + with build_simulation_context(device="cuda:0", sim_cfg=newton_sim_cfg, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + InteractiveScene(_SceneCfg()) + sim.reset() # triggers start_simulation -> unmasked eval_fk -> _restore_cable_body_q + + # ``model.body_q`` holds the rest pose produced by ``add_rod_graph`` and is never + # written by ``eval_fk``. With the restoration in place, ``state_0.body_q`` for cable + # bodies must match ``model.body_q`` bit-for-bit. Without the fix, the second cable + # body's quaternion differs (eval_fk reuses the root segment's orientation). + assert NewtonVBDManager._cable_registry, "Cable registry empty — replicate hook did not run." + + body_q_state = NewtonVBDManager._state_0.body_q.numpy() + body_q_model = NewtonVBDManager._model.body_q.numpy() + + cable_body_indices: list[int] = [] + for entry in NewtonVBDManager._cable_registry: + for body_offset in entry.body_offsets: + cable_body_indices.extend(range(body_offset, body_offset + len(entry.edges))) + + np.testing.assert_allclose( + body_q_state[cable_body_indices], + body_q_model[cable_body_indices], + err_msg=( + "Cable body_q in state_0 does not match model.body_q after start_simulation." + " The unmasked eval_fk corrupted cable bodies and _restore_cable_body_q did not" + " restore them." + ), + ) From c5a1f1d59c79a17c8296da7d091cd27ebd04dd1e Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Mon, 18 May 2026 20:36:17 +0200 Subject: [PATCH 16/26] Test: USD loadable cables --- scripts/demos/cables.py | 63 ++++++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/scripts/demos/cables.py b/scripts/demos/cables.py index d7db508fa1d1..ee73424ad5cc 100644 --- a/scripts/demos/cables.py +++ b/scripts/demos/cables.py @@ -36,6 +36,7 @@ import tqdm from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg +from isaaclab_visualizers.newton.newton_visualizer_cfg import NewtonVisualizerCfg import isaaclab.sim as sim_utils @@ -55,17 +56,17 @@ def design_scene(num_cables: int) -> dict[str, CableObject]: light_cfg.func("/World/light", light_cfg) # Cable centerline: 10 control points along local +X, length ~0.9 m. - num_points = 10 - segment_length = 0.1 + num_points = 20 + segment_length = 0.015 cable_length = (num_points - 1) * segment_length - width = 0.03 + width = 0.01 # Pile footprint: small XY box, stacked Z so cables fall and intersect. # Spacing is generous to avoid self-contact at spawn, and the base height is # kept low so cables don't gain a lot of velocity before first contact. - xy_jitter = 0.6 - z_spacing = 2.5 * width - z_base = 0.1 + xy_jitter = 0.3 + z_spacing = 1.5 * width + z_base = 0.8 print(f"[INFO]: Spawning {num_cables} cables...") entities: dict[str, CableObject] = {} @@ -86,7 +87,7 @@ def design_scene(num_cables: int) -> dict[str, CableObject]: bend_stiffness=1e-4, stretch_damping=1e-4, bend_damping=1e-4, - density=1000.0, + density=100.0, ), collision_props=sim_utils.CollisionPropertiesCfg(), ) @@ -97,6 +98,40 @@ def design_scene(num_cables: int) -> dict[str, CableObject]: ) entities[f"Cable{idx:03d}"] = CableObject(cfg=cfg) + spawn_cfg = sim_utils.UsdFileCfg( + usd_path="/home/mmichelis/Documents/IsaacLab-Origin/scripts/demos/cable001.usda", + physics_material=NewtonCableMaterialCfg( + stretch_stiffness=1e6, + bend_stiffness=1e-4, + stretch_damping=1e-4, + bend_damping=1e-4, + density=100.0, + ), + ) + cfg = CableObjectCfg( + prim_path=f"/World/Origin/Cable1{idx:03d}", + spawn=spawn_cfg, + init_state=CableObjectCfg.InitialStateCfg(pos=(cx, cy, cz), rot=z_axis_quat(angle)), + ) + entities[f"Cable1{idx:03d}"] = CableObject(cfg=cfg) + + spawn_cfg = sim_utils.UsdFileCfg( + usd_path="/home/mmichelis/Documents/IsaacLab-Origin/scripts/demos/cable002.usda", + physics_material=NewtonCableMaterialCfg( + stretch_stiffness=1e6, + bend_stiffness=1e-4, + stretch_damping=1e-4, + bend_damping=1e-4, + density=100.0, + ), + ) + cfg = CableObjectCfg( + prim_path=f"/World/Origin/Cable2{idx:03d}", + spawn=spawn_cfg, + init_state=CableObjectCfg.InitialStateCfg(pos=(cx, cy, cz), rot=z_axis_quat(angle)), + ) + entities[f"Cable2{idx:03d}"] = CableObject(cfg=cfg) + return entities @@ -127,9 +162,7 @@ def main(): physics_cfg = NewtonCfg( solver_cfg=VBDSolverCfg( - iterations=20, - rigid_body_contact_buffer_size=256, - rigid_contact_k_start=1.0e1, + iterations=20, rigid_body_contact_buffer_size=1024, rigid_contact_k_start=1.0e1, rigid_avbd_beta=1e2 ), num_substeps=8, ) @@ -138,12 +171,16 @@ def main(): # cables from sliding off the pile. physics_cfg.model_cfg = NewtonModelCfg( shape_material_ke=1.0e3, - shape_material_kd=1.0e1, + shape_material_kd=1.0e0, shape_material_mu=1.0, ) - sim_cfg = sim_utils.SimulationCfg(dt=0.01, device=args_cli.device, physics=physics_cfg) + sim_cfg = sim_utils.SimulationCfg( + dt=0.01, + device=args_cli.device, + physics=physics_cfg, + visualizer_cfgs=[NewtonVisualizerCfg(eye=(2.5, 2.5, 1.0), lookat=(0.0, 0.0, 0.25))], + ) sim = sim_utils.SimulationContext(sim_cfg) - sim.set_camera_view([2.5, 2.5, 2.0], [0.0, 0.0, 0.3]) scene_entities = design_scene(num_cables=args_cli.num_cables) sim.reset() From dd81fc80d05f297574698136fa6bd024eea073da Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Mon, 18 May 2026 23:22:14 +0200 Subject: [PATCH 17/26] Fix: Environments properly initialized --- .../isaaclab_contrib/deformable/vbd_manager.py | 13 +++++-------- .../isaaclab_newton/physics/newton_manager.py | 2 +- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py index 2fc93866d0d4..88bc11f69a5e 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -342,15 +342,12 @@ def forward(cls) -> None: :meth:`~isaaclab.visualizers.BaseVisualizer.requires_forward_before_step` to ``True``). """ - if cls._non_cable_articulation_mask is None: + if cls._non_cable_articulation_mask is None: if cls._cable_registry: - raise RuntimeError( - "Cables are registered but `_non_cable_articulation_mask` is None — refusing to" - " fall through to the unmasked eval_fk that would corrupt cable body_q. The mask" - " is built in `start_simulation()`; ensure it has run." - ) - super().forward() - return + cls._build_non_cable_articulation_mask() + else: + super().forward() + return eval_fk( cls._model, cls._state_0.joint_q, diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index bc849daf79ab..a3be601ab814 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -980,7 +980,7 @@ def start_simulation(cls) -> None: NewtonManager._state_0 = cls._model.state() NewtonManager._state_1 = cls._model.state() NewtonManager._control = cls._model.control() - eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, None) + cls.forward() # The single global actuator adapter is built lazily on the first # call to ``activate_newton_actuator_path`` from any Newton-fast-path From 5e2c1673bdaa452acce5658f690bf461c694d4f9 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Mon, 18 May 2026 23:33:01 +0200 Subject: [PATCH 18/26] Fix: remove fixed mask --- .../isaaclab_contrib/deformable/vbd_manager.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py index 88bc11f69a5e..447842e044e3 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -149,19 +149,6 @@ def start_simulation(cls) -> None: """ super().start_simulation() - # Newton's ``eval_fk`` has no case for :attr:`newton.JointType.CABLE`, so the unmasked - # ``eval_fk`` at the end of :meth:`NewtonManager.start_simulation` collapsed every cable - # capsule onto its parent joint anchor (same failure mode that motivates the mask for - # later FK passes). Drop the corrupted states and rebuild them from ``model.body_q`` - # (untouched by ``eval_fk``), then re-run :meth:`forward` with the cable mask to seed - # non-cable ``body_q`` from joint coordinates without touching cables. - # NOTE: Can be removed once Newton patches cable joints in eval_fk. - cls._build_non_cable_articulation_mask() - if cls._non_cable_articulation_mask is not None and cls._model is not None: - cls._state_0 = cls._model.state() - cls._state_1 = cls._model.state() - cls.forward() - # Apply global model parameters from :class:`NewtonModelCfg` to the finalized model. # Sets ``soft_contact_ke/kd/mu`` and optionally overrides per-shape # ``shape_material_ke/kd/mu`` on the Newton model. From bbf61de71d169508f291028e4e0d92526f745280 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Mon, 18 May 2026 23:35:35 +0200 Subject: [PATCH 19/26] Fix: typo --- .../isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py index 447842e044e3..aa070fcc9574 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -329,7 +329,7 @@ def forward(cls) -> None: :meth:`~isaaclab.visualizers.BaseVisualizer.requires_forward_before_step` to ``True``). """ - if cls._non_cable_articulation_mask is None: + if cls._non_cable_articulation_mask is None: if cls._cable_registry: cls._build_non_cable_articulation_mask() else: From cb9c9ff2bebd5e8b7ffcc4a5f46908be020e1e95 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Wed, 20 May 2026 15:54:12 +0200 Subject: [PATCH 20/26] Feat: Reset of cables to init state --- scripts/demos/cables.py | 74 +++++++++---------- .../isaaclab_contrib/cable/cable_object.py | 46 ++++++++++++ 2 files changed, 83 insertions(+), 37 deletions(-) diff --git a/scripts/demos/cables.py b/scripts/demos/cables.py index ee73424ad5cc..6c3b701d4834 100644 --- a/scripts/demos/cables.py +++ b/scripts/demos/cables.py @@ -98,53 +98,52 @@ def design_scene(num_cables: int) -> dict[str, CableObject]: ) entities[f"Cable{idx:03d}"] = CableObject(cfg=cfg) - spawn_cfg = sim_utils.UsdFileCfg( - usd_path="/home/mmichelis/Documents/IsaacLab-Origin/scripts/demos/cable001.usda", - physics_material=NewtonCableMaterialCfg( - stretch_stiffness=1e6, - bend_stiffness=1e-4, - stretch_damping=1e-4, - bend_damping=1e-4, - density=100.0, - ), - ) - cfg = CableObjectCfg( - prim_path=f"/World/Origin/Cable1{idx:03d}", - spawn=spawn_cfg, - init_state=CableObjectCfg.InitialStateCfg(pos=(cx, cy, cz), rot=z_axis_quat(angle)), - ) - entities[f"Cable1{idx:03d}"] = CableObject(cfg=cfg) - - spawn_cfg = sim_utils.UsdFileCfg( - usd_path="/home/mmichelis/Documents/IsaacLab-Origin/scripts/demos/cable002.usda", - physics_material=NewtonCableMaterialCfg( - stretch_stiffness=1e6, - bend_stiffness=1e-4, - stretch_damping=1e-4, - bend_damping=1e-4, - density=100.0, - ), - ) - cfg = CableObjectCfg( - prim_path=f"/World/Origin/Cable2{idx:03d}", - spawn=spawn_cfg, - init_state=CableObjectCfg.InitialStateCfg(pos=(cx, cy, cz), rot=z_axis_quat(angle)), - ) - entities[f"Cable2{idx:03d}"] = CableObject(cfg=cfg) + # spawn_cfg = sim_utils.UsdFileCfg( + # usd_path="/home/mmichelis/Documents/IsaacLab-Origin/scripts/demos/cable001.usda", + # physics_material=NewtonCableMaterialCfg( + # stretch_stiffness=1e6, + # bend_stiffness=1e-4, + # stretch_damping=1e-4, + # bend_damping=1e-4, + # density=100.0, + # ), + # ) + # cfg = CableObjectCfg( + # prim_path=f"/World/Origin/Cable1{idx:03d}", + # spawn=spawn_cfg, + # init_state=CableObjectCfg.InitialStateCfg(pos=(cx, cy, cz), rot=z_axis_quat(angle)), + # ) + # entities[f"Cable1{idx:03d}"] = CableObject(cfg=cfg) + + # spawn_cfg = sim_utils.UsdFileCfg( + # usd_path="/home/mmichelis/Documents/IsaacLab-Origin/scripts/demos/cable002.usda", + # physics_material=NewtonCableMaterialCfg( + # stretch_stiffness=1e6, + # bend_stiffness=1e-4, + # stretch_damping=1e-4, + # bend_damping=1e-4, + # density=100.0, + # ), + # ) + # cfg = CableObjectCfg( + # prim_path=f"/World/Origin/Cable2{idx:03d}", + # spawn=spawn_cfg, + # init_state=CableObjectCfg.InitialStateCfg(pos=(cx, cy, cz), rot=z_axis_quat(angle)), + # ) + # entities[f"Cable2{idx:03d}"] = CableObject(cfg=cfg) return entities def run_simulator(sim: sim_utils.SimulationContext, entities: dict[str, CableObject]): - """Step the sim and periodically reset cable state.""" + """Step the sim and periodically snap cables back to their initial state.""" sim_dt = sim.get_physics_dt() - reset_steps = int(3.0 / sim_dt) + reset_steps = int(2.0 / sim_dt) count = 0 while simulation_app.is_running(): if count % reset_steps == 0: count = 0 - # Cables have no nodal snap-back; reset internal buffers only. for cable in entities.values(): cable.reset() print("[INFO]: Resetting cable state...") @@ -156,7 +155,7 @@ def run_simulator(sim: sim_utils.SimulationContext, entities: dict[str, CableObj def main(): """Main entry point.""" - from isaaclab_newton.physics import NewtonCfg + from isaaclab_newton.physics import NewtonCfg, NewtonCollisionPipelineCfg from isaaclab_contrib.deformable.newton_manager_cfg import NewtonModelCfg, VBDSolverCfg @@ -165,6 +164,7 @@ def main(): iterations=20, rigid_body_contact_buffer_size=1024, rigid_contact_k_start=1.0e1, rigid_avbd_beta=1e2 ), num_substeps=8, + collision_cfg=NewtonCollisionPipelineCfg(rigid_contact_max=65536), ) # Soften body-body contact: lower ke + nonzero kd damps out the # spikes when many cable segments pile onto one segment. mu=1.0 keeps diff --git a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py index 0aba2f14aa47..1cfe256b6921 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py +++ b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py @@ -17,6 +17,7 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -201,6 +202,8 @@ class CableObject(Articulation): cable registry. Caller must have called :func:`install_cable_builder_hooks` before constructing any :class:`CableObject` (typical: from a solver manager init, mirroring how the deformable contrib package wires things up). + - :meth:`reset` snaps each environment's cable bodies back to the + rest pose stored in ``model.body_q``. """ cfg: CableObjectCfg @@ -384,3 +387,46 @@ def _get_material_attr(name: str, default): ) SimulationManager._cable_registry.append(entry) return entry + + def reset( + self, + env_ids: Sequence[int] | slice | None = None, + env_mask: wp.array | None = None, + ) -> None: + """Snap each env's cable bodies back to the spawn pose. + + Restores four arrays per-env body slice. ``state.body_q`` and + ``solver.body_q_prev`` come from :attr:`Model.body_q` (the rest-pose + template that :class:`SolverVBD` itself reads at init); + ``state.body_qd`` and ``solver.body_inertia_q`` are zeroed. + ``body_q_prev`` is load-bearing — AVBD computes implicit velocity as + ``(body_q - body_q_prev) / dt``, so without this the snap-back + produces ~700 m/s spurious velocities. + + Joint state and AVBD penalty/Dahl buffers are intentionally not + touched: they are global to the world (penalty ``k``) or would need + joint offsets in the registry (Dahl, ``joint_q``); in practice the + body-side reset is sufficient to keep post-reset dynamics bounded. + + Args: + env_ids: Environment indices to reset. ``None`` means all. + env_mask: Parent-class compatibility; unused. + """ + super().reset(env_ids=env_ids, env_mask=env_mask) + if not getattr(self, "_is_initialized", False) or SimulationManager._solver is None: + return + model = SimulationManager.get_model() + state = SimulationManager.get_state_0() + solver = SimulationManager._solver + body_offsets = self._registry_entry.body_offsets + n = len(self._registry_entry.edges) + # Per-call zero buffer for velocity slices (one segment chain wide). + zero_qd = wp.zeros(n, dtype=state.body_qd.dtype, device=state.body_qd.device) + zero_q = wp.zeros(n, dtype=solver.body_inertia_q.dtype, device=solver.body_inertia_q.device) + env_iter = range(len(body_offsets)) if env_ids is None or env_ids == slice(None) else list(env_ids) + for env_idx in env_iter: + offset = int(body_offsets[env_idx]) + wp.copy(dest=state.body_q, src=model.body_q, dest_offset=offset, src_offset=offset, count=n) + wp.copy(dest=solver.body_q_prev, src=model.body_q, dest_offset=offset, src_offset=offset, count=n) + wp.copy(dest=state.body_qd, src=zero_qd, dest_offset=offset, count=n) + wp.copy(dest=solver.body_inertia_q, src=zero_q, dest_offset=offset, count=n) From 2e47b51fe281fe08e4ea78c3cd812a0e69a63ddb Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Wed, 20 May 2026 16:22:18 +0200 Subject: [PATCH 21/26] Docs: update cables usd loading and resetting --- .../physical-backends/newton/using-cables.rst | 73 ++++++++++++++++++- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/docs/source/overview/core-concepts/physical-backends/newton/using-cables.rst b/docs/source/overview/core-concepts/physical-backends/newton/using-cables.rst index cbec48ab323d..e61bc4a790e3 100644 --- a/docs/source/overview/core-concepts/physical-backends/newton/using-cables.rst +++ b/docs/source/overview/core-concepts/physical-backends/newton/using-cables.rst @@ -203,10 +203,75 @@ device** because Kit / Hydra reads curve points from the CPU Fabric bucket for runtime-spawned ``UsdGeomBasisCurves``. If your visualizer skips curves at runtime, prefer the default ``--visualizer kit`` flag used by the demo. -A ``reset()`` call on a :class:`~isaaclab_contrib.cable.CableObject` does -**not** snap control points back to their initial positions: cables have no -nodal snap-back, only internal-buffer reset. To re-pose cables, write directly -into ``body_q`` or recreate the scene. +A ``reset()`` call on a :class:`~isaaclab_contrib.cable.CableObject` snaps +each environment's cable bodies back to the spawn pose stored in +``newton.Model.body_q`` and zeroes both ``state.body_qd`` and the AVBD +``solver.body_inertia_q`` buffer. The implicit-velocity buffer +``solver.body_q_prev`` is also restored to the rest pose — without this, +AVBD's ``(body_q - body_q_prev) / dt`` velocity estimate would emit ~700 m/s +spurious velocities the step after a snap-back. Joint state and AVBD +penalty / Dahl buffers are intentionally left alone: they are either global +to the world or would require joint offsets in the registry to slice +per-env, and the body-side reset is sufficient to keep post-reset dynamics +bounded in practice. + + +Loading Cables from USD +----------------------- + +In addition to the procedural :class:`~isaaclab.sim.spawners.shapes.CableCfg` +path, a cable can be loaded from an arbitrary USD via +:class:`~isaaclab.sim.spawners.from_files.UsdFileCfg`. The USD must contain +exactly one ``UsdGeomBasisCurves`` prim anywhere under the loaded template +prim — :class:`~isaaclab_contrib.cable.CableObject` walks the template +prim's descendants with ``Usd.PrimRange`` and raises +``NotImplementedError`` if more than one curve is found (multi-curve cables +under a single :class:`CableObject` are not supported yet). + +.. code-block:: python + + from isaaclab_contrib.cable import CableObject, CableObjectCfg + from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg + + import isaaclab.sim as sim_utils + + cable = CableObject( + cfg=CableObjectCfg( + prim_path="/World/Origin/Cable", + spawn=sim_utils.UsdFileCfg( + usd_path="path/to/cable.usda", + physics_material=NewtonCableMaterialCfg(density=100.0), + ), + init_state=CableObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 0.5)), + ) + ) + +The curve prim must author three attributes: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Attribute + - Description + * - ``point3f[] points`` + - Control points in the curve prim's local frame [m]. The curve prim's + xform is baked into these positions at registration time, so the + replicate hook only needs to apply the per-env transform. + * - ``float[] widths`` + - One width per control point [m]. For now, only the first entry is + read — it defines the capsule diameter for every segment. + * - ``int2[] connections`` + - Edge topology — each ``Vec2i`` lists the indices of one segment's two + endpoint control points. :func:`~isaaclab.sim.spawners.shapes.spawn_cable` + writes a linear chain ``[(0,1), (1,2), ...]`` automatically; + user-imported curve USDs must author this attribute explicitly, since + ``connections`` is not part of the ``UsdGeomBasisCurves`` schema and + cannot be inferred from the curve's vertex counts. + +The Newton cable material is taken from the spawner's ``physics_material`` +binding on the curve prim. If no Newton cable material is bound, the +:class:`~isaaclab_contrib.cable.CableRegistryEntry` defaults are used. Limitations From e0969c1360145c24e342950645ad94aab19302c3 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Wed, 20 May 2026 16:29:44 +0200 Subject: [PATCH 22/26] Docs: Update parameter docstring --- .../isaaclab_contrib/deformable/newton_manager_cfg.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py index 93489a8a7491..82900263e9e9 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py @@ -100,10 +100,11 @@ class VBDSolverCfg(NewtonSolverCfg): rigid_avbd_beta: float = 1.0e2 """Per-iteration AVBD penalty-stiffness ramp rate. - Each iteration grows every constraint's penalty k by beta * |C| (with |C| the current constraint violation), - clamped to the slot's stiffness ceiling. Starting from a soft k_start and ramping toward the ceiling improves - Hessian conditioning and avoids overshoot when the iteration budget is small, while still enforcing the constraint - by the end of the step. Set to 0 (default) to disable ramping and pin k at the ceiling for the entire step. + Each iteration grows every constraint's penalty ``k`` by ``beta * |C|`` (with ``|C|`` the current constraint + violation), clamped to the slot's stiffness ceiling. Starting from a soft ``k_start`` and ramping toward the + ceiling improves Hessian conditioning and avoids overshoot when the iteration budget is small, while still + enforcing the constraint by the end of the step. Set to ``0`` (default) to disable ramping and pin ``k`` at + the ceiling for the entire step. """ From 25f234d3c91c7614fbbb350927b7309375b47cf7 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Wed, 20 May 2026 16:31:02 +0200 Subject: [PATCH 23/26] Fix: unit tests --- .../isaaclab_contrib/cable/cable_object.py | 7 +++++++ source/isaaclab_contrib/test/cable/test_cable_object.py | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py index 1cfe256b6921..da249e2d505c 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py +++ b/source/isaaclab_contrib/isaaclab_contrib/cable/cable_object.py @@ -267,6 +267,13 @@ def _register_cable(self) -> CableRegistryEntry: " manager init, mirroring the deformable contrib pattern)." ) + if self.cfg.spawn is None: + raise ValueError( + f"CableObjectCfg(prim_path='{self.cfg.prim_path}') has no `spawn` configuration." + " CableObject requires a `CableCfg` (or compatible USD-loading cfg) to register" + " cable geometry; pass one via `CableObjectCfg.spawn`." + ) + # Resolve the spawned template prim. ``spawn_path`` is set by InteractiveScene's # template-based cloning flow; falls back to ``prim_path`` for direct envs that # spawn straight at the cloned regex. diff --git a/source/isaaclab_contrib/test/cable/test_cable_object.py b/source/isaaclab_contrib/test/cable/test_cable_object.py index d77c9d0040b6..8a75bf9d4d65 100644 --- a/source/isaaclab_contrib/test/cable/test_cable_object.py +++ b/source/isaaclab_contrib/test/cable/test_cable_object.py @@ -53,8 +53,8 @@ def test_add_registered_cables_iterates_registry(monkeypatch): calls = [] - def _fake_entry_hook(builder, entry, env_idx, env_pos, env_rot): - calls.append((entry.prim_path, env_idx)) + def _fake_entry_hook(builder, entry, env_idx, env_pos, env_rot, cable_idx=0): + calls.append((entry.prim_path, env_idx, cable_idx)) monkeypatch.setattr( "isaaclab_contrib.cable.cable_object.add_cable_entry_to_builder", @@ -78,7 +78,7 @@ def _fake_entry_hook(builder, entry, env_idx, env_pos, env_rot): add_registered_cables_to_builder(builder=None, world_idx=3, env_position=[0, 0, 0], env_rotation=[0, 0, 0, 1]) - assert calls == [("/World/cable_a", 3), ("/World/cable_b", 3)] + assert calls == [("/World/cable_a", 3, 0), ("/World/cable_b", 3, 1)] class _FakeBuilder: From beeb8728cdf34824159cae071c420c78c345babd Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Wed, 20 May 2026 16:38:21 +0200 Subject: [PATCH 24/26] Test: Add cabl;e reset test --- .../test/cable/test_cable_object.py | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/source/isaaclab_contrib/test/cable/test_cable_object.py b/source/isaaclab_contrib/test/cable/test_cable_object.py index 8a75bf9d4d65..80d0ff2c9202 100644 --- a/source/isaaclab_contrib/test/cable/test_cable_object.py +++ b/source/isaaclab_contrib/test/cable/test_cable_object.py @@ -444,3 +444,104 @@ class _SceneCfg(InteractiveSceneCfg): " restore them." ), ) + + +def test_cable_object_reset_restores_body_state(): + """``CableObject.reset()`` snaps the cable's body slice back to the rest pose. + + Steps the sim to drift the cable away from its spawn pose, calls + :meth:`CableObject.reset`, and verifies that: + + 1. ``state.body_q`` matches ``model.body_q`` for the cable's bodies. + 2. ``state.body_qd`` is zero for the cable's bodies. + 3. ``solver.body_q_prev`` is refreshed to the rest pose (otherwise AVBD's + implicit velocity ``(body_q - body_q_prev) / dt`` would produce + hundreds of m/s on the next step). + 4. ``solver.body_inertia_q`` is zero (matches solver-init default). + 5. One more ``sim.step()`` keeps ``|body_qd|`` bounded (regression for the + ~700 m/s spurious-velocity bug). + """ + import numpy as np + from isaaclab_newton.physics import NewtonCfg + from isaaclab_newton.sim.spawners.materials import NewtonCableMaterialCfg as _NewtonCableMaterialCfg + + import isaaclab.sim as sim_utils + from isaaclab.scene import InteractiveScene, InteractiveSceneCfg + from isaaclab.sim import SimulationCfg, build_simulation_context + from isaaclab.utils import configclass + + from isaaclab_contrib.cable import CableObjectCfg + from isaaclab_contrib.deformable.newton_manager_cfg import VBDSolverCfg + from isaaclab_contrib.deformable.vbd_manager import NewtonVBDManager + + cable_spawn = sim_utils.CableCfg( + positions=[(0.0, 0.0, 0.0), (0.05, 0.0, 0.0), (0.1, 0.0, 0.0)], + width=0.01, + physics_material=_NewtonCableMaterialCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + ) + + @configclass + class _SceneCfg(InteractiveSceneCfg): + num_envs: int = 1 + env_spacing: float = 1.0 + cable: CableObjectCfg = CableObjectCfg( + prim_path="{ENV_REGEX_NS}/Cable", + spawn=cable_spawn, + init_state=CableObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 0.5)), + ) + + newton_sim_cfg = SimulationCfg(physics=NewtonCfg(solver_cfg=VBDSolverCfg(iterations=10), num_substeps=4), dt=0.01) + + with build_simulation_context(device="cuda:0", sim_cfg=newton_sim_cfg, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + scene = InteractiveScene(_SceneCfg()) + sim.reset() + + cable = scene["cable"] + entry = cable._registry_entry + body_indices = list(range(entry.body_offsets[0], entry.body_offsets[0] + len(entry.edges))) + + body_q_model = NewtonVBDManager._model.body_q.numpy()[body_indices] + + # Step under gravity so the cable's body slice drifts away from the rest pose. + for _ in range(20): + sim.step() + body_q_drifted = NewtonVBDManager._state_0.body_q.numpy()[body_indices] + assert not np.allclose(body_q_drifted, body_q_model, atol=1e-4), ( + "Sim did not advance: cable body_q matches model.body_q without stepping." + ) + + cable.reset() + + body_q_after = NewtonVBDManager._state_0.body_q.numpy()[body_indices] + body_qd_after = NewtonVBDManager._state_0.body_qd.numpy()[body_indices] + body_q_prev_after = NewtonVBDManager._solver.body_q_prev.numpy()[body_indices] + body_inertia_q_after = NewtonVBDManager._solver.body_inertia_q.numpy()[body_indices] + + np.testing.assert_allclose( + body_q_after, + body_q_model, + err_msg="state.body_q was not restored to model.body_q after CableObject.reset().", + ) + np.testing.assert_array_equal( + body_qd_after, + np.zeros_like(body_qd_after), + err_msg="state.body_qd was not zeroed after CableObject.reset().", + ) + np.testing.assert_allclose( + body_q_prev_after, + body_q_model, + err_msg="solver.body_q_prev was not refreshed to model.body_q after CableObject.reset().", + ) + np.testing.assert_array_equal( + body_inertia_q_after, + np.zeros_like(body_inertia_q_after), + err_msg="solver.body_inertia_q was not zeroed after CableObject.reset().", + ) + + # One step of free-fall should add at most ~g*dt = ~0.1 m/s. A failure + # here (e.g. ~700 m/s) indicates AVBD picked up stale solver-side state. + sim.step() + max_speed = float(np.abs(NewtonVBDManager._state_0.body_qd.numpy()[body_indices]).max()) + assert max_speed < 1.0, f"body_qd exploded after first post-reset step: |body_qd|_max={max_speed}" From 663f75b98340d007b45d753fc4f633de939beb28 Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Wed, 20 May 2026 16:39:37 +0200 Subject: [PATCH 25/26] Style: lint --- source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index 86f61858d112..d89211660f07 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -297,7 +297,8 @@ def spawn_cable( # explicitly because ``connections`` is not part of the UsdGeomBasisCurves # schema; ``create_prim``'s attribute dict relies on ``GetAttribute().Set()`` # which can't infer a type for non-schema attributes. - # TODO: Remove in the future once UsdGeomBasisCurves natively supports curve topology. For now, the Newton replicate hook expects this attribute to build the rod graph. + # TODO: Remove in the future once UsdGeomBasisCurves natively supports curve topology. + # For now, the Newton replicate hook expects this attribute to build the rod graph. mesh_prim = stage.GetPrimAtPath(prim_path + "/geometry/mesh") connections_attr = mesh_prim.CreateAttribute("connections", Sdf.ValueTypeNames.Int2Array, True) connections_attr.Set([Gf.Vec2i(i, i + 1) for i in range(n_points - 1)]) From a8a96f35991fed646b9a59146b42429f9df5a3fb Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis Date: Wed, 20 May 2026 16:44:21 +0200 Subject: [PATCH 26/26] Style: Clean up demo --- scripts/demos/cables.py | 37 +------------------------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/scripts/demos/cables.py b/scripts/demos/cables.py index 6c3b701d4834..c3e7d7260020 100644 --- a/scripts/demos/cables.py +++ b/scripts/demos/cables.py @@ -23,7 +23,6 @@ parser = argparse.ArgumentParser(description="Spawn a pile of cables at varied z-axis rotations.") parser.add_argument("--num_cables", type=int, default=25, help="Number of cables to spawn.") AppLauncher.add_app_launcher_args(parser) -parser.set_defaults(visualizer=["kit"]) args_cli = parser.parse_args() app_launcher = AppLauncher(args_cli) @@ -98,40 +97,6 @@ def design_scene(num_cables: int) -> dict[str, CableObject]: ) entities[f"Cable{idx:03d}"] = CableObject(cfg=cfg) - # spawn_cfg = sim_utils.UsdFileCfg( - # usd_path="/home/mmichelis/Documents/IsaacLab-Origin/scripts/demos/cable001.usda", - # physics_material=NewtonCableMaterialCfg( - # stretch_stiffness=1e6, - # bend_stiffness=1e-4, - # stretch_damping=1e-4, - # bend_damping=1e-4, - # density=100.0, - # ), - # ) - # cfg = CableObjectCfg( - # prim_path=f"/World/Origin/Cable1{idx:03d}", - # spawn=spawn_cfg, - # init_state=CableObjectCfg.InitialStateCfg(pos=(cx, cy, cz), rot=z_axis_quat(angle)), - # ) - # entities[f"Cable1{idx:03d}"] = CableObject(cfg=cfg) - - # spawn_cfg = sim_utils.UsdFileCfg( - # usd_path="/home/mmichelis/Documents/IsaacLab-Origin/scripts/demos/cable002.usda", - # physics_material=NewtonCableMaterialCfg( - # stretch_stiffness=1e6, - # bend_stiffness=1e-4, - # stretch_damping=1e-4, - # bend_damping=1e-4, - # density=100.0, - # ), - # ) - # cfg = CableObjectCfg( - # prim_path=f"/World/Origin/Cable2{idx:03d}", - # spawn=spawn_cfg, - # init_state=CableObjectCfg.InitialStateCfg(pos=(cx, cy, cz), rot=z_axis_quat(angle)), - # ) - # entities[f"Cable2{idx:03d}"] = CableObject(cfg=cfg) - return entities @@ -178,7 +143,7 @@ def main(): dt=0.01, device=args_cli.device, physics=physics_cfg, - visualizer_cfgs=[NewtonVisualizerCfg(eye=(2.5, 2.5, 1.0), lookat=(0.0, 0.0, 0.25))], + visualizer_cfgs=[NewtonVisualizerCfg(eye=(2.0, 2.0, 1.0), lookat=(0.0, 0.0, 0.25))], ) sim = sim_utils.SimulationContext(sim_cfg)