From 003103a0d045df95591549a850ab03528207bb46 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 23 Jul 2026 19:32:49 -0700 Subject: [PATCH 01/22] feat: support Newton viewer controls and MJWarp dragging --- .../overview/core-concepts/visualization.rst | 15 ++ scripts/demos/newton_viewer_controls.py | 87 ++++++++++ .../max-newton-viewer-controls.rst | 5 + .../isaaclab/sim/simulation_context.py | 77 +++++++-- .../isaaclab/visualizers/base_visualizer.py | 11 ++ .../test_simulation_context_visualizers.py | 69 +++++++- .../changelog.d/max-newton-viewer-support.rst | 4 + .../isaaclab_newton/physics/newton_manager.py | 27 +++ .../test_newton_manager_abstraction.py | 68 ++++++++ .../changelog.d/max-newton-viewer-support.rst | 5 + .../newton/newton_visualizer.py | 156 ++++++++++++++---- .../newton/newton_visualizer_cfg.py | 6 + .../test/test_newton_adapter.py | 102 ++++++++++++ .../test/visualizer_integration_utils.py | 22 ++- 14 files changed, 597 insertions(+), 57 deletions(-) create mode 100644 scripts/demos/newton_viewer_controls.py create mode 100644 source/isaaclab/changelog.d/max-newton-viewer-controls.rst create mode 100644 source/isaaclab_newton/changelog.d/max-newton-viewer-support.rst create mode 100644 source/isaaclab_visualizers/changelog.d/max-newton-viewer-support.rst diff --git a/docs/source/overview/core-concepts/visualization.rst b/docs/source/overview/core-concepts/visualization.rst index dbb605330127..bce0c9c6d437 100644 --- a/docs/source/overview/core-concepts/visualization.rst +++ b/docs/source/overview/core-concepts/visualization.rst @@ -113,6 +113,21 @@ To run in headless mode, omit the ``--viz`` argument: ./isaaclab.sh train --rl_library rsl_rl --task Isaac-Cartpole +Newton MJWarp Object Interaction +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +With the Newton MJWarp solver, right-click and drag a dynamic rigid body in the +Newton visualizer to apply an interactive force. Static and kinematic bodies +are not moved. Set +:attr:`~isaaclab_visualizers.newton.NewtonVisualizerCfg.enable_picking` to +``False`` to disable this interaction. Picking is also disabled in headless +viewers and with other physics solvers. + +Newton's native ``Pause`` and ``Step`` controls pause physics and advance it by +one step, respectively. The equivalent keyboard shortcuts are ``Space`` and +``.``. ``Pause Rendering`` only freezes viewer rendering. + + .. _visualization-configuration: Configuration diff --git a/scripts/demos/newton_viewer_controls.py b/scripts/demos/newton_viewer_controls.py new file mode 100644 index 000000000000..2a47017d79c5 --- /dev/null +++ b/scripts/demos/newton_viewer_controls.py @@ -0,0 +1,87 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Exercise Newton viewer dragging, pause, and single-step controls. + +The scene uses only the Newton MJWarp rigid-body solver and contains three +dynamic cubes. Right-click and drag any cube to apply a force, press ``Space`` +to pause or resume physics, and press ``.`` to advance one physics step. + +.. code-block:: bash + + uv run python scripts/demos/newton_viewer_controls.py +""" + +import argparse + +from isaaclab.app import add_launcher_args, launch_simulation + +parser = argparse.ArgumentParser(description="Newton viewer controls with three draggable MJWarp cubes.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") +add_launcher_args(parser) +parser.set_defaults(visualizer=["newton"]) +args_cli = parser.parse_args() + +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import AssetBaseCfg, RigidObjectCfg +from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.utils.configclass import configclass + + +def cube_cfg(name: str, position: tuple[float, float, float]) -> RigidObjectCfg: + """Create one draggable cube configuration.""" + return RigidObjectCfg( + prim_path=f"{{ENV_REGEX_NS}}/{name}", + spawn=sim_utils.CuboidCfg( + size=(0.5, 0.5, 0.5), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(mass=1.0), + collision_props=sim_utils.CollisionPropertiesCfg(), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=position), + ) + + +@configclass +class ViewerControlsSceneCfg(InteractiveSceneCfg): + """Ground plane and three dynamic MJWarp cubes.""" + + ground = AssetBaseCfg( + prim_path="/World/Ground", + spawn=sim_utils.GroundPlaneCfg(size=(6.0, 6.0), color=(0.25, 0.25, 0.25)), + ) + left_cube = cube_cfg("LeftCube", (-0.75, 0.0, 0.5)) + center_cube = cube_cfg("CenterCube", (0.0, 0.0, 0.5)) + right_cube = cube_cfg("RightCube", (0.75, 0.0, 0.5)) + + +def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> None: + """Run until the viewer closes or the optional step limit is reached.""" + sim_dt = sim.get_physics_dt() + step_count = 0 + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): + scene.write_data_to_sim() + sim.step() + scene.update(sim_dt) + step_count += 1 + + +def main() -> None: + """Launch the MJWarp viewer-controls demo.""" + physics_cfg = NewtonCfg(solver_cfg=MJWarpSolverCfg()) + with launch_simulation(cfg=physics_cfg, launcher_args=args_cli) as resolved_physics_cfg: + sim_cfg = sim_utils.SimulationCfg(dt=1.0 / 100.0, device=args_cli.device, physics=resolved_physics_cfg) + sim = sim_utils.SimulationContext(sim_cfg) + sim.set_camera_view(eye=(3.0, -4.0, 2.5), target=(0.0, 0.0, 0.5)) + scene = InteractiveScene(ViewerControlsSceneCfg(num_envs=1, env_spacing=1.0)) + sim.reset() + print("[INFO]: Right-click and drag any cube. Space pauses; '.' advances one step.", flush=True) + run_simulator(sim, scene) + + +if __name__ == "__main__": + main() diff --git a/source/isaaclab/changelog.d/max-newton-viewer-controls.rst b/source/isaaclab/changelog.d/max-newton-viewer-controls.rst new file mode 100644 index 000000000000..3487fb0b5d60 --- /dev/null +++ b/source/isaaclab/changelog.d/max-newton-viewer-controls.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed visualizer pause and single-step requests to gate physics before each + simulation step. diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 123fa316ccc6..96e52aaf6219 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -212,6 +212,7 @@ def __init__(self, cfg: SimulationCfg | None = None): # Shared renderers for all Camera sensors (compatible renderer_cfg only). self._render_context = RenderContext() + self._pre_capture_visualizers: set[BaseVisualizer] = set() # Run renderer post-physics setup. self.physics_manager.register_callback( @@ -219,6 +220,13 @@ def __init__(self, cfg: SimulationCfg | None = None): PhysicsEvent.PHYSICS_READY, order=5, ) + if "mjwarp" in self.physics_manager.__name__.lower() and "newton" in self.resolve_visualizer_types(): + self.physics_manager.register_callback( + self._prepare_newton_mjwarp_visualizer_for_capture, + PhysicsEvent.PHYSICS_READY, + order=30, + name="newton_mjwarp_visualizer_pre_capture", + ) self._services = ServiceLocator() @@ -515,11 +523,13 @@ def _resolve_visualizer_cfgs(self) -> list[Any]: return resolved - def initialize_visualizers(self) -> None: - """Initialize visualizers from SimulationCfg.visualizer_cfgs.""" - if self._visualizers: - return + def initialize_visualizers(self, only_types: set[str] | None = None) -> None: + """Initialize missing visualizers from ``SimulationCfg.visualizer_cfgs``. + Args: + only_types: Optional visualizer types to initialize. Other configured + visualizers remain pending until a later unfiltered call. + """ physics_dt = getattr(self.cfg.physics, "dt", None) self._viz_dt = (physics_dt if physics_dt is not None else self.cfg.dt) * self.cfg.render_interval @@ -535,7 +545,13 @@ def initialize_visualizers(self) -> None: ] requirements = resolve_scene_data_requirements(visualizer_types=visualizer_types) self._scene_data_requirements = requirements - self._visualizers = [] + initialized_types = {getattr(viz.cfg, "visualizer_type", None) for viz in self._visualizers} + visualizer_cfgs = [ + cfg + for cfg in visualizer_cfgs + if getattr(cfg, "visualizer_type", None) not in initialized_types + and (only_types is None or getattr(cfg, "visualizer_type", None) in only_types) + ] for cfg in visualizer_cfgs: try: @@ -561,9 +577,10 @@ def initialize_visualizers(self) -> None: eye, target = pending for viz in self._visualizers: viz.set_camera_view(eye, target) - self._pending_camera_view = None + if only_types is None: + self._pending_camera_view = None - if not self._visualizers and self._scene_data_provider is not None: + if only_types is None and not self._visualizers and self._scene_data_provider is not None: close_provider = getattr(self._scene_data_provider, "close", None) if callable(close_provider): close_provider() @@ -622,18 +639,31 @@ def forward(self) -> None: """Update kinematics without stepping physics.""" self.physics_manager.forward() + def _prepare_newton_mjwarp_visualizer_for_capture(self, _payload=None) -> None: + """Initialize or rebind the Newton viewer before MJWarp graph capture.""" + existing = {viz for viz in self._visualizers if getattr(viz.cfg, "visualizer_type", None) == "newton"} + if not existing: + self.initialize_visualizers(only_types={"newton"}) + + for viz in (viz for viz in self._visualizers if getattr(viz.cfg, "visualizer_type", None) == "newton"): + if viz in existing: + viz.reset(soft=False) + self._pre_capture_visualizers.add(viz) + def reset(self, soft: bool = False) -> None: """Reset the simulation. Args: soft: If True, skip full reinitialization. """ + self._pre_capture_visualizers.clear() self.physics_manager.reset(soft) for viz in self._visualizers: - viz.reset(soft) - if not self._visualizers: - # Initialize visualizers after PhysX sim views are ready, but before play() pumps timeline events. - self.initialize_visualizers() + if viz not in self._pre_capture_visualizers: + viz.reset(soft) + # Initialize any visualizers not prepared by a backend-specific pre-capture hook. + self.initialize_visualizers() + self._pre_capture_visualizers.clear() # Start the timeline so the play button is pressed self.physics_manager.play() self._is_playing = True @@ -642,8 +672,9 @@ def reset(self, soft: bool = False) -> None: def step(self, render: bool = True) -> None: """Step physics and optionally render. - If the timeline is paused (e.g. via the GUI), this method blocks and keeps - the visualizer responsive until the timeline is resumed or stopped. + If the timeline or a visualizer is paused, this method blocks and keeps + its event loop responsive until simulation is resumed, single-stepped, + or stopped. Args: render: Whether to render the scene after stepping. Defaults to True. @@ -651,11 +682,29 @@ def step(self, render: bool = True) -> None: # Block while the GUI timeline is paused so the entire training loop freezes. # See: https://github.com/isaac-sim/IsaacLab/issues/4279 self.physics_manager.wait_for_playing() + self._wait_for_visualizer_step() self._physics_step_count += 1 self.physics_manager.step() if render and self.is_rendering: self.render() + def _wait_for_visualizer_step(self) -> None: + """Pump standalone visualizers until each permits one physics step.""" + for viz in tuple(self._visualizers): + if viz.pumps_app_update(): + continue + try: + while viz.is_running() and not viz.is_closed and not viz.should_step(): + viz.step(0.0) + except Exception as exc: + logger.error("Error polling paused visualizer '%s': %s", type(viz).__name__, exc) + try: + viz.close() + except Exception as close_exc: + logger.error("Error closing visualizer: %s", close_exc) + if viz in self._visualizers: + self._visualizers.remove(viz) + def render(self, mode: int | None = None, skip_app_pumping: bool = False) -> None: """Update visualizers and render the scene. @@ -735,8 +784,6 @@ def update_visualizers(self, dt: float, skip_app_pumping: bool = False) -> None: if not viz.pumps_app_update(): viz.step(0.0) continue - while viz.is_training_paused() and viz.is_running(): - viz.step(0.0) viz.step(dt) except Exception as exc: logger.error("Error stepping visualizer '%s': %s", type(viz).__name__, exc) diff --git a/source/isaaclab/isaaclab/visualizers/base_visualizer.py b/source/isaaclab/isaaclab/visualizers/base_visualizer.py index 30cbd34c27e1..76b38cfcce87 100644 --- a/source/isaaclab/isaaclab/visualizers/base_visualizer.py +++ b/source/isaaclab/isaaclab/visualizers/base_visualizer.py @@ -83,6 +83,17 @@ def is_running(self) -> bool: """ raise NotImplementedError + def should_step(self) -> bool: + """Return whether the simulation may advance one step. + + Stateful visualizers may consume a pending single-step request here. + The default behavior supports pause-only visualizers. + + Returns: + ``True`` when physics should advance, otherwise ``False``. + """ + return not self.is_training_paused() + def is_training_paused(self) -> bool: """Check if training is paused by visualizer controls. diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 28d7249950b2..eb168f52378a 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -33,10 +33,18 @@ def test_web_visualizer_cfgs_do_not_open_browser_by_default(): class _FakePhysicsManager: def __init__(self): self.forward_calls = 0 + self.wait_for_playing_calls = 0 + self.step_calls = 0 def forward(self): self.forward_calls += 1 + def wait_for_playing(self): + self.wait_for_playing_calls += 1 + + def step(self): + self.step_calls += 1 + class _FakeProvider: """Fake new-style SceneDataProvider for tests; only provides what visualizers read.""" @@ -80,6 +88,7 @@ def __init__( self._requires_forward = requires_forward self._pumps_app_update = pumps_app_update self.step_calls = [] + self.should_step_calls = 0 self.close_calls = 0 @property @@ -92,6 +101,10 @@ def is_running(self): def is_rendering_paused(self): return self._rendering_paused + def should_step(self): + self.should_step_calls += 1 + return not self.is_training_paused() + def is_training_paused(self): if self._training_paused_steps > 0: self._training_paused_steps -= 1 @@ -128,6 +141,7 @@ def _make_context(visualizers, provider=None): ctx._visualizers = list(visualizers) ctx._scene_data_provider = provider ctx.physics_manager = _FakePhysicsManager() + ctx._physics_step_count = 0 return ctx @@ -186,14 +200,58 @@ def test_update_visualizers_skips_zero_dt_for_paused_app_pumping_visualizer(): assert paused_app_pumping_viz.step_calls == [] -def test_update_visualizers_handles_training_pause_loop(): - provider = _FakeProvider() +def test_step_pumps_paused_visualizer_before_physics(): viz = _FakeVisualizer(training_paused_steps=1) - ctx = _make_context([viz], provider=provider) + ctx = _make_context([viz]) + + ctx.step(render=False) + + assert viz.step_calls == [0.0] + assert viz.should_step_calls == 2 + assert ctx.physics_manager.wait_for_playing_calls == 1 + assert ctx.physics_manager.step_calls == 1 + assert ctx._physics_step_count == 1 + + +def test_step_leaves_app_backed_visualizer_to_physics_timeline_gate(): + app_backed = _FakeVisualizer(pumps_app_update=True, training_paused_steps=100) + ctx = _make_context([app_backed]) + + ctx.step(render=False) + + assert app_backed.should_step_calls == 0 + assert app_backed.step_calls == [] + assert ctx.physics_manager.step_calls == 1 + assert ctx._physics_step_count == 1 + + +def test_newton_mjwarp_visualizer_is_initialized_and_rebound_before_capture(): + ctx = object.__new__(SimulationContext) + ctx._visualizers = [] + ctx._pre_capture_visualizers = set() + viz = _FakeVisualizer() + viz.cfg = type("Cfg", (), {"visualizer_type": "newton"})() + reset_calls = [] + initialize_calls = [] + viz.reset = lambda soft: reset_calls.append(soft) + + def initialize_visualizers(only_types=None): + initialize_calls.append(only_types) + ctx._visualizers.append(viz) + + ctx.initialize_visualizers = initialize_visualizers + + ctx._prepare_newton_mjwarp_visualizer_for_capture() + + assert initialize_calls == [{"newton"}] + assert ctx._visualizers == [viz] + assert ctx._pre_capture_visualizers == {viz} - ctx.update_visualizers(0.2) + ctx._pre_capture_visualizers.clear() + ctx._prepare_newton_mjwarp_visualizer_for_capture() - assert viz.step_calls == [0.0, 0.2] + assert reset_calls == [False] + assert ctx._pre_capture_visualizers == {viz} def test_reset_initializes_visualizers_before_playing_timeline(): @@ -201,6 +259,7 @@ def test_reset_initializes_visualizers_before_playing_timeline(): events: list[str] = [] ctx = object.__new__(SimulationContext) ctx._visualizers = [] + ctx._pre_capture_visualizers = set() class _PhysicsManager: @staticmethod diff --git a/source/isaaclab_newton/changelog.d/max-newton-viewer-support.rst b/source/isaaclab_newton/changelog.d/max-newton-viewer-support.rst new file mode 100644 index 000000000000..9e67174f33e1 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/max-newton-viewer-support.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added per-substep state-force callbacks for Newton MJWarp viewer interaction. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index d82f53e697d7..ce9d1af9a787 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -311,6 +311,8 @@ def provides_implicit_damping(cls) -> bool: # substeps, in registration order. Multiple articulations register their # implicit-DOF telemetry / FF-routing kernels here. _post_actuator_callbacks: list[Callable[[], None]] = [] + # In-graph hooks invoked immediately before every solver substep. + _state_force_callbacks: list[Callable[[State], None]] = [] # In-graph hooks invoked after the last solver substep and before sensors, # in registration order. Articulations with non-identity ordering register # their backend-to-user state republish kernels here so the reorders are @@ -883,6 +885,7 @@ def clear(cls): NewtonManager._supports_contact_sensors = True NewtonManager._adapter = None NewtonManager._post_actuator_callbacks = [] + NewtonManager._state_force_callbacks = [] NewtonManager._post_step_callbacks = [] # Set by an articulation that took the ``use_newton_actuators=True`` # branch in ``_process_actuators_cfg``. Together with the adapter @@ -1891,6 +1894,8 @@ def _run_solver_substeps(cls, contacts) -> None: if cls._use_single_state: for i in range(cls._num_substeps): + for callback in cls._state_force_callbacks: + callback(cls._state_0) cls._step_solver(cls._state_0, cls._state_0, cls._control, contacts, cls._solver_dt) cls._state_0.clear_forces() if collide_mid_loop and (i + 1) % collide_every == 0 and i + 1 < cls._num_substeps: @@ -1899,6 +1904,8 @@ def _run_solver_substeps(cls, contacts) -> None: cfg = PhysicsManager._cfg need_copy_on_last = cfg is not None and cls._num_substeps % 2 == 1 for i in range(cls._num_substeps): + for callback in cls._state_force_callbacks: + callback(cls._state_0) cls._step_solver(cls._state_0, cls._state_1, cls._control, contacts, cls._solver_dt) if need_copy_on_last and i == cls._num_substeps - 1: cls._state_0.assign(cls._state_1) @@ -2377,6 +2384,26 @@ def register_post_actuator_callback(cls, callback: Callable[[], None]) -> None: """ cls._post_actuator_callbacks.append(callback) + @classmethod + def register_state_force_callback(cls, callback: Callable[[State], None]) -> None: + """Register a graph-safe callback that applies forces before every solver substep. + + Callbacks registered before solver initialization are included in the + existing CUDA graph capture. Late registration falls back to eager + execution for safety. + + Args: + callback: Function that adds forces [N, N·m] to the provided state. + """ + if callback in NewtonManager._state_force_callbacks: + return + NewtonManager._state_force_callbacks.append(callback) + if NewtonManager._graph is None: + return + NewtonManager._graph = None + NewtonManager._graph_capture_pending = False + logger.info("%s switched to eager execution after a late state-force callback", cls.__name__) + @classmethod def register_post_step_callback(cls, callback: Callable[[], None]) -> None: """Append a hook to the list invoked after the last solver substep on every step. diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 1ee4c52ba80e..11ce714bbcda 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -741,6 +741,74 @@ def counting_collide(state, contacts): assert calls["n"] == 1 + expected_mid_loop_collides +@pytest.mark.parametrize( + "use_single_state, expected_events", + [ + (True, [("force", "state_0"), ("step", "state_0", "state_0")] * 2), + ( + False, + [ + ("force", "state_0"), + ("step", "state_0", "state_1"), + ("force", "state_1"), + ("step", "state_1", "state_0"), + ], + ), + ], +) +def test_state_force_callback_runs_before_every_solver_substep(monkeypatch, use_single_state, expected_events): + """Viewer forces are applied to the current state before every solver substep.""" + events = [] + + class _State: + def __init__(self, name): + self.name = name + + def clear_forces(self): + pass + + state_0 = _State("state_0") + state_1 = _State("state_1") + + monkeypatch.setattr(NewtonManager, "_state_0", state_0) + monkeypatch.setattr(NewtonManager, "_state_1", state_1) + monkeypatch.setattr(NewtonManager, "_control", object()) + monkeypatch.setattr(NewtonManager, "_solver_dt", 0.001) + monkeypatch.setattr(NewtonManager, "_num_substeps", 2) + monkeypatch.setattr(NewtonManager, "_collision_decimation", 0) + monkeypatch.setattr(NewtonManager, "_needs_collision_pipeline", False) + monkeypatch.setattr(NewtonManager, "_use_single_state", use_single_state) + monkeypatch.setattr(NewtonManager, "_state_force_callbacks", [lambda state: events.append(("force", state.name))]) + monkeypatch.setattr( + NewtonManager, + "_step_solver", + staticmethod( + lambda input_state, output_state, _control, _contacts, _dt: events.append( + ("step", input_state.name, output_state.name) + ) + ), + ) + + NewtonManager._run_solver_substeps(contacts=None) + + assert events == expected_events + + +def test_late_state_force_callback_drops_captured_graph(monkeypatch): + def callback(_state): + pass + + monkeypatch.setattr(NewtonManager, "_graph", object()) + monkeypatch.setattr(NewtonManager, "_graph_capture_pending", False) + monkeypatch.setattr(NewtonManager, "_state_force_callbacks", []) + + NewtonManager.register_state_force_callback(callback) + + assert NewtonManager._state_force_callbacks == [callback] + assert NewtonManager._graph is None + assert NewtonManager._graph_capture_pending is False + + # --------------------------------------------------------------------------- # Regression: an env reset written through the data layer must land in the # manager's canonical _state_0 after an odd number of steps when CUDA graphs diff --git a/source/isaaclab_visualizers/changelog.d/max-newton-viewer-support.rst b/source/isaaclab_visualizers/changelog.d/max-newton-viewer-support.rst new file mode 100644 index 000000000000..617a73bc998a --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/max-newton-viewer-support.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added Newton MJWarp rigid-body dragging and wired Newton's native pause and + single-step controls to simulation stepping. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 9a54cf9cb3d2..253ec869f7de 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -55,11 +55,13 @@ """Length of synthesized contact arrows in meters.""" if TYPE_CHECKING: + from newton import State + from isaaclab.scene_data import SceneDataProvider class NewtonViewerGL(ViewerGL): - """Wrapper around Newton's ViewerGL with training/rendering pause controls.""" + """Wrapper around Newton's ViewerGL with Isaac Lab rendering controls.""" def __init__( self, @@ -77,7 +79,6 @@ def __init__( **kwargs: Keyword arguments forwarded to ``ViewerGL``. """ super().__init__(*args, **kwargs) - self._paused_training = False self._paused_rendering = False self._metadata = metadata or {} self._fallback_draw_controls = False @@ -95,15 +96,28 @@ def __init__( backend = FactoryBase._get_backend() self._backend_display = _BACKEND_DISPLAY_NAMES.get(backend, backend) + self._register_isaaclab_ui_callbacks() + + def set_model(self, model) -> None: + """Set a model and restore UI callbacks cleared by Newton on model swaps.""" + replaces_model = self.model is not None + super().set_model(model) + if replaces_model: + self._register_isaaclab_ui_callbacks(include_persistent=False) + + def _register_isaaclab_ui_callbacks(self, *, include_persistent: bool = True) -> None: + """Register Isaac Lab's small additions to Newton's native UI.""" try: self.register_ui_callback(self._render_training_controls, position="side") - self.register_ui_callback(self._render_physics_panel, position="panel") + if include_persistent: + self.register_ui_callback(self._render_physics_panel, position="panel") + self._fallback_draw_controls = False except AttributeError: self._fallback_draw_controls = True def is_training_paused(self) -> bool: """Return whether simulation is paused by viewer controls.""" - return self._paused_training + return self.is_paused() def is_rendering_paused(self) -> bool: """Return whether rendering is paused by viewer controls.""" @@ -114,14 +128,9 @@ def _render_training_controls(self, imgui): imgui.separator() imgui.text("IsaacLab Controls") - pause_label = "Resume Simulation" if self._paused_training else "Pause Simulation" - if imgui.button(pause_label): - self._paused_training = not self._paused_training - rendering_label = "Resume Rendering" if self._paused_rendering else "Pause Rendering" if imgui.button(rendering_label): self._paused_rendering = not self._paused_rendering - self._paused = self._paused_rendering imgui.text("Visualizer Update Frequency") current_frequency = self._update_frequency @@ -144,12 +153,6 @@ def _render_physics_panel(self, imgui): imgui.separator() imgui.text(f"Physics: {self._backend_display}") - def on_key_press(self, symbol, modifiers): - """Forward key presses unless UI is currently capturing input.""" - if self.ui.is_capturing(): - return - super().on_key_press(symbol, modifiers) - def _render_ui(self): """Render default UI and fallback control window when callback hooks are unavailable.""" if not self._fallback_draw_controls: @@ -348,6 +351,52 @@ def _prime_image_logger_window_layout(self) -> None: class NewtonVisualizer(BaseVisualizer): """Newton OpenGL visualizer for Isaac Lab.""" + class _ViewerForceBinding: + """Stable Newton-manager callback for viewer-owned force inputs. + + CUDA graphs record the viewer's picking and wind arrays by address. + Closing the window therefore neutralizes and retains those small inputs + instead of removing the callback and invalidating the physics graph. + """ + + def __init__(self) -> None: + self._viewer: NewtonViewerGL | None = None + self._retained_force_helpers: tuple[object, ...] = () + + def bind(self, viewer: NewtonViewerGL) -> None: + """Bind force application to the current viewer model.""" + self._viewer = viewer + self._retained_force_helpers = () + + def apply(self, state: State) -> None: + """Apply viewer forces while the viewer is active.""" + if self._viewer is None: + # Host callbacks do not run while the captured graph replays. + # Reaching this branch means that graph is gone, so its force + # helpers can be released. + self._retained_force_helpers = () + return + self._viewer.apply_forces(state) + + def deactivate(self) -> None: + """Make captured force launches inert while preserving their inputs.""" + viewer = self._viewer + if viewer is None: + return + + picking = getattr(viewer, "picking", None) + wind = getattr(viewer, "wind", None) + if picking is not None: + viewer.picking_enabled = False + picking.release() + if wind is not None: + wind.amplitude = 0.0 + wind.update(0.0) + + # The captured graph retains addresses owned by these helpers. + self._retained_force_helpers = tuple(helper for helper in (picking, wind) if helper is not None) + self._viewer = None + def __init__(self, cfg: NewtonVisualizerCfg): """Initialize Newton visualizer state. @@ -370,6 +419,9 @@ def __init__(self, cfg: NewtonVisualizerCfg): self._camera_env_indices: list[int] = [] self._camera_is_owned = False self._generated_camera_prim_paths: list[str] = [] + self._viewer_force_binding = self._ViewerForceBinding() + self._state_force_callback_registered = False + self._picking_enabled = False def initialize(self, scene_data_provider: SceneDataProvider) -> None: """Initialize viewer resources and bind scene data provider. @@ -379,17 +431,24 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: """ from isaaclab_newton.physics import NewtonManager + from isaaclab.sim import SimulationContext + if self._is_initialized: logger.debug("[NewtonVisualizer] initialize() called while already initialized.") return scene_data_provider = self._set_scene_data_provider(scene_data_provider) + newton_backend_active = self.physics_backend == "newton" + physics_manager = SimulationContext.instance().physics_manager + mjwarp_backend_active = newton_backend_active and "mjwarp" in physics_manager.__name__.lower() num_envs = scene_data_provider.num_envs metadata = {"num_envs": num_envs} self._env_ids = self._compute_visualized_env_ids() self._resolved_visible_env_ids = resolve_visible_env_indices(self._env_ids, self.cfg.max_visible_envs, num_envs) self._model = NewtonManager.get_model() - self._state = NewtonManager.get_state(self._scene_data_provider) + self._state = ( + NewtonManager.get_state_0() if newton_backend_active else NewtonManager.get_state(self._scene_data_provider) + ) runtime_headless = self.cfg.headless or ( sys.platform not in ("win32", "darwin") and not os.environ.get("DISPLAY") @@ -439,6 +498,8 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: self._viewer.show_com = self.cfg.show_com self._viewer.show_particles = self.cfg.show_particles self._viewer.particle_color = self.cfg.particle_color + self._picking_enabled = self.cfg.enable_picking and mjwarp_backend_active and not runtime_headless + self._viewer.picking_enabled = self._picking_enabled self._viewer.renderer.draw_shadows = self.cfg.enable_shadows self._viewer.renderer.draw_sky = self.cfg.enable_sky @@ -469,8 +530,17 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: ("headless", self.cfg.headless), ("show_particles", self.cfg.show_particles), ("particle_color", self.cfg.particle_color), + ("enable_picking", self._viewer.picking_enabled if self._viewer is not None else False), ], ) + if self._viewer is not None and self._picking_enabled: + self._viewer_force_binding.bind(self._viewer) + NewtonManager.register_state_force_callback(self._viewer_force_binding.apply) + self._state_force_callback_registered = True + if self._viewer is not None and self.cfg.enable_picking and not mjwarp_backend_active: + logger.info( + "[NewtonVisualizer] Object dragging is disabled because the active physics solver is not Newton MJWarp." + ) self._is_initialized = True def step(self, dt: float) -> None: @@ -482,26 +552,28 @@ def step(self, dt: float) -> None: if not self._is_initialized or self._is_closed: return - self._sim_time += dt - self._step_counter += 1 + if dt > 0.0: + self._sim_time += dt + self._step_counter += 1 from isaaclab_newton.physics import NewtonManager if self._viewer is None: - self._state = NewtonManager.get_state(self._scene_data_provider) + if dt > 0.0: + self._state = NewtonManager.get_state(self._scene_data_provider) return update_frequency = self._viewer._update_frequency if self._viewer else self._update_frequency - if self._step_counter % update_frequency != 0: + if dt > 0.0 and self._step_counter % update_frequency != 0 and not self._viewer.is_paused(): return num_envs = NewtonManager.get_num_envs() try: - if not self._viewer.is_paused(): - self._state = NewtonManager.get_state(self._scene_data_provider) - self._viewer.begin_frame(self._sim_time) - try: + self._viewer.begin_frame(self._sim_time) + try: + if not self._viewer.is_rendering_paused(): + self._state = NewtonManager.get_state(self._scene_data_provider) if self._state is not None: body_q = getattr(self._state, "body_q", None) if hasattr(body_q, "shape") and body_q.shape[0] == 0: @@ -517,18 +589,40 @@ def step(self, dt: float) -> None: self._viewer, self._resolved_visible_env_ids, num_envs=num_envs ) self._log_camera_sensor_image() - finally: - self._viewer.end_frame() - else: - self._viewer._update() + finally: + self._viewer.end_frame() + if not self._viewer.is_running(): + self._viewer_force_binding.deactivate() except Exception: logger.exception("[NewtonVisualizer] Viewer update failed.") + def reset(self, soft: bool = False) -> None: + """Rebind viewer resources after a hard Newton model reset.""" + if soft or not self._is_initialized or self._is_closed or self.physics_backend != "newton": + return + + from isaaclab_newton.physics import NewtonManager + + self._model = NewtonManager.get_model() + self._state = NewtonManager.get_state_0() + if self._viewer is not None: + self._viewer.set_model(self._model) + self._viewer.set_visible_worlds(self._resolved_visible_env_ids) + self._viewer.set_world_offsets(self.cfg.world_spacing) + self._viewer.picking_enabled = self._picking_enabled + if self._state_force_callback_registered: + self._viewer_force_binding.bind(self._viewer) + def close(self) -> None: """Release viewer resources.""" if self._is_closed: return + if self._state_force_callback_registered: + # Keep the stable callback registered: captured graphs replay its + # now-neutral device inputs without retaining the GL viewer. + self._viewer_force_binding.deactivate() if self._viewer is not None: + self._viewer.close() self._viewer = None if self._camera_sensor is not None and self._camera_is_owned: remove_generated_prims(self._generated_camera_prim_paths) @@ -795,6 +889,12 @@ def is_training_paused(self) -> bool: return False return self._viewer.is_training_paused() + def should_step(self) -> bool: + """Return whether Newton's native pause/step controls permit one physics step.""" + if not self._is_initialized or self._viewer is None: + return True + return self._viewer.should_step() + def is_rendering_paused(self) -> bool: """Return whether rendering is paused from viewer controls.""" if not self._is_initialized or self._viewer is None: diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py index 06d01f68aa07..912d411e490e 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py @@ -61,6 +61,12 @@ class NewtonVisualizerCfg(VisualizerCfg): Values are passed through to the Newton viewer unchanged. """ + enable_picking: bool = True + """Enable right-click dragging with the Newton MJWarp solver. + + Disabled automatically for headless viewers and other physics solvers. + """ + enable_shadows: bool = True """Enable shadow rendering.""" diff --git a/source/isaaclab_visualizers/test/test_newton_adapter.py b/source/isaaclab_visualizers/test/test_newton_adapter.py index 50bbf8c0b4f8..c78aad41d167 100644 --- a/source/isaaclab_visualizers/test/test_newton_adapter.py +++ b/source/isaaclab_visualizers/test/test_newton_adapter.py @@ -8,6 +8,7 @@ from __future__ import annotations from types import SimpleNamespace +from unittest.mock import Mock import numpy as np import pytest @@ -362,6 +363,12 @@ def __init__(self): def is_paused(self): return False + def is_rendering_paused(self): + return False + + def is_running(self): + return True + def begin_frame(self, _time): pass @@ -377,6 +384,9 @@ def log_arrows(self, name, starts, ends, colors): def end_frame(self): pass + def close(self): + pass + class _Proxy: def __init__(self, tensor): @@ -416,10 +426,102 @@ def _make_newton_visualizer(viewer, scene_data_provider=None): visualizer._state = None visualizer._scene_data_provider = scene_data_provider visualizer._resolved_visible_env_ids = None + visualizer._picking_enabled = False + visualizer._viewer_force_binding = NewtonVisualizer._ViewerForceBinding() + if viewer is not None: + visualizer._viewer_force_binding.bind(viewer) + visualizer._state_force_callback_registered = False + visualizer._camera_sensor = None + visualizer._camera_is_owned = False + visualizer._generated_camera_prim_paths = [] visualizer._log_camera_sensor_image = lambda: None return visualizer +def test_newton_viewer_model_swap_restores_only_cleared_ui_callbacks(monkeypatch): + from newton.viewer import ViewerGL + + viewer = NewtonViewerGL.__new__(NewtonViewerGL) + viewer.model = object() + registered_positions = [] + monkeypatch.setattr(ViewerGL, "set_model", lambda self, model: setattr(self, "model", model)) + monkeypatch.setattr( + viewer, + "register_ui_callback", + lambda _callback, *, position: registered_positions.append(position), + ) + + viewer.set_model(object()) + + assert registered_positions == ["side"] + + +def test_newton_visualizer_close_neutralizes_forces_without_invalidating_graph(monkeypatch): + from isaaclab_newton.physics import NewtonManager + + viewer = _Viewer() + viewer.picking_enabled = True + viewer.picking = SimpleNamespace(release=Mock()) + viewer.wind = SimpleNamespace(amplitude=3.0, update=Mock()) + visualizer = _make_newton_visualizer(viewer) + visualizer._state_force_callback_registered = True + graph = object() + callback = visualizer._viewer_force_binding.apply + monkeypatch.setattr(NewtonManager, "_graph", graph) + monkeypatch.setattr(NewtonManager, "_state_force_callbacks", [callback]) + + visualizer.close() + + assert NewtonManager._graph is graph + assert NewtonManager._state_force_callbacks == [callback] + assert viewer.picking_enabled is False + viewer.picking.release.assert_called_once_with() + assert viewer.wind.amplitude == 0.0 + viewer.wind.update.assert_called_once_with(0.0) + assert visualizer._viewer is None + assert visualizer._viewer_force_binding._viewer is None + assert visualizer._viewer_force_binding._retained_force_helpers == (viewer.picking, viewer.wind) + + # Once the graph is gone, the next eager callback releases its retained helpers. + NewtonManager._graph = None + callback(object()) + assert visualizer._viewer_force_binding._retained_force_helpers == () + + +def test_newton_visualizer_hard_reset_rebinds_viewer_model(monkeypatch): + from isaaclab_newton.physics import NewtonManager + + monkeypatch.setattr(NewtonVisualizer, "physics_backend", property(lambda _self: "newton")) + + new_model = object() + new_state = object() + monkeypatch.setattr(NewtonManager, "get_model", lambda: new_model) + monkeypatch.setattr(NewtonManager, "get_state_0", lambda: new_state) + + viewer = _Viewer() + viewer.picking_enabled = False + viewer.set_model = Mock() + viewer.set_visible_worlds = Mock() + viewer.set_world_offsets = Mock() + visualizer = _make_newton_visualizer(viewer) + visualizer._resolved_visible_env_ids = [1, 3] + visualizer._picking_enabled = True + visualizer._state_force_callback_registered = True + visualizer._viewer_force_binding._retained_force_helpers = (object(),) + visualizer.cfg.world_spacing = (2.0, 0.0, 0.0) + + visualizer.reset(soft=False) + + assert visualizer._model is new_model + assert visualizer._state is new_state + viewer.set_model.assert_called_once_with(new_model) + viewer.set_visible_worlds.assert_called_once_with([1, 3]) + viewer.set_world_offsets.assert_called_once_with((2.0, 0.0, 0.0)) + assert viewer.picking_enabled is True + assert visualizer._viewer_force_binding._viewer is viewer + assert visualizer._viewer_force_binding._retained_force_helpers == () + + def test_newton_visualizer_logs_native_contacts_when_available(monkeypatch): from isaaclab_newton.physics import NewtonManager diff --git a/source/isaaclab_visualizers/test/visualizer_integration_utils.py b/source/isaaclab_visualizers/test/visualizer_integration_utils.py index 2e67d6093aa7..080e48ed5053 100644 --- a/source/isaaclab_visualizers/test/visualizer_integration_utils.py +++ b/source/isaaclab_visualizers/test/visualizer_integration_utils.py @@ -652,16 +652,11 @@ def set_tooltip(self, _text): viewer._render_training_controls(_FakeImgui()) -def _select_newton_pause_simulation_button(viewer) -> None: - """Trigger the Newton visualizer's Pause/Resume Simulation UI button.""" - label = "Resume Simulation" if viewer.is_training_paused() else "Pause Simulation" - _select_newton_training_control_button(viewer, label) - - def _set_newton_simulation_paused(viewer, paused: bool) -> None: - """Put Newton visualizer simulation pause control into a desired state.""" - if viewer.is_training_paused() != paused: - _select_newton_pause_simulation_button(viewer) + """Put Newton's native simulation pause control into a desired state.""" + viewer._paused = paused + if not paused: + viewer._step_requested = False def _select_newton_pause_rendering_button(viewer) -> None: @@ -835,6 +830,15 @@ def _attempt_simulation_pause(): phase="pausing_simulation", ) + # Newton's native Step request authorizes exactly one SimulationContext + # physics tick while leaving the persistent pause state enabled. + physics_step_before_single_step = get_physics_step_count() + viewer._step_requested = True + env.sim.step() + assert get_physics_step_count() == physics_step_before_single_step + 1 + assert viewer.is_training_paused() + assert not viewer.should_step(), "Newton single-step request was not consumed exactly once." + simulation_play_start_idx = simulation_pause_end_idx simulation_play_end_idx = simulation_play_start_idx + PLAY_VIZ_N_STEP From 0fe8ed3dc252a5334c07079514c79e4e3f809068 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 24 Jul 2026 14:51:43 -0700 Subject: [PATCH 02/22] Update Newton marker viewer test double --- source/isaaclab/test/markers/test_visualization_markers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/source/isaaclab/test/markers/test_visualization_markers.py b/source/isaaclab/test/markers/test_visualization_markers.py index 7fdcde236adb..cae008a5b969 100644 --- a/source/isaaclab/test/markers/test_visualization_markers.py +++ b/source/isaaclab/test/markers/test_visualization_markers.py @@ -281,6 +281,12 @@ def __init__(self): def is_paused(self): return False + def is_rendering_paused(self): + return False + + def is_running(self): + return True + def begin_frame(self, sim_time): self.calls.append(("begin_frame", sim_time)) From 8796cf5d3b666e87881fdee83a9e8bbd235b7728 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 30 Jul 2026 15:15:08 -0700 Subject: [PATCH 03/22] Narrow Newton viewer support to dragging --- .../overview/core-concepts/visualization.rst | 25 ++- ..._controls.py => newton_viewer_dragging.py} | 17 +- .../max-newton-viewer-controls.rst | 5 - .../max-newton-viewer-dragging.minor.rst | 4 + .../isaaclab/sim/simulation_context.py | 135 +++++++------- .../isaaclab/visualizers/base_visualizer.py | 11 -- .../test/app/standalone_script_cases.py | 5 + .../markers/test_visualization_markers.py | 3 - .../test_simulation_context_visualizers.py | 80 +++------ .../max-newton-viewer-dragging.minor.rst | 5 + .../changelog.d/max-newton-viewer-support.rst | 4 - .../isaaclab_newton/physics/newton_manager.py | 12 +- .../test_newton_manager_abstraction.py | 55 +----- .../max-newton-viewer-dragging.minor.rst | 5 + .../changelog.d/max-newton-viewer-support.rst | 5 - .../newton/newton_visualizer.py | 170 +++++++++--------- .../test/test_newton_adapter.py | 93 +++------- .../test/visualizer_integration_utils.py | 34 +--- 18 files changed, 258 insertions(+), 410 deletions(-) rename scripts/demos/{newton_viewer_controls.py => newton_viewer_dragging.py} (80%) delete mode 100644 source/isaaclab/changelog.d/max-newton-viewer-controls.rst create mode 100644 source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst create mode 100644 source/isaaclab_newton/changelog.d/max-newton-viewer-dragging.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/max-newton-viewer-support.rst create mode 100644 source/isaaclab_visualizers/changelog.d/max-newton-viewer-dragging.minor.rst delete mode 100644 source/isaaclab_visualizers/changelog.d/max-newton-viewer-support.rst diff --git a/docs/source/overview/core-concepts/visualization.rst b/docs/source/overview/core-concepts/visualization.rst index c55e04f0347b..585a5f202efc 100644 --- a/docs/source/overview/core-concepts/visualization.rst +++ b/docs/source/overview/core-concepts/visualization.rst @@ -113,21 +113,6 @@ To run in headless mode, omit the ``--viz`` argument: ./isaaclab.sh train --rl_library rsl_rl --task Isaac-Cartpole -Newton MJWarp Object Interaction -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -With the Newton MJWarp solver, right-click and drag a dynamic rigid body in the -Newton visualizer to apply an interactive force. Static and kinematic bodies -are not moved. Set -:attr:`~isaaclab_visualizers.newton.NewtonVisualizerCfg.enable_picking` to -``False`` to disable this interaction. Picking is also disabled in headless -viewers and with other physics solvers. - -Newton's native ``Pause`` and ``Step`` controls pause physics and advance it by -one step, respectively. The equivalent keyboard shortcuts are ``Space`` and -``.``. ``Pause Rendering`` only freezes viewer rendering. - - .. _visualization-configuration: Configuration @@ -469,6 +454,7 @@ Newton Visualizer - Lightweight OpenGL rendering with low overhead - Simulation and rendering pause controls +- Right-click rigid-body dragging with the Newton MJWarp solver - Adjustable update frequency for performance tuning - Some customizable rendering options (shadows, sky, wireframe) - Visualization markers (joints, contacts, springs, COM, debug markers) @@ -489,6 +475,8 @@ Newton Visualizer - Down / Up * - **Left Click + Drag** - Look around + * - **Right Click + Drag** + - Apply an interactive force to a dynamic rigid body (Newton MJWarp only) * - **Mouse Scroll** - Zoom in/out * - **H** @@ -530,6 +518,7 @@ Newton Visualizer show_contacts=False, # Show contact points and normals show_springs=False, # Show spring constraints show_com=False, # Show center of mass markers + enable_picking=True, # Enable MJWarp rigid-body dragging # Rendering options enable_shadows=True, # Enable shadow rendering @@ -542,6 +531,12 @@ Newton Visualizer light_color=(1.0, 1.0, 1.0), # Directional light color (RGB [0,1]) ) +.. note:: + + Object dragging requires an interactive Newton visualizer with the Newton + MJWarp solver. Static and kinematic bodies are not moved, and picking is + disabled automatically in headless viewers and with other physics solvers. + Rerun Visualizer ~~~~~~~~~~~~~~~~ diff --git a/scripts/demos/newton_viewer_controls.py b/scripts/demos/newton_viewer_dragging.py similarity index 80% rename from scripts/demos/newton_viewer_controls.py rename to scripts/demos/newton_viewer_dragging.py index 2a47017d79c5..6eefe445896c 100644 --- a/scripts/demos/newton_viewer_controls.py +++ b/scripts/demos/newton_viewer_dragging.py @@ -3,22 +3,21 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Exercise Newton viewer dragging, pause, and single-step controls. +"""Exercise Newton MJWarp rigid-body dragging. The scene uses only the Newton MJWarp rigid-body solver and contains three -dynamic cubes. Right-click and drag any cube to apply a force, press ``Space`` -to pause or resume physics, and press ``.`` to advance one physics step. +dynamic cubes. Right-click and drag any cube to apply an interactive force. .. code-block:: bash - uv run python scripts/demos/newton_viewer_controls.py + uv run python scripts/demos/newton_viewer_dragging.py """ import argparse from isaaclab.app import add_launcher_args, launch_simulation -parser = argparse.ArgumentParser(description="Newton viewer controls with three draggable MJWarp cubes.") +parser = argparse.ArgumentParser(description="Newton viewer dragging with three dynamic MJWarp cubes.") parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") add_launcher_args(parser) parser.set_defaults(visualizer=["newton"]) @@ -47,7 +46,7 @@ def cube_cfg(name: str, position: tuple[float, float, float]) -> RigidObjectCfg: @configclass -class ViewerControlsSceneCfg(InteractiveSceneCfg): +class ViewerDraggingSceneCfg(InteractiveSceneCfg): """Ground plane and three dynamic MJWarp cubes.""" ground = AssetBaseCfg( @@ -71,15 +70,15 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> def main() -> None: - """Launch the MJWarp viewer-controls demo.""" + """Launch the MJWarp viewer-dragging demo.""" physics_cfg = NewtonCfg(solver_cfg=MJWarpSolverCfg()) with launch_simulation(cfg=physics_cfg, launcher_args=args_cli) as resolved_physics_cfg: sim_cfg = sim_utils.SimulationCfg(dt=1.0 / 100.0, device=args_cli.device, physics=resolved_physics_cfg) sim = sim_utils.SimulationContext(sim_cfg) sim.set_camera_view(eye=(3.0, -4.0, 2.5), target=(0.0, 0.0, 0.5)) - scene = InteractiveScene(ViewerControlsSceneCfg(num_envs=1, env_spacing=1.0)) + scene = InteractiveScene(ViewerDraggingSceneCfg(num_envs=1, env_spacing=1.0)) sim.reset() - print("[INFO]: Right-click and drag any cube. Space pauses; '.' advances one step.", flush=True) + print("[INFO]: Setup complete. Right-click and drag any cube.", flush=True) run_simulator(sim, scene) diff --git a/source/isaaclab/changelog.d/max-newton-viewer-controls.rst b/source/isaaclab/changelog.d/max-newton-viewer-controls.rst deleted file mode 100644 index 3487fb0b5d60..000000000000 --- a/source/isaaclab/changelog.d/max-newton-viewer-controls.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Fixed visualizer pause and single-step requests to gate physics before each - simulation step. diff --git a/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst b/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst new file mode 100644 index 000000000000..a31d62d33910 --- /dev/null +++ b/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added a three-cube Newton MJWarp demo for interactive rigid-body dragging. diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 8e6c34cdde82..49217d440687 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -8,7 +8,7 @@ import gc import logging import traceback -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import contextmanager from dataclasses import fields from typing import TYPE_CHECKING, Any @@ -195,6 +195,9 @@ def __init__(self, cfg: SimulationCfg | None = None): # Initialize visualizer state (visualizers are created lazily during initialize_visualizers()). self._scene_data_provider = SceneDataProvider(self.physics_manager.get_scene_data_backend()) self._visualizers: list[BaseVisualizer] = [] + self._visualizer_cfg_cache: list[Any] | None = None + self._initialized_visualizer_cfg_indices: set[int] = set() + self._visualizers_fully_initialized = False self._reset_requested: bool = False self._scene_data_requirements = SceneDataRequirement() # Clone plan published by InteractiveScene after cloning. Providers (e.g. the @@ -231,7 +234,6 @@ def __init__(self, cfg: SimulationCfg | None = None): # Shared renderers for all Camera sensors (compatible renderer_cfg only). self._render_context = RenderContext() - self._pre_capture_visualizers: set[BaseVisualizer] = set() # Run renderer post-physics setup. self.physics_manager.register_callback( @@ -239,7 +241,8 @@ def __init__(self, cfg: SimulationCfg | None = None): PhysicsEvent.PHYSICS_READY, order=5, ) - if "mjwarp" in self.physics_manager.__name__.lower() and "newton" in self.resolve_visualizer_types(): + is_newton_mjwarp = self.physics_manager.__name__ == "NewtonMJWarpManager" + if is_newton_mjwarp and any(self._is_interactive_newton_cfg(cfg) for cfg in self._get_visualizer_cfgs()): self.physics_manager.register_callback( self._prepare_newton_mjwarp_visualizer_for_capture, PhysicsEvent.PHYSICS_READY, @@ -542,17 +545,44 @@ def _resolve_visualizer_cfgs(self) -> list[Any]: return resolved - def initialize_visualizers(self, only_types: set[str] | None = None) -> None: - """Initialize missing visualizers from ``SimulationCfg.visualizer_cfgs``. + def initialize_visualizers(self) -> None: + """Initialize visualizers from ``SimulationCfg.visualizer_cfgs``.""" + if self._visualizers_fully_initialized: + if self._visualizers: + return + # Preserve the existing behavior of recreating configured visualizers + # after all previous instances have been closed. + self._visualizer_cfg_cache = None + self._initialized_visualizer_cfg_indices.clear() + self._visualizers_fully_initialized = False + + visualizer_cfgs = self._get_visualizer_cfgs() + if not visualizer_cfgs: + self._visualizers_fully_initialized = True + return - Args: - only_types: Optional visualizer types to initialize. Other configured - visualizers remain pending until a later unfiltered call. - """ + self._initialize_visualizers() + self._visualizers_fully_initialized = True + self._pending_camera_view = None + + if not self._visualizers and self._scene_data_provider is not None: + close_provider = getattr(self._scene_data_provider, "close", None) + if callable(close_provider): + close_provider() + self._scene_data_provider = None + + def _get_visualizer_cfgs(self) -> list[Any]: + """Resolve and cache visualizer configs for the current initialization cycle.""" + if self._visualizer_cfg_cache is None: + self._visualizer_cfg_cache = self._resolve_visualizer_cfgs() + return self._visualizer_cfg_cache + + def _initialize_visualizers(self, config_filter: Callable[[Any], bool] | None = None) -> None: + """Initialize pending visualizers, optionally restricted by config.""" physics_dt = getattr(self.cfg.physics, "dt", None) self._viz_dt = (physics_dt if physics_dt is not None else self.cfg.dt) * self.cfg.render_interval - visualizer_cfgs = self._resolve_visualizer_cfgs() + visualizer_cfgs = self._get_visualizer_cfgs() if not visualizer_cfgs: return @@ -564,19 +594,19 @@ def initialize_visualizers(self, only_types: set[str] | None = None) -> None: ] requirements = resolve_scene_data_requirements(visualizer_types=visualizer_types) self._scene_data_requirements = requirements - initialized_types = {getattr(viz.cfg, "visualizer_type", None) for viz in self._visualizers} - visualizer_cfgs = [ - cfg - for cfg in visualizer_cfgs - if getattr(cfg, "visualizer_type", None) not in initialized_types - and (only_types is None or getattr(cfg, "visualizer_type", None) in only_types) - ] - for cfg in visualizer_cfgs: + new_visualizers = [] + for index, cfg in enumerate(visualizer_cfgs): + if index in self._initialized_visualizer_cfg_indices: + continue + if config_filter is not None and not config_filter(cfg): + continue + self._initialized_visualizer_cfg_indices.add(index) try: visualizer = cfg.create_visualizer() visualizer.initialize(self._scene_data_provider) self._visualizers.append(visualizer) + new_visualizers.append(visualizer) except Exception as exc: if cli_explicit: raise RuntimeError( @@ -594,16 +624,8 @@ def initialize_visualizers(self, only_types: set[str] | None = None) -> None: pending = getattr(self, "_pending_camera_view", None) if pending is not None: eye, target = pending - for viz in self._visualizers: + for viz in new_visualizers: viz.set_camera_view(eye, target) - if only_types is None: - self._pending_camera_view = None - - if only_types is None and not self._visualizers and self._scene_data_provider is not None: - close_provider = getattr(self._scene_data_provider, "close", None) - if callable(close_provider): - close_provider() - self._scene_data_provider = None def get_scene_data_provider(self) -> SceneDataProvider: return self._scene_data_provider @@ -660,14 +682,22 @@ def forward(self) -> None: def _prepare_newton_mjwarp_visualizer_for_capture(self, _payload=None) -> None: """Initialize or rebind the Newton viewer before MJWarp graph capture.""" - existing = {viz for viz in self._visualizers if getattr(viz.cfg, "visualizer_type", None) == "newton"} - if not existing: - self.initialize_visualizers(only_types={"newton"}) - - for viz in (viz for viz in self._visualizers if getattr(viz.cfg, "visualizer_type", None) == "newton"): - if viz in existing: - viz.reset(soft=False) - self._pre_capture_visualizers.add(viz) + if self._visualizers_fully_initialized and not self._visualizers: + self._visualizer_cfg_cache = None + self._initialized_visualizer_cfg_indices.clear() + self._visualizers_fully_initialized = False + self._initialize_visualizers(self._is_interactive_newton_cfg) + for viz in (viz for viz in self._visualizers if self._is_interactive_newton_cfg(viz.cfg)): + viz.reset(soft=False) + + @staticmethod + def _is_interactive_newton_cfg(cfg: Any) -> bool: + """Return whether a config can create interactive Newton picking inputs.""" + return ( + getattr(cfg, "visualizer_type", None) == "newton" + and bool(getattr(cfg, "enable_picking", False)) + and not bool(getattr(cfg, "headless", False)) + ) def reset(self, soft: bool = False) -> None: """Reset the simulation. @@ -675,14 +705,12 @@ def reset(self, soft: bool = False) -> None: Args: soft: If True, skip full reinitialization. """ - self._pre_capture_visualizers.clear() self.physics_manager.reset(soft) for viz in self._visualizers: - if viz not in self._pre_capture_visualizers: - viz.reset(soft) - # Initialize any visualizers not prepared by a backend-specific pre-capture hook. - self.initialize_visualizers() - self._pre_capture_visualizers.clear() + viz.reset(soft) + if not self._visualizers_fully_initialized or not self._visualizers: + # Initialize visualizers not prepared by a backend-specific pre-capture hook. + self.initialize_visualizers() # Start the timeline so the play button is pressed self.physics_manager.play() self._is_playing = True @@ -691,9 +719,8 @@ def reset(self, soft: bool = False) -> None: def step(self, render: bool = True) -> None: """Step physics and optionally render. - If the timeline or a visualizer is paused, this method blocks and keeps - its event loop responsive until simulation is resumed, single-stepped, - or stopped. + If the timeline is paused (e.g. via the GUI), this method blocks and keeps + the visualizer responsive until the timeline is resumed or stopped. Args: render: Whether to render the scene after stepping. Defaults to True. @@ -701,29 +728,11 @@ def step(self, render: bool = True) -> None: # Block while the GUI timeline is paused so the entire training loop freezes. # See: https://github.com/isaac-sim/IsaacLab/issues/4279 self.physics_manager.wait_for_playing() - self._wait_for_visualizer_step() self._physics_step_count += 1 self.physics_manager.step() if render and self.is_rendering: self.render() - def _wait_for_visualizer_step(self) -> None: - """Pump standalone visualizers until each permits one physics step.""" - for viz in tuple(self._visualizers): - if viz.pumps_app_update(): - continue - try: - while viz.is_running() and not viz.is_closed and not viz.should_step(): - viz.step(0.0) - except Exception as exc: - logger.error("Error polling paused visualizer '%s': %s", type(viz).__name__, exc) - try: - viz.close() - except Exception as close_exc: - logger.error("Error closing visualizer: %s", close_exc) - if viz in self._visualizers: - self._visualizers.remove(viz) - def render(self, mode: int | None = None, skip_app_pumping: bool = False) -> None: """Update visualizers and render the scene. @@ -803,6 +812,8 @@ def update_visualizers(self, dt: float, skip_app_pumping: bool = False) -> None: if not viz.pumps_app_update(): viz.step(0.0) continue + while viz.is_training_paused() and viz.is_running(): + viz.step(0.0) viz.step(dt) except Exception as exc: logger.error("Error stepping visualizer '%s': %s", type(viz).__name__, exc) diff --git a/source/isaaclab/isaaclab/visualizers/base_visualizer.py b/source/isaaclab/isaaclab/visualizers/base_visualizer.py index 10c2cd5190ba..fa7a708c7fe7 100644 --- a/source/isaaclab/isaaclab/visualizers/base_visualizer.py +++ b/source/isaaclab/isaaclab/visualizers/base_visualizer.py @@ -88,17 +88,6 @@ def is_running(self) -> bool: """ raise NotImplementedError - def should_step(self) -> bool: - """Return whether the simulation may advance one step. - - Stateful visualizers may consume a pending single-step request here. - The default behavior supports pause-only visualizers. - - Returns: - ``True`` when physics should advance, otherwise ``False``. - """ - return not self.is_training_paused() - def is_training_paused(self) -> bool: """Check if training is paused by visualizer controls. diff --git a/source/isaaclab/test/app/standalone_script_cases.py b/source/isaaclab/test/app/standalone_script_cases.py index 1288994467af..49cd537271b6 100644 --- a/source/isaaclab/test/app/standalone_script_cases.py +++ b/source/isaaclab/test/app/standalone_script_cases.py @@ -165,6 +165,11 @@ class SmokeResult: fixed_physics_backend="newton_mpm", ), "scripts/demos/multi_asset.py": ScriptOverride(args=("--num_envs", "4")), + "scripts/demos/newton_viewer_dragging.py": ScriptOverride( + args=("--max_steps", "20"), + fixed_physics_backend="newton_mjwarp", + visualizers=("newton",), + ), "scripts/demos/sensors/cameras.py": ScriptOverride(args=("--num_envs", "1"), startup_timeout=600.0), "scripts/demos/sensors/multi_mesh_raycaster.py": ScriptOverride( args=("--flat_ground",), diff --git a/source/isaaclab/test/markers/test_visualization_markers.py b/source/isaaclab/test/markers/test_visualization_markers.py index cae008a5b969..b3817c8332bd 100644 --- a/source/isaaclab/test/markers/test_visualization_markers.py +++ b/source/isaaclab/test/markers/test_visualization_markers.py @@ -281,9 +281,6 @@ def __init__(self): def is_paused(self): return False - def is_rendering_paused(self): - return False - def is_running(self): return True diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index eb168f52378a..1fc1dabe008f 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -33,18 +33,10 @@ def test_web_visualizer_cfgs_do_not_open_browser_by_default(): class _FakePhysicsManager: def __init__(self): self.forward_calls = 0 - self.wait_for_playing_calls = 0 - self.step_calls = 0 def forward(self): self.forward_calls += 1 - def wait_for_playing(self): - self.wait_for_playing_calls += 1 - - def step(self): - self.step_calls += 1 - class _FakeProvider: """Fake new-style SceneDataProvider for tests; only provides what visualizers read.""" @@ -88,7 +80,6 @@ def __init__( self._requires_forward = requires_forward self._pumps_app_update = pumps_app_update self.step_calls = [] - self.should_step_calls = 0 self.close_calls = 0 @property @@ -101,10 +92,6 @@ def is_running(self): def is_rendering_paused(self): return self._rendering_paused - def should_step(self): - self.should_step_calls += 1 - return not self.is_training_paused() - def is_training_paused(self): if self._training_paused_steps > 0: self._training_paused_steps -= 1 @@ -141,7 +128,6 @@ def _make_context(visualizers, provider=None): ctx._visualizers = list(visualizers) ctx._scene_data_provider = provider ctx.physics_manager = _FakePhysicsManager() - ctx._physics_step_count = 0 return ctx @@ -200,58 +186,43 @@ def test_update_visualizers_skips_zero_dt_for_paused_app_pumping_visualizer(): assert paused_app_pumping_viz.step_calls == [] -def test_step_pumps_paused_visualizer_before_physics(): +def test_update_visualizers_handles_training_pause_loop(): + provider = _FakeProvider() viz = _FakeVisualizer(training_paused_steps=1) - ctx = _make_context([viz]) - - ctx.step(render=False) - - assert viz.step_calls == [0.0] - assert viz.should_step_calls == 2 - assert ctx.physics_manager.wait_for_playing_calls == 1 - assert ctx.physics_manager.step_calls == 1 - assert ctx._physics_step_count == 1 - - -def test_step_leaves_app_backed_visualizer_to_physics_timeline_gate(): - app_backed = _FakeVisualizer(pumps_app_update=True, training_paused_steps=100) - ctx = _make_context([app_backed]) + ctx = _make_context([viz], provider=provider) - ctx.step(render=False) + ctx.update_visualizers(0.2) - assert app_backed.should_step_calls == 0 - assert app_backed.step_calls == [] - assert ctx.physics_manager.step_calls == 1 - assert ctx._physics_step_count == 1 + assert viz.step_calls == [0.0, 0.2] def test_newton_mjwarp_visualizer_is_initialized_and_rebound_before_capture(): - ctx = object.__new__(SimulationContext) - ctx._visualizers = [] - ctx._pre_capture_visualizers = set() - viz = _FakeVisualizer() - viz.cfg = type("Cfg", (), {"visualizer_type": "newton"})() + created = [] reset_calls = [] - initialize_calls = [] - viz.reset = lambda soft: reset_calls.append(soft) - def initialize_visualizers(only_types=None): - initialize_calls.append(only_types) - ctx._visualizers.append(viz) + class _Cfg: + def __init__(self, visualizer_type, enable_picking=False): + self.visualizer_type = visualizer_type + self.enable_picking = enable_picking + self.headless = False - ctx.initialize_visualizers = initialize_visualizers + def create_visualizer(self): + viz = _FakeVisualizer() + viz.cfg = self + viz.initialize = lambda _provider: created.append(self.visualizer_type) + viz.reset = lambda soft: reset_calls.append((self.visualizer_type, soft)) + return viz + ctx = _make_context_with_settings({}, visualizer_cfgs=[_Cfg("newton", True), _Cfg("rerun")]) ctx._prepare_newton_mjwarp_visualizer_for_capture() + assert created == ["newton"] - assert initialize_calls == [{"newton"}] - assert ctx._visualizers == [viz] - assert ctx._pre_capture_visualizers == {viz} - - ctx._pre_capture_visualizers.clear() + ctx.initialize_visualizers() ctx._prepare_newton_mjwarp_visualizer_for_capture() - assert reset_calls == [False] - assert ctx._pre_capture_visualizers == {viz} + assert created == ["newton", "rerun"] + assert len(ctx._visualizers) == 2 + assert reset_calls == [("newton", False), ("newton", False)] def test_reset_initializes_visualizers_before_playing_timeline(): @@ -259,7 +230,7 @@ def test_reset_initializes_visualizers_before_playing_timeline(): events: list[str] = [] ctx = object.__new__(SimulationContext) ctx._visualizers = [] - ctx._pre_capture_visualizers = set() + ctx._visualizers_fully_initialized = False class _PhysicsManager: @staticmethod @@ -749,6 +720,9 @@ def _make_context_with_settings( ctx._pending_camera_view = None ctx._render_generation = 0 ctx._visualizers = [] + ctx._visualizer_cfg_cache = None + ctx._initialized_visualizer_cfg_indices = set() + ctx._visualizers_fully_initialized = False ctx._scene_data_provider = _FakeProvider() ctx._scene_data_requirements = None ctx._clone_plan = None diff --git a/source/isaaclab_newton/changelog.d/max-newton-viewer-dragging.minor.rst b/source/isaaclab_newton/changelog.d/max-newton-viewer-dragging.minor.rst new file mode 100644 index 000000000000..b36dd69acfb0 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/max-newton-viewer-dragging.minor.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added support for applying Newton visualizer dragging forces during MJWarp + substeps. diff --git a/source/isaaclab_newton/changelog.d/max-newton-viewer-support.rst b/source/isaaclab_newton/changelog.d/max-newton-viewer-support.rst deleted file mode 100644 index 9e67174f33e1..000000000000 --- a/source/isaaclab_newton/changelog.d/max-newton-viewer-support.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* Added per-substep state-force callbacks for Newton MJWarp viewer interaction. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index fb73c641511f..621c2a89c4d6 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -2014,8 +2014,6 @@ def _run_solver_substeps(cls, contacts) -> None: cfg = PhysicsManager._cfg need_copy_on_last = cfg is not None and cls._num_substeps % 2 == 1 for i in range(cls._num_substeps): - for callback in cls._state_force_callbacks: - callback(cls._state_0) cls._step_solver(cls._state_0, cls._state_1, cls._control, contacts, cls._solver_dt) if need_copy_on_last and i == cls._num_substeps - 1: cls._state_0.assign(cls._state_1) @@ -2531,9 +2529,8 @@ def register_post_actuator_callback(cls, callback: Callable[[], None]) -> None: def register_state_force_callback(cls, callback: Callable[[State], None]) -> None: """Register a graph-safe callback that applies forces before every solver substep. - Callbacks registered before solver initialization are included in the - existing CUDA graph capture. Late registration falls back to eager - execution for safety. + Callbacks must be registered before solver initialization so they are + included in CUDA graph capture. Args: callback: Function that adds forces [N, N·m] to the provided state. @@ -2541,11 +2538,6 @@ def register_state_force_callback(cls, callback: Callable[[State], None]) -> Non if callback in NewtonManager._state_force_callbacks: return NewtonManager._state_force_callbacks.append(callback) - if NewtonManager._graph is None: - return - NewtonManager._graph = None - NewtonManager._graph_capture_pending = False - logger.info("%s switched to eager execution after a late state-force callback", cls.__name__) @classmethod def register_post_step_callback(cls, callback: Callable[[], None]) -> None: diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index bd206a50edd5..75e111366fcf 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -880,72 +880,33 @@ def counting_collide(state, contacts): assert calls["n"] == 1 + expected_mid_loop_collides -@pytest.mark.parametrize( - "use_single_state, expected_events", - [ - (True, [("force", "state_0"), ("step", "state_0", "state_0")] * 2), - ( - False, - [ - ("force", "state_0"), - ("step", "state_0", "state_1"), - ("force", "state_1"), - ("step", "state_1", "state_0"), - ], - ), - ], -) -def test_state_force_callback_runs_before_every_solver_substep(monkeypatch, use_single_state, expected_events): - """Viewer forces are applied to the current state before every solver substep.""" +def test_state_force_callback_runs_before_every_mjwarp_substep(monkeypatch): + """Viewer forces are applied before every in-place MJWarp solver substep.""" events = [] class _State: - def __init__(self, name): - self.name = name - def clear_forces(self): pass - state_0 = _State("state_0") - state_1 = _State("state_1") + state = _State() - monkeypatch.setattr(NewtonManager, "_state_0", state_0) - monkeypatch.setattr(NewtonManager, "_state_1", state_1) + monkeypatch.setattr(NewtonManager, "_state_0", state) monkeypatch.setattr(NewtonManager, "_control", object()) monkeypatch.setattr(NewtonManager, "_solver_dt", 0.001) monkeypatch.setattr(NewtonManager, "_num_substeps", 2) monkeypatch.setattr(NewtonManager, "_collision_decimation", 0) monkeypatch.setattr(NewtonManager, "_needs_collision_pipeline", False) - monkeypatch.setattr(NewtonManager, "_use_single_state", use_single_state) - monkeypatch.setattr(NewtonManager, "_state_force_callbacks", [lambda state: events.append(("force", state.name))]) + monkeypatch.setattr(NewtonManager, "_use_single_state", True) + monkeypatch.setattr(NewtonManager, "_state_force_callbacks", [lambda _state: events.append("force")]) monkeypatch.setattr( NewtonManager, "_step_solver", - staticmethod( - lambda input_state, output_state, _control, _contacts, _dt: events.append( - ("step", input_state.name, output_state.name) - ) - ), + staticmethod(lambda *_args: events.append("step")), ) NewtonManager._run_solver_substeps(contacts=None) - assert events == expected_events - - -def test_late_state_force_callback_drops_captured_graph(monkeypatch): - def callback(_state): - pass - - monkeypatch.setattr(NewtonManager, "_graph", object()) - monkeypatch.setattr(NewtonManager, "_graph_capture_pending", False) - monkeypatch.setattr(NewtonManager, "_state_force_callbacks", []) - - NewtonManager.register_state_force_callback(callback) - - assert NewtonManager._state_force_callbacks == [callback] - assert NewtonManager._graph is None - assert NewtonManager._graph_capture_pending is False + assert events == ["force", "step", "force", "step"] # --------------------------------------------------------------------------- diff --git a/source/isaaclab_visualizers/changelog.d/max-newton-viewer-dragging.minor.rst b/source/isaaclab_visualizers/changelog.d/max-newton-viewer-dragging.minor.rst new file mode 100644 index 000000000000..24fc2135543a --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/max-newton-viewer-dragging.minor.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added right-click rigid-body dragging to the Newton visualizer with the + MJWarp solver. diff --git a/source/isaaclab_visualizers/changelog.d/max-newton-viewer-support.rst b/source/isaaclab_visualizers/changelog.d/max-newton-viewer-support.rst deleted file mode 100644 index 617a73bc998a..000000000000 --- a/source/isaaclab_visualizers/changelog.d/max-newton-viewer-support.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added Newton MJWarp rigid-body dragging and wired Newton's native pause and - single-step controls to simulation stepping. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 772a4e5506e1..754c5022c2eb 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -85,7 +85,7 @@ def _newton_scalar_base_name(name: str) -> str: class NewtonViewerGL(ViewerGL): - """Wrapper around Newton's ViewerGL with Isaac Lab rendering controls.""" + """Wrapper around Newton's ViewerGL with training/rendering pause controls.""" def __init__( self, @@ -103,6 +103,7 @@ def __init__( **kwargs: Keyword arguments forwarded to ``ViewerGL``. """ super().__init__(*args, **kwargs) + self._paused_training = False self._paused_rendering = False self._reset_requested = False self._metadata = metadata or {} @@ -128,13 +129,6 @@ def __init__( self._register_isaaclab_ui_callbacks() - def set_model(self, model) -> None: - """Set a model and restore UI callbacks cleared by Newton on model swaps.""" - replaces_model = self.model is not None - super().set_model(model) - if replaces_model: - self._register_isaaclab_ui_callbacks() - def _register_isaaclab_ui_callbacks(self) -> None: """Register Isaac Lab's model-dependent viewer controls.""" try: @@ -143,6 +137,13 @@ def _register_isaaclab_ui_callbacks(self) -> None: except AttributeError: self._fallback_draw_controls = True + def apply_picking_force(self, state: State) -> None: + """Apply only the viewer's rigid-body picking force.""" + if self.picking_enabled and self.picking is not None: + # Newton currently exposes picking only through apply_forces(), + # which also applies wind. Keep this integration dragging-only. + self.picking._apply_picking_force(state) + def _patch_scalar_plot_width(self) -> None: """Set up ImPlot and suppress Newton's built-in floating Plots window. @@ -168,7 +169,7 @@ def _patch_scalar_plot_width(self) -> None: def is_training_paused(self) -> bool: """Return whether simulation is paused by viewer controls.""" - return self.is_paused() + return self._paused_training def is_rendering_paused(self) -> bool: """Return whether rendering is paused by viewer controls.""" @@ -198,8 +199,8 @@ def _patch_viewer_panel(self) -> None: 6. **Controls** (closed) — camera keyboard reference. 7. **Selection API** (closed) — Newton's selection panel. - Newton's native pause and single-step state is exposed through the - Isaac Lab controls so UI buttons and keyboard shortcuts share one state. + The top-level Newton ``Pause / Step`` row is suppressed; pause/resume is + handled by the IsaacLab training controls inside **Isaac Lab**. """ import newton as nt @@ -349,17 +350,15 @@ def _render_left_panel(_g=gui): gui._render_left_panel = _render_left_panel def _render_training_controls(self, imgui): - """Render native simulation controls inside the Isaac Lab panel section.""" - _changed, self._paused = imgui.checkbox("Pause Simulation", self._paused) - imgui.same_line() - imgui.begin_disabled(not self._paused) - if imgui.button("Step"): - self._step_requested = True - imgui.end_disabled() + """Render Isaac Lab training control widgets inside the Isaac Lab panel section.""" + pause_label = "Resume Simulation" if self._paused_training else "Pause Simulation" + if imgui.button(pause_label): + self._paused_training = not self._paused_training rendering_label = "Resume Rendering" if self._paused_rendering else "Pause Rendering" if imgui.button(rendering_label): self._paused_rendering = not self._paused_rendering + self._paused = self._paused_rendering if imgui.button("Reset Episode"): self._reset_requested = True @@ -378,6 +377,12 @@ def _render_training_controls(self, imgui): " training\nhigher values -> less responsive visualizer but faster training" ) + def on_key_press(self, symbol, modifiers): + """Forward key presses unless UI is currently capturing input.""" + if self.ui.is_capturing(): + return + super().on_key_press(symbol, modifiers) + def _render_ui(self): """Render default UI and fallback control window when callback hooks are unavailable.""" if not self._fallback_draw_controls: @@ -576,50 +581,43 @@ def _prime_image_logger_window_layout(self) -> None: class NewtonVisualizer(BaseVisualizer): """Newton OpenGL visualizer for Isaac Lab.""" - class _ViewerForceBinding: - """Stable Newton-manager callback for viewer-owned force inputs. + class _ViewerPickingBinding: + """Stable Newton-manager callback for viewer picking. - CUDA graphs record the viewer's picking and wind arrays by address. - Closing the window therefore neutralizes and retains those small inputs - instead of removing the callback and invalidating the physics graph. + CUDA graphs record picking arrays by address, so closing the window + neutralizes and retains them until the captured graph is gone. """ def __init__(self) -> None: self._viewer: NewtonViewerGL | None = None - self._retained_force_helpers: tuple[object, ...] = () + self._retained_picking = None def bind(self, viewer: NewtonViewerGL) -> None: - """Bind force application to the current viewer model.""" + """Bind picking to the current viewer model.""" self._viewer = viewer - self._retained_force_helpers = () + self._retained_picking = None def apply(self, state: State) -> None: - """Apply viewer forces while the viewer is active.""" + """Apply picking while the viewer is active.""" if self._viewer is None: - # Host callbacks do not run while the captured graph replays. - # Reaching this branch means that graph is gone, so its force - # helpers can be released. - self._retained_force_helpers = () + # Host callbacks do not run during graph replay, so reaching + # this branch means captured inputs are no longer needed. + self._retained_picking = None return - self._viewer.apply_forces(state) + self._viewer.apply_picking_force(state) def deactivate(self) -> None: - """Make captured force launches inert while preserving their inputs.""" + """Make captured picking inert while preserving its inputs.""" viewer = self._viewer if viewer is None: return picking = getattr(viewer, "picking", None) - wind = getattr(viewer, "wind", None) if picking is not None: viewer.picking_enabled = False picking.release() - if wind is not None: - wind.amplitude = 0.0 - wind.update(0.0) - # The captured graph retains addresses owned by these helpers. - self._retained_force_helpers = tuple(helper for helper in (picking, wind) if helper is not None) + self._retained_picking = picking self._viewer = None def __init__(self, cfg: NewtonVisualizerCfg): @@ -644,8 +642,7 @@ def __init__(self, cfg: NewtonVisualizerCfg): self._camera_env_indices: list[int] = [] self._camera_is_owned = False self._generated_camera_prim_paths: list[str] = [] - self._viewer_force_binding = self._ViewerForceBinding() - self._state_force_callback_registered = False + self._viewer_picking_binding = self._ViewerPickingBinding() self._picking_enabled = False self._live_plots_manager_visible: dict[str, bool] = {} @@ -666,7 +663,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: scene_data_provider = self._set_scene_data_provider(scene_data_provider) newton_backend_active = self.physics_backend == "newton" physics_manager = SimulationContext.instance().physics_manager - mjwarp_backend_active = newton_backend_active and "mjwarp" in physics_manager.__name__.lower() + mjwarp_backend_active = newton_backend_active and physics_manager.__name__ == "NewtonMJWarpManager" num_envs = scene_data_provider.num_envs metadata = {"num_envs": num_envs} self._env_ids = self._compute_visualized_env_ids() @@ -717,14 +714,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: self._viewer.scaling = 1.0 self._viewer._paused = False - self._viewer.show_joints = self.cfg.show_joints - self._viewer.show_contacts = self.cfg.show_contacts - self._viewer.show_collision = self.cfg.show_collision - self._viewer.show_springs = self.cfg.show_springs - self._viewer.show_inertia_boxes = self.cfg.show_inertia_boxes - self._viewer.show_com = self.cfg.show_com - self._viewer.show_particles = self.cfg.show_particles - self._viewer.particle_color = self.cfg.particle_color + self._apply_model_visualization_options() self._picking_enabled = self.cfg.enable_picking and mjwarp_backend_active and not runtime_headless self._viewer.picking_enabled = self._picking_enabled @@ -761,15 +751,27 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: ], ) if self._viewer is not None and self._picking_enabled: - self._viewer_force_binding.bind(self._viewer) - NewtonManager.register_state_force_callback(self._viewer_force_binding.apply) - self._state_force_callback_registered = True + self._viewer_picking_binding.bind(self._viewer) + NewtonManager.register_state_force_callback(self._viewer_picking_binding.apply) if self._viewer is not None and self.cfg.enable_picking and not mjwarp_backend_active: logger.info( "[NewtonVisualizer] Object dragging is disabled because the active physics solver is not Newton MJWarp." ) self._is_initialized = True + def _apply_model_visualization_options(self) -> None: + """Apply configured options reset by Newton model changes.""" + if self._viewer is None: + return + self._viewer.show_joints = self.cfg.show_joints + self._viewer.show_contacts = self.cfg.show_contacts + self._viewer.show_collision = self.cfg.show_collision + self._viewer.show_springs = self.cfg.show_springs + self._viewer.show_inertia_boxes = self.cfg.show_inertia_boxes + self._viewer.show_com = self.cfg.show_com + self._viewer.show_particles = self.cfg.show_particles + self._viewer.particle_color = self.cfg.particle_color + def step(self, dt: float) -> None: """Advance visualization by one simulation step. @@ -779,38 +781,26 @@ def step(self, dt: float) -> None: if not self._is_initialized or self._is_closed: return - if dt > 0.0: - self._sim_time += dt - self._step_counter += 1 + self._sim_time += dt + self._step_counter += 1 from isaaclab_newton.physics import NewtonManager if self._viewer is None: - if dt > 0.0: - self._state = NewtonManager.get_state(self._scene_data_provider) + self._state = NewtonManager.get_state(self._scene_data_provider) return update_frequency = self._viewer._update_frequency if self._viewer else self._update_frequency - if dt > 0.0 and self._step_counter % update_frequency != 0 and not self._viewer.is_paused(): - return - - if dt <= 0.0: - try: - # Pump input and UI without re-logging unchanged scene data while physics is paused. - self._viewer._update() - if not self._viewer.is_running(): - self._viewer_force_binding.deactivate() - except Exception: - logger.exception("[NewtonVisualizer] Viewer update failed.") + if self._step_counter % update_frequency != 0: return num_envs = NewtonManager.get_num_envs() try: - self._viewer.begin_frame(self._sim_time) - try: - if not self._viewer.is_rendering_paused(): - self._state = NewtonManager.get_state(self._scene_data_provider) + if not self._viewer.is_paused(): + self._state = NewtonManager.get_state(self._scene_data_provider) + self._viewer.begin_frame(self._sim_time) + try: if self._state is not None: body_q = getattr(self._state, "body_q", None) if hasattr(body_q, "shape") and body_q.shape[0] == 0: @@ -827,40 +817,48 @@ def step(self, dt: float) -> None: ) self._log_camera_sensor_image() self._render_live_plots() - finally: - self._viewer.end_frame() + finally: + self._viewer.end_frame() + if not self._viewer.is_running(): + self._viewer_picking_binding.deactivate() + else: + self._viewer._update() if not self._viewer.is_running(): - self._viewer_force_binding.deactivate() + self._viewer_picking_binding.deactivate() except Exception: logger.exception("[NewtonVisualizer] Viewer update failed.") def reset(self, soft: bool = False) -> None: """Rebind viewer resources after a hard Newton model reset.""" - if soft or not self._is_initialized or self._is_closed or self.physics_backend != "newton": + if soft or not self._picking_enabled or not self._is_initialized or self._is_closed: return from isaaclab_newton.physics import NewtonManager - self._model = NewtonManager.get_model() + model = NewtonManager.get_model() + if model is self._model: + return + self._model = model self._state = NewtonManager.get_state_0() if self._viewer is not None: self._viewer.set_model(self._model) + self._viewer._register_isaaclab_ui_callbacks() self._viewer.set_visible_worlds(self._resolved_visible_env_ids) self._viewer.set_world_offsets(self.cfg.world_spacing) + self._apply_model_visualization_options() self._viewer.picking_enabled = self._picking_enabled - if self._state_force_callback_registered: - self._viewer_force_binding.bind(self._viewer) + if self._picking_enabled: + self._viewer_picking_binding.bind(self._viewer) def close(self) -> None: """Release viewer resources.""" if self._is_closed: return - if self._state_force_callback_registered: + if self._picking_enabled: # Keep the stable callback registered: captured graphs replay its # now-neutral device inputs without retaining the GL viewer. - self._viewer_force_binding.deactivate() + self._viewer_picking_binding.deactivate() if self._viewer is not None: - self._viewer.close() self._viewer = None if self._camera_sensor is not None and self._camera_is_owned: remove_generated_prims(self._generated_camera_prim_paths) @@ -1251,12 +1249,6 @@ def is_training_paused(self) -> bool: return False return self._viewer.is_training_paused() - def should_step(self) -> bool: - """Return whether Newton's native pause/step controls permit one physics step.""" - if not self._is_initialized or self._viewer is None: - return True - return self._viewer.should_step() - def is_rendering_paused(self) -> bool: """Return whether rendering is paused from viewer controls.""" if not self._is_initialized or self._viewer is None: diff --git a/source/isaaclab_visualizers/test/test_newton_adapter.py b/source/isaaclab_visualizers/test/test_newton_adapter.py index 8f282fac0108..0693f72bac3d 100644 --- a/source/isaaclab_visualizers/test/test_newton_adapter.py +++ b/source/isaaclab_visualizers/test/test_newton_adapter.py @@ -111,11 +111,13 @@ def set_visible_worlds(self, worlds): assert calls[-1] is None -def test_newton_visualizer_cfg_exposes_particle_options(): +def test_newton_visualizer_cfg_exposes_viewer_options(): cfg = NewtonVisualizerCfg(show_particles=True, particle_color=(0.1, 0.2, 0.3)) assert cfg.show_particles is True assert cfg.particle_color == (0.1, 0.2, 0.3) + assert cfg.enable_picking is True + assert NewtonVisualizerCfg(enable_picking=False).enable_picking is False def test_newton_marker_registry_lifecycle(monkeypatch: pytest.MonkeyPatch): @@ -363,9 +365,6 @@ def __init__(self): def is_paused(self): return False - def is_rendering_paused(self): - return False - def is_running(self): return True @@ -384,9 +383,6 @@ def log_arrows(self, name, starts, ends, colors): def end_frame(self): pass - def close(self): - pass - class _Proxy: def __init__(self, tensor): @@ -421,83 +417,34 @@ def _make_newton_visualizer(viewer, scene_data_provider=None): visualizer._viewer = viewer visualizer._scene_data_provider = scene_data_provider if viewer is not None: - visualizer._viewer_force_binding.bind(viewer) + visualizer._viewer_picking_binding.bind(viewer) visualizer._log_camera_sensor_image = lambda: None return visualizer -def test_newton_viewer_model_swap_restores_only_cleared_ui_callbacks(monkeypatch): - from newton.viewer import ViewerGL - - viewer = NewtonViewerGL.__new__(NewtonViewerGL) - viewer.model = object() - registered_positions = [] - monkeypatch.setattr(ViewerGL, "set_model", lambda self, model: setattr(self, "model", model)) - monkeypatch.setattr( - viewer, - "register_ui_callback", - lambda _callback, *, position: registered_positions.append(position), - ) - - viewer.set_model(object()) - - assert registered_positions == ["side"] - - -def test_newton_viewer_controls_use_native_pause_and_step_state(): - viewer = NewtonViewerGL.__new__(NewtonViewerGL) - viewer._paused = False - viewer._step_requested = False - viewer._paused_rendering = False - viewer._update_frequency = 1 - imgui = SimpleNamespace( - checkbox=lambda *_args: (True, True), - same_line=lambda: None, - begin_disabled=lambda _disabled: None, - end_disabled=lambda: None, - button=lambda label: label == "Step", - text=lambda _text: None, - slider_int=lambda _label, value, *_args: (False, value), - is_item_hovered=lambda: False, - ) - - viewer._render_training_controls(imgui) - - assert viewer._paused is True - assert viewer._step_requested is True - assert viewer._paused_rendering is False - - -def test_newton_visualizer_close_neutralizes_forces_without_invalidating_graph(monkeypatch): - from isaaclab_newton.physics import NewtonManager - +def test_newton_visualizer_forwards_and_neutralizes_picking(): viewer = _Viewer() viewer.picking_enabled = True viewer.picking = SimpleNamespace(release=Mock()) - viewer.wind = SimpleNamespace(amplitude=3.0, update=Mock()) + viewer.apply_picking_force = Mock() visualizer = _make_newton_visualizer(viewer) - visualizer._state_force_callback_registered = True - graph = object() - callback = visualizer._viewer_force_binding.apply - monkeypatch.setattr(NewtonManager, "_graph", graph) - monkeypatch.setattr(NewtonManager, "_state_force_callbacks", [callback]) + visualizer._picking_enabled = True + callback = visualizer._viewer_picking_binding.apply + + state = object() + callback(state) + viewer.apply_picking_force.assert_called_once_with(state) visualizer.close() - assert NewtonManager._graph is graph - assert NewtonManager._state_force_callbacks == [callback] assert viewer.picking_enabled is False viewer.picking.release.assert_called_once_with() - assert viewer.wind.amplitude == 0.0 - viewer.wind.update.assert_called_once_with(0.0) assert visualizer._viewer is None - assert visualizer._viewer_force_binding._viewer is None - assert visualizer._viewer_force_binding._retained_force_helpers == (viewer.picking, viewer.wind) + assert visualizer._viewer_picking_binding._viewer is None + assert visualizer._viewer_picking_binding._retained_picking is viewer.picking - # Once the graph is gone, the next eager callback releases its retained helpers. - NewtonManager._graph = None callback(object()) - assert visualizer._viewer_force_binding._retained_force_helpers == () + assert visualizer._viewer_picking_binding._retained_picking is None def test_newton_visualizer_hard_reset_rebinds_viewer_model(monkeypatch): @@ -513,25 +460,27 @@ def test_newton_visualizer_hard_reset_rebinds_viewer_model(monkeypatch): viewer = _Viewer() viewer.picking_enabled = False viewer.set_model = Mock() + viewer._register_isaaclab_ui_callbacks = Mock() viewer.set_visible_worlds = Mock() viewer.set_world_offsets = Mock() visualizer = _make_newton_visualizer(viewer) visualizer._resolved_visible_env_ids = [1, 3] visualizer._picking_enabled = True - visualizer._state_force_callback_registered = True - visualizer._viewer_force_binding._retained_force_helpers = (object(),) visualizer.cfg.world_spacing = (2.0, 0.0, 0.0) + visualizer.cfg.show_contacts = True + visualizer.reset(soft=False) visualizer.reset(soft=False) assert visualizer._model is new_model assert visualizer._state is new_state viewer.set_model.assert_called_once_with(new_model) + viewer._register_isaaclab_ui_callbacks.assert_called_once_with() viewer.set_visible_worlds.assert_called_once_with([1, 3]) viewer.set_world_offsets.assert_called_once_with((2.0, 0.0, 0.0)) + assert viewer.show_contacts is True assert viewer.picking_enabled is True - assert visualizer._viewer_force_binding._viewer is viewer - assert visualizer._viewer_force_binding._retained_force_helpers == () + assert visualizer._viewer_picking_binding._viewer is viewer def test_newton_visualizer_logs_native_contacts_when_available(monkeypatch): diff --git a/source/isaaclab_visualizers/test/visualizer_integration_utils.py b/source/isaaclab_visualizers/test/visualizer_integration_utils.py index fc21e26fcd4e..78bd01560234 100644 --- a/source/isaaclab_visualizers/test/visualizer_integration_utils.py +++ b/source/isaaclab_visualizers/test/visualizer_integration_utils.py @@ -664,18 +664,6 @@ def separator(self): def text(self, _text): pass - def checkbox(self, label, value): - return (True, not value) if label == target_label else (False, value) - - def same_line(self): - pass - - def begin_disabled(self, _disabled): - pass - - def end_disabled(self): - pass - def button(self, label): return label == target_label @@ -691,11 +679,16 @@ def set_tooltip(self, _text): viewer._render_training_controls(_FakeImgui()) +def _select_newton_pause_simulation_button(viewer) -> None: + """Trigger the Newton visualizer's Pause/Resume Simulation UI button.""" + label = "Resume Simulation" if viewer.is_training_paused() else "Pause Simulation" + _select_newton_training_control_button(viewer, label) + + def _set_newton_simulation_paused(viewer, paused: bool) -> None: - """Put Newton's native simulation pause control into a desired state.""" - viewer._paused = paused - if not paused: - viewer._step_requested = False + """Put Newton visualizer simulation pause control into a desired state.""" + if viewer.is_training_paused() != paused: + _select_newton_pause_simulation_button(viewer) def _select_newton_pause_rendering_button(viewer) -> None: @@ -882,15 +875,6 @@ def _attempt_simulation_pause(): phase="pausing_simulation", ) - # Newton's native Step request authorizes exactly one SimulationContext - # physics tick while leaving the persistent pause state enabled. - physics_step_before_single_step = get_physics_step_count() - viewer._step_requested = True - env.sim.step() - assert get_physics_step_count() == physics_step_before_single_step + 1 - assert viewer.is_training_paused() - assert not viewer.should_step(), "Newton single-step request was not consumed exactly once." - simulation_play_start_idx = simulation_pause_end_idx simulation_play_end_idx = simulation_play_start_idx + PLAY_VIZ_N_STEP From 8084b4e5feeee550ca5f104dd7196f8038cbbd64 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 30 Jul 2026 16:31:21 -0700 Subject: [PATCH 04/22] Support Newton dragging across rigid solvers Apply viewer picking forces in both Newton state layouts and use an explicit rigid-body force-input capability to keep particle-only MPM excluded. --- .../overview/core-concepts/visualization.rst | 11 ++-- scripts/demos/newton_viewer_dragging.py | 4 +- .../isaaclab/sim/simulation_context.py | 12 ++--- .../test_simulation_context_visualizers.py | 6 +-- .../max-newton-viewer-dragging.minor.rst | 4 +- .../physics/featherstone_manager.py | 2 + .../isaaclab_newton/physics/kamino_manager.py | 2 + .../isaaclab_newton/physics/mjwarp_manager.py | 2 + .../isaaclab_newton/physics/newton_manager.py | 4 ++ .../isaaclab_newton/physics/xpbd_manager.py | 2 + .../test_newton_manager_abstraction.py | 54 ++++++++++++++++--- .../max-newton-viewer-dragging.minor.rst | 4 +- .../newton/newton_visualizer.py | 11 ++-- .../newton/newton_visualizer_cfg.py | 4 +- 14 files changed, 88 insertions(+), 34 deletions(-) diff --git a/docs/source/overview/core-concepts/visualization.rst b/docs/source/overview/core-concepts/visualization.rst index 585a5f202efc..644f813d1766 100644 --- a/docs/source/overview/core-concepts/visualization.rst +++ b/docs/source/overview/core-concepts/visualization.rst @@ -454,7 +454,7 @@ Newton Visualizer - Lightweight OpenGL rendering with low overhead - Simulation and rendering pause controls -- Right-click rigid-body dragging with the Newton MJWarp solver +- Right-click rigid-body dragging with Newton rigid-body solvers - Adjustable update frequency for performance tuning - Some customizable rendering options (shadows, sky, wireframe) - Visualization markers (joints, contacts, springs, COM, debug markers) @@ -518,7 +518,7 @@ Newton Visualizer show_contacts=False, # Show contact points and normals show_springs=False, # Show spring constraints show_com=False, # Show center of mass markers - enable_picking=True, # Enable MJWarp rigid-body dragging + enable_picking=True, # Enable Newton rigid-body dragging # Rendering options enable_shadows=True, # Enable shadow rendering @@ -533,9 +533,10 @@ Newton Visualizer .. note:: - Object dragging requires an interactive Newton visualizer with the Newton - MJWarp solver. Static and kinematic bodies are not moved, and picking is - disabled automatically in headless viewers and with other physics solvers. + Object dragging requires an interactive Newton visualizer with a Newton + rigid-body solver (MJWarp, XPBD, Featherstone, or Kamino). Static and + kinematic bodies are not moved. Picking is disabled automatically for + headless viewers, MPM, and non-Newton physics. Rerun Visualizer diff --git a/scripts/demos/newton_viewer_dragging.py b/scripts/demos/newton_viewer_dragging.py index 6eefe445896c..fd98745860c8 100644 --- a/scripts/demos/newton_viewer_dragging.py +++ b/scripts/demos/newton_viewer_dragging.py @@ -17,7 +17,7 @@ from isaaclab.app import add_launcher_args, launch_simulation -parser = argparse.ArgumentParser(description="Newton viewer dragging with three dynamic MJWarp cubes.") +parser = argparse.ArgumentParser(description="Three-cube Newton viewer dragging demo (MJWarp).") parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") add_launcher_args(parser) parser.set_defaults(visualizer=["newton"]) @@ -47,7 +47,7 @@ def cube_cfg(name: str, position: tuple[float, float, float]) -> RigidObjectCfg: @configclass class ViewerDraggingSceneCfg(InteractiveSceneCfg): - """Ground plane and three dynamic MJWarp cubes.""" + """Ground plane and three dynamic cubes.""" ground = AssetBaseCfg( prim_path="/World/Ground", diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 49217d440687..eb74aa1ae820 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -241,13 +241,13 @@ def __init__(self, cfg: SimulationCfg | None = None): PhysicsEvent.PHYSICS_READY, order=5, ) - is_newton_mjwarp = self.physics_manager.__name__ == "NewtonMJWarpManager" - if is_newton_mjwarp and any(self._is_interactive_newton_cfg(cfg) for cfg in self._get_visualizer_cfgs()): + supports_newton_picking = bool(getattr(self.physics_manager, "_supports_rigid_body_force_input", False)) + if supports_newton_picking and any(self._is_interactive_newton_cfg(cfg) for cfg in self._get_visualizer_cfgs()): self.physics_manager.register_callback( - self._prepare_newton_mjwarp_visualizer_for_capture, + self._prepare_newton_visualizer_for_capture, PhysicsEvent.PHYSICS_READY, order=30, - name="newton_mjwarp_visualizer_pre_capture", + name="newton_visualizer_pre_capture", ) self._services = ServiceLocator() @@ -680,8 +680,8 @@ def forward(self) -> None: """Update kinematics without stepping physics.""" self.physics_manager.forward() - def _prepare_newton_mjwarp_visualizer_for_capture(self, _payload=None) -> None: - """Initialize or rebind the Newton viewer before MJWarp graph capture.""" + def _prepare_newton_visualizer_for_capture(self, _payload=None) -> None: + """Initialize or rebind the Newton viewer before solver graph capture.""" if self._visualizers_fully_initialized and not self._visualizers: self._visualizer_cfg_cache = None self._initialized_visualizer_cfg_indices.clear() diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 1fc1dabe008f..2eba6b463b04 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -196,7 +196,7 @@ def test_update_visualizers_handles_training_pause_loop(): assert viz.step_calls == [0.0, 0.2] -def test_newton_mjwarp_visualizer_is_initialized_and_rebound_before_capture(): +def test_newton_visualizer_is_initialized_and_rebound_before_capture(): created = [] reset_calls = [] @@ -214,11 +214,11 @@ def create_visualizer(self): return viz ctx = _make_context_with_settings({}, visualizer_cfgs=[_Cfg("newton", True), _Cfg("rerun")]) - ctx._prepare_newton_mjwarp_visualizer_for_capture() + ctx._prepare_newton_visualizer_for_capture() assert created == ["newton"] ctx.initialize_visualizers() - ctx._prepare_newton_mjwarp_visualizer_for_capture() + ctx._prepare_newton_visualizer_for_capture() assert created == ["newton", "rerun"] assert len(ctx._visualizers) == 2 diff --git a/source/isaaclab_newton/changelog.d/max-newton-viewer-dragging.minor.rst b/source/isaaclab_newton/changelog.d/max-newton-viewer-dragging.minor.rst index b36dd69acfb0..5f7048b42adc 100644 --- a/source/isaaclab_newton/changelog.d/max-newton-viewer-dragging.minor.rst +++ b/source/isaaclab_newton/changelog.d/max-newton-viewer-dragging.minor.rst @@ -1,5 +1,5 @@ Added ^^^^^ -* Added support for applying Newton visualizer dragging forces during MJWarp - substeps. +* Added support for applying Newton visualizer dragging forces during + rigid-body solver substeps. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager.py index 99f86dc8b3be..5b536f3ed082 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager.py @@ -20,6 +20,8 @@ class NewtonFeatherstoneManager(NewtonManager): Always uses Newton's :class:`CollisionPipeline` for contact handling. """ + _supports_rigid_body_force_input = True + @classmethod def _create_solver(cls, model: Model, solver_cfg: FeatherstoneSolverCfg) -> SolverFeatherstone: """Construct the configured Featherstone solver.""" diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py index 257de444f048..74bc5ef2fee8 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py @@ -54,6 +54,8 @@ class NewtonKaminoManager(NewtonManager): Kamino's internal collision detector handles contact generation. """ + _supports_rigid_body_force_input = True + # Annotate the concrete solver type. _solver: SolverKamino diff --git a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py index 91e1cd2cc2f1..fe15431f88a2 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py @@ -31,6 +31,8 @@ class NewtonMJWarpManager(NewtonManager): :attr:`NewtonCfg.debug_mode` is enabled. """ + _supports_rigid_body_force_input = True + @classmethod def _create_solver(cls, model: Model, solver_cfg: MJWarpSolverCfg) -> SolverMuJoCo: """Construct the configured MuJoCo Warp solver.""" diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 621c2a89c4d6..dbaaf0b32b9b 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -292,6 +292,8 @@ def provides_implicit_damping(cls) -> bool: _decimation: int = 1 _collision_decimation: int = 0 _num_envs: int | None = None + _supports_rigid_body_force_input: bool = False + """Whether the solver consumes applied rigid-body forces from :class:`State`.""" # Newton model and state _builder: ModelBuilder = None @@ -2014,6 +2016,8 @@ def _run_solver_substeps(cls, contacts) -> None: cfg = PhysicsManager._cfg need_copy_on_last = cfg is not None and cls._num_substeps % 2 == 1 for i in range(cls._num_substeps): + for callback in cls._state_force_callbacks: + callback(cls._state_0) cls._step_solver(cls._state_0, cls._state_1, cls._control, contacts, cls._solver_dt) if need_copy_on_last and i == cls._num_substeps - 1: cls._state_0.assign(cls._state_1) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/xpbd_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/xpbd_manager.py index da90a977d566..eb68d94e4c0c 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/xpbd_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/xpbd_manager.py @@ -20,6 +20,8 @@ class NewtonXPBDManager(NewtonManager): Always uses Newton's :class:`CollisionPipeline` for contact handling. """ + _supports_rigid_body_force_input = True + @classmethod def _create_solver(cls, model: Model, solver_cfg: XPBDSolverCfg) -> SolverXPBD: """Construct the configured XPBD solver.""" diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 75e111366fcf..2cdc8d1a99ed 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -659,6 +659,21 @@ def test_subclass_of_newton_manager(manager): assert manager._create_solver is not NewtonManager._create_solver +@pytest.mark.parametrize( + ("manager", "expected"), + [ + (NewtonMJWarpManager, True), + (NewtonXPBDManager, True), + (NewtonFeatherstoneManager, True), + (NewtonKaminoManager, True), + (NewtonMPMManager, False), + ], +) +def test_manager_reports_rigid_body_force_input_support(manager, expected): + """Only rigid-body solvers opt into viewer force input.""" + assert manager._supports_rigid_body_force_input is expected + + def test_abstract_build_solver_raises(): """Calling :meth:`_build_solver` on the abstract base raises.""" with pytest.raises(NotImplementedError): @@ -880,33 +895,56 @@ def counting_collide(state, contacts): assert calls["n"] == 1 + expected_mid_loop_collides -def test_state_force_callback_runs_before_every_mjwarp_substep(monkeypatch): - """Viewer forces are applied before every in-place MJWarp solver substep.""" +@pytest.mark.parametrize("use_single_state", [True, False], ids=["single_state", "double_state"]) +def test_state_force_callback_runs_before_every_solver_substep(monkeypatch, use_single_state): + """Viewer forces are applied to each current input state before solver stepping.""" events = [] class _State: + def __init__(self, name): + self.name = name + def clear_forces(self): pass - state = _State() + state_0 = _State("state_0") + state_1 = _State("state_1") - monkeypatch.setattr(NewtonManager, "_state_0", state) + monkeypatch.setattr(NewtonManager, "_state_0", state_0) + monkeypatch.setattr(NewtonManager, "_state_1", state_1) monkeypatch.setattr(NewtonManager, "_control", object()) monkeypatch.setattr(NewtonManager, "_solver_dt", 0.001) monkeypatch.setattr(NewtonManager, "_num_substeps", 2) monkeypatch.setattr(NewtonManager, "_collision_decimation", 0) monkeypatch.setattr(NewtonManager, "_needs_collision_pipeline", False) - monkeypatch.setattr(NewtonManager, "_use_single_state", True) - monkeypatch.setattr(NewtonManager, "_state_force_callbacks", [lambda _state: events.append("force")]) + monkeypatch.setattr(NewtonManager, "_use_single_state", use_single_state) + monkeypatch.setattr( + NewtonManager, + "_state_force_callbacks", + [lambda state: events.append(("force", state.name))], + ) monkeypatch.setattr( NewtonManager, "_step_solver", - staticmethod(lambda *_args: events.append("step")), + staticmethod(lambda state_in, state_out, *_args: events.append(("step", state_in.name, state_out.name))), ) NewtonManager._run_solver_substeps(contacts=None) - assert events == ["force", "step", "force", "step"] + if use_single_state: + assert events == [ + ("force", "state_0"), + ("step", "state_0", "state_0"), + ("force", "state_0"), + ("step", "state_0", "state_0"), + ] + else: + assert events == [ + ("force", "state_0"), + ("step", "state_0", "state_1"), + ("force", "state_1"), + ("step", "state_1", "state_0"), + ] # --------------------------------------------------------------------------- diff --git a/source/isaaclab_visualizers/changelog.d/max-newton-viewer-dragging.minor.rst b/source/isaaclab_visualizers/changelog.d/max-newton-viewer-dragging.minor.rst index 24fc2135543a..e13d87627813 100644 --- a/source/isaaclab_visualizers/changelog.d/max-newton-viewer-dragging.minor.rst +++ b/source/isaaclab_visualizers/changelog.d/max-newton-viewer-dragging.minor.rst @@ -1,5 +1,5 @@ Added ^^^^^ -* Added right-click rigid-body dragging to the Newton visualizer with the - MJWarp solver. +* Added right-click rigid-body dragging to the Newton visualizer with Newton + rigid-body solvers. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 754c5022c2eb..89bbe0ae7094 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -663,7 +663,9 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: scene_data_provider = self._set_scene_data_provider(scene_data_provider) newton_backend_active = self.physics_backend == "newton" physics_manager = SimulationContext.instance().physics_manager - mjwarp_backend_active = newton_backend_active and physics_manager.__name__ == "NewtonMJWarpManager" + picking_supported = newton_backend_active and bool( + getattr(physics_manager, "_supports_rigid_body_force_input", False) + ) num_envs = scene_data_provider.num_envs metadata = {"num_envs": num_envs} self._env_ids = self._compute_visualized_env_ids() @@ -715,7 +717,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: self._viewer._paused = False self._apply_model_visualization_options() - self._picking_enabled = self.cfg.enable_picking and mjwarp_backend_active and not runtime_headless + self._picking_enabled = self.cfg.enable_picking and picking_supported and not runtime_headless self._viewer.picking_enabled = self._picking_enabled self._viewer.renderer.draw_shadows = self.cfg.enable_shadows @@ -753,9 +755,10 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: if self._viewer is not None and self._picking_enabled: self._viewer_picking_binding.bind(self._viewer) NewtonManager.register_state_force_callback(self._viewer_picking_binding.apply) - if self._viewer is not None and self.cfg.enable_picking and not mjwarp_backend_active: + if self._viewer is not None and self.cfg.enable_picking and not picking_supported: logger.info( - "[NewtonVisualizer] Object dragging is disabled because the active physics solver is not Newton MJWarp." + "[NewtonVisualizer] Object dragging is disabled because the active physics solver does not support" + " rigid-body force input." ) self._is_initialized = True diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py index 912d411e490e..84575714cc0d 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py @@ -62,9 +62,9 @@ class NewtonVisualizerCfg(VisualizerCfg): """ enable_picking: bool = True - """Enable right-click dragging with the Newton MJWarp solver. + """Enable right-click dragging with Newton rigid-body solvers. - Disabled automatically for headless viewers and other physics solvers. + Disabled automatically for headless viewers, MPM, and non-Newton physics. """ enable_shadows: bool = True From 0b2e94c25d94dfde116dff5b3d4215ef96fe0b16 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 30 Jul 2026 16:49:40 -0700 Subject: [PATCH 05/22] Simplify Newton viewer dragging bridge Use Newton's public force path and collapse visualizer initialization bookkeeping while preserving graph and reset safety. --- .../overview/core-concepts/visualization.rst | 2 +- .../isaaclab/sim/simulation_context.py | 49 +++++++------------ .../test_simulation_context_visualizers.py | 5 +- .../test_newton_manager_abstraction.py | 16 +----- .../newton/newton_visualizer.py | 16 +++--- .../test/test_newton_adapter.py | 9 ++-- 6 files changed, 32 insertions(+), 65 deletions(-) diff --git a/docs/source/overview/core-concepts/visualization.rst b/docs/source/overview/core-concepts/visualization.rst index 644f813d1766..93b2abf99813 100644 --- a/docs/source/overview/core-concepts/visualization.rst +++ b/docs/source/overview/core-concepts/visualization.rst @@ -476,7 +476,7 @@ Newton Visualizer * - **Left Click + Drag** - Look around * - **Right Click + Drag** - - Apply an interactive force to a dynamic rigid body (Newton MJWarp only) + - Apply an interactive force to a dynamic Newton rigid body * - **Mouse Scroll** - Zoom in/out * - **H** diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index eb74aa1ae820..0794aa6d3c37 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -195,9 +195,7 @@ def __init__(self, cfg: SimulationCfg | None = None): # Initialize visualizer state (visualizers are created lazily during initialize_visualizers()). self._scene_data_provider = SceneDataProvider(self.physics_manager.get_scene_data_backend()) self._visualizers: list[BaseVisualizer] = [] - self._visualizer_cfg_cache: list[Any] | None = None - self._initialized_visualizer_cfg_indices: set[int] = set() - self._visualizers_fully_initialized = False + self._pending_visualizer_cfgs: list[Any] | None = None self._reset_requested: bool = False self._scene_data_requirements = SceneDataRequirement() # Clone plan published by InteractiveScene after cloning. Providers (e.g. the @@ -547,23 +545,14 @@ def _resolve_visualizer_cfgs(self) -> list[Any]: def initialize_visualizers(self) -> None: """Initialize visualizers from ``SimulationCfg.visualizer_cfgs``.""" - if self._visualizers_fully_initialized: - if self._visualizers: - return - # Preserve the existing behavior of recreating configured visualizers - # after all previous instances have been closed. - self._visualizer_cfg_cache = None - self._initialized_visualizer_cfg_indices.clear() - self._visualizers_fully_initialized = False + if self._pending_visualizer_cfgs == [] or (self._pending_visualizer_cfgs is None and self._visualizers): + return visualizer_cfgs = self._get_visualizer_cfgs() if not visualizer_cfgs: - self._visualizers_fully_initialized = True return self._initialize_visualizers() - self._visualizers_fully_initialized = True - self._pending_camera_view = None if not self._visualizers and self._scene_data_provider is not None: close_provider = getattr(self._scene_data_provider, "close", None) @@ -572,10 +561,10 @@ def initialize_visualizers(self) -> None: self._scene_data_provider = None def _get_visualizer_cfgs(self) -> list[Any]: - """Resolve and cache visualizer configs for the current initialization cycle.""" - if self._visualizer_cfg_cache is None: - self._visualizer_cfg_cache = self._resolve_visualizer_cfgs() - return self._visualizer_cfg_cache + """Resolve visualizer configs for the current initialization cycle.""" + if self._pending_visualizer_cfgs is None: + self._pending_visualizer_cfgs = self._resolve_visualizer_cfgs() + return self._pending_visualizer_cfgs def _initialize_visualizers(self, config_filter: Callable[[Any], bool] | None = None) -> None: """Initialize pending visualizers, optionally restricted by config.""" @@ -589,19 +578,19 @@ def _initialize_visualizers(self, config_filter: Callable[[Any], bool] | None = cli_explicit = self._is_cli_visualizer_explicit() # Resolve visualizer-driven requirements once and keep optional artifact payload untouched. + all_visualizer_cfgs = [viz.cfg for viz in self._visualizers] + visualizer_cfgs visualizer_types = [ - cfg.visualizer_type for cfg in visualizer_cfgs if getattr(cfg, "visualizer_type", None) is not None + cfg.visualizer_type for cfg in all_visualizer_cfgs if getattr(cfg, "visualizer_type", None) is not None ] requirements = resolve_scene_data_requirements(visualizer_types=visualizer_types) self._scene_data_requirements = requirements + pending_cfgs = [] new_visualizers = [] - for index, cfg in enumerate(visualizer_cfgs): - if index in self._initialized_visualizer_cfg_indices: - continue + for cfg in visualizer_cfgs: if config_filter is not None and not config_filter(cfg): + pending_cfgs.append(cfg) continue - self._initialized_visualizer_cfg_indices.add(index) try: visualizer = cfg.create_visualizer() visualizer.initialize(self._scene_data_provider) @@ -619,6 +608,7 @@ def _initialize_visualizers(self, config_filter: Callable[[Any], bool] | None = type(cfg).__name__, exc, ) + self._pending_visualizer_cfgs = pending_cfgs # Replay any camera pose requested before visualizers were initialized. pending = getattr(self, "_pending_camera_view", None) @@ -626,6 +616,8 @@ def _initialize_visualizers(self, config_filter: Callable[[Any], bool] | None = eye, target = pending for viz in new_visualizers: viz.set_camera_view(eye, target) + if not pending_cfgs: + self._pending_camera_view = None def get_scene_data_provider(self) -> SceneDataProvider: return self._scene_data_provider @@ -682,10 +674,6 @@ def forward(self) -> None: def _prepare_newton_visualizer_for_capture(self, _payload=None) -> None: """Initialize or rebind the Newton viewer before solver graph capture.""" - if self._visualizers_fully_initialized and not self._visualizers: - self._visualizer_cfg_cache = None - self._initialized_visualizer_cfg_indices.clear() - self._visualizers_fully_initialized = False self._initialize_visualizers(self._is_interactive_newton_cfg) for viz in (viz for viz in self._visualizers if self._is_interactive_newton_cfg(viz.cfg)): viz.reset(soft=False) @@ -708,9 +696,8 @@ def reset(self, soft: bool = False) -> None: self.physics_manager.reset(soft) for viz in self._visualizers: viz.reset(soft) - if not self._visualizers_fully_initialized or not self._visualizers: - # Initialize visualizers not prepared by a backend-specific pre-capture hook. - self.initialize_visualizers() + # Initialize visualizers not prepared by a backend-specific pre-capture hook. + self.initialize_visualizers() # Start the timeline so the play button is pressed self.physics_manager.play() self._is_playing = True @@ -826,6 +813,8 @@ def update_visualizers(self, dt: float, skip_app_pumping: bool = False) -> None: logger.info("Removed visualizer: %s", type(viz).__name__) except Exception as exc: logger.error("Error closing visualizer: %s", exc) + if visualizers_to_remove and not self._visualizers: + self._pending_visualizer_cfgs = None def _should_forward_before_visualizer_update(self) -> bool: """Return True if any visualizer requires pre-step forward kinematics.""" diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 2eba6b463b04..035a6702670d 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -230,7 +230,6 @@ def test_reset_initializes_visualizers_before_playing_timeline(): events: list[str] = [] ctx = object.__new__(SimulationContext) ctx._visualizers = [] - ctx._visualizers_fully_initialized = False class _PhysicsManager: @staticmethod @@ -720,9 +719,7 @@ def _make_context_with_settings( ctx._pending_camera_view = None ctx._render_generation = 0 ctx._visualizers = [] - ctx._visualizer_cfg_cache = None - ctx._initialized_visualizer_cfg_indices = set() - ctx._visualizers_fully_initialized = False + ctx._pending_visualizer_cfgs = None ctx._scene_data_provider = _FakeProvider() ctx._scene_data_requirements = None ctx._clone_plan = None diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 2cdc8d1a99ed..fb8f8d006637 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -657,21 +657,7 @@ def test_subclass_of_newton_manager(manager): # Subclasses must override the abstract factory. assert manager._build_solver is not NewtonManager._build_solver assert manager._create_solver is not NewtonManager._create_solver - - -@pytest.mark.parametrize( - ("manager", "expected"), - [ - (NewtonMJWarpManager, True), - (NewtonXPBDManager, True), - (NewtonFeatherstoneManager, True), - (NewtonKaminoManager, True), - (NewtonMPMManager, False), - ], -) -def test_manager_reports_rigid_body_force_input_support(manager, expected): - """Only rigid-body solvers opt into viewer force input.""" - assert manager._supports_rigid_body_force_input is expected + assert manager._supports_rigid_body_force_input is (manager is not NewtonMPMManager) def test_abstract_build_solver_raises(): diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 89bbe0ae7094..4dc9110b4280 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -137,13 +137,6 @@ def _register_isaaclab_ui_callbacks(self) -> None: except AttributeError: self._fallback_draw_controls = True - def apply_picking_force(self, state: State) -> None: - """Apply only the viewer's rigid-body picking force.""" - if self.picking_enabled and self.picking is not None: - # Newton currently exposes picking only through apply_forces(), - # which also applies wind. Keep this integration dragging-only. - self.picking._apply_picking_force(state) - def _patch_scalar_plot_width(self) -> None: """Set up ImPlot and suppress Newton's built-in floating Plots window. @@ -604,7 +597,7 @@ def apply(self, state: State) -> None: # this branch means captured inputs are no longer needed. self._retained_picking = None return - self._viewer.apply_picking_force(state) + self._viewer.apply_forces(state) def deactivate(self) -> None: """Make captured picking inert while preserving its inputs.""" @@ -696,6 +689,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: pyglet.options["headless"] = True + self._picking_enabled = self.cfg.enable_picking and picking_supported and not runtime_headless self._viewer = NewtonViewerGL( width=self.cfg.window_width, height=self.cfg.window_height, @@ -706,6 +700,9 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: if self._viewer is not None: self._viewer.set_model(self._model) + if self._picking_enabled: + # Keep Newton's public force path scoped to picking for this integration. + self._viewer.wind = None self._viewer.set_visible_worlds(self._resolved_visible_env_ids) self._viewer.set_world_offsets(self.cfg.world_spacing) self._apply_camera_focal_length() @@ -717,7 +714,6 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: self._viewer._paused = False self._apply_model_visualization_options() - self._picking_enabled = self.cfg.enable_picking and picking_supported and not runtime_headless self._viewer.picking_enabled = self._picking_enabled self._viewer.renderer.draw_shadows = self.cfg.enable_shadows @@ -845,6 +841,8 @@ def reset(self, soft: bool = False) -> None: self._state = NewtonManager.get_state_0() if self._viewer is not None: self._viewer.set_model(self._model) + if self._picking_enabled: + self._viewer.wind = None self._viewer._register_isaaclab_ui_callbacks() self._viewer.set_visible_worlds(self._resolved_visible_env_ids) self._viewer.set_world_offsets(self.cfg.world_spacing) diff --git a/source/isaaclab_visualizers/test/test_newton_adapter.py b/source/isaaclab_visualizers/test/test_newton_adapter.py index 0693f72bac3d..7093c4eee4c8 100644 --- a/source/isaaclab_visualizers/test/test_newton_adapter.py +++ b/source/isaaclab_visualizers/test/test_newton_adapter.py @@ -116,8 +116,6 @@ def test_newton_visualizer_cfg_exposes_viewer_options(): assert cfg.show_particles is True assert cfg.particle_color == (0.1, 0.2, 0.3) - assert cfg.enable_picking is True - assert NewtonVisualizerCfg(enable_picking=False).enable_picking is False def test_newton_marker_registry_lifecycle(monkeypatch: pytest.MonkeyPatch): @@ -426,14 +424,14 @@ def test_newton_visualizer_forwards_and_neutralizes_picking(): viewer = _Viewer() viewer.picking_enabled = True viewer.picking = SimpleNamespace(release=Mock()) - viewer.apply_picking_force = Mock() + viewer.apply_forces = Mock() visualizer = _make_newton_visualizer(viewer) visualizer._picking_enabled = True callback = visualizer._viewer_picking_binding.apply state = object() callback(state) - viewer.apply_picking_force.assert_called_once_with(state) + viewer.apply_forces.assert_called_once_with(state) visualizer.close() @@ -450,8 +448,6 @@ def test_newton_visualizer_forwards_and_neutralizes_picking(): def test_newton_visualizer_hard_reset_rebinds_viewer_model(monkeypatch): from isaaclab_newton.physics import NewtonManager - monkeypatch.setattr(NewtonVisualizer, "physics_backend", property(lambda _self: "newton")) - new_model = object() new_state = object() monkeypatch.setattr(NewtonManager, "get_model", lambda: new_model) @@ -480,6 +476,7 @@ def test_newton_visualizer_hard_reset_rebinds_viewer_model(monkeypatch): viewer.set_world_offsets.assert_called_once_with((2.0, 0.0, 0.0)) assert viewer.show_contacts is True assert viewer.picking_enabled is True + assert viewer.wind is None assert visualizer._viewer_picking_binding._viewer is viewer From 7c475e67ba390f05f1faa94d3a316f79d1dd59d8 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 30 Jul 2026 18:37:04 -0700 Subject: [PATCH 06/22] feat: support coupled Newton viewer dragging --- .../overview/core-concepts/visualization.rst | 7 +- .../demos/mpm/newton_mpm_twoway_coupling.py | 223 ++++++++++++++++++ .../max-newton-viewer-dragging.minor.rst | 3 +- .../test/app/standalone_script_cases.py | 6 + .../max-newton-viewer-dragging.minor.rst | 5 + .../isaaclab_contrib/coupling/coupler.py | 2 + .../test/coupling/test_coupler.py | 5 + .../newton/newton_visualizer_cfg.py | 4 +- 8 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 scripts/demos/mpm/newton_mpm_twoway_coupling.py create mode 100644 source/isaaclab_contrib/changelog.d/max-newton-viewer-dragging.minor.rst diff --git a/docs/source/overview/core-concepts/visualization.rst b/docs/source/overview/core-concepts/visualization.rst index 93b2abf99813..7503681686b9 100644 --- a/docs/source/overview/core-concepts/visualization.rst +++ b/docs/source/overview/core-concepts/visualization.rst @@ -534,9 +534,10 @@ Newton Visualizer .. note:: Object dragging requires an interactive Newton visualizer with a Newton - rigid-body solver (MJWarp, XPBD, Featherstone, or Kamino). Static and - kinematic bodies are not moved. Picking is disabled automatically for - headless viewers, MPM, and non-Newton physics. + rigid-body solver (MJWarp, XPBD, Featherstone, or Kamino), either standalone + or in a supported coupled solver with a rigid-body entry. Static and + kinematic bodies and MPM particles are not moved. Picking is disabled + automatically for headless viewers, standalone MPM, and non-Newton physics. Rerun Visualizer diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py new file mode 100644 index 000000000000..9c1a98e41e74 --- /dev/null +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -0,0 +1,223 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Drag rigid boxes coupled to Newton implicit-MPM sand. + +This Isaac Lab port of Newton's ``mpm_twoway_coupling`` example uses a proxy +coupler to expose dynamic rigid boxes as MPM colliders and feed the resulting +impulses back into the rigid-body solver. + +.. code-block:: bash + + uv run python scripts/demos/mpm/newton_mpm_twoway_coupling.py + +Right-click and drag a box to apply an interactive force. Use ``Space`` to +pause or resume the simulation and ``.`` to advance one step while paused. +""" + +from __future__ import annotations + +import argparse + +from isaaclab.app import add_launcher_args, launch_simulation + +parser = argparse.ArgumentParser(description="Newton rigid-box and MPM-sand two-way coupling demo.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many frames; negative runs forever.") +parser.add_argument("--voxel_size", type=float, default=0.05, help="MPM grid voxel size [m].") +parser.add_argument("--rigid_substeps", type=int, default=4, help="Rigid-solver substeps per coupled step.") +add_launcher_args(parser) +parser.set_defaults(visualizer=["newton"]) +args_cli = parser.parse_args() + + +FPS = 100.0 +GRAVITY = (0.0, 0.0, -9.81) +PARTICLES_PER_CELL = 3.0 +PARTICLE_COLOR = (0.7, 0.6, 0.4) + +BOX_BODY_PATTERN = r"/World/envs/env_.*/Box_[0-9]+" +BOX_HALF_EXTENTS = ( + (0.25, 0.35, 0.25), + (0.25, 0.25, 0.25), + (0.30, 0.20, 0.20), + (0.25, 0.35, 0.25), + (0.25, 0.25, 0.25), + (0.30, 0.20, 0.20), +) +# Match Newton's reference scene: 75 kg body mass plus the shape's +# default-density contribution. +BOX_MASSES = (250.0, 200.0, 171.0, 250.0, 200.0, 171.0) +BOX_OFFSETS_XY = ( + (0.00, 0.00), + (0.10, 0.00), + (-0.10, 0.00), + (0.00, 0.10), + (0.00, -0.10), + (0.10, 0.10), +) + + +def create_visualizer_cfgs(): + """Create the demo-specific Newton visualizer configuration.""" + if "newton" not in (args_cli.visualizer or []): + return [] + + from isaaclab_visualizers.newton import NewtonVisualizerCfg + + return [ + NewtonVisualizerCfg( + show_particles=True, + particle_color=PARTICLE_COLOR, + update_frequency=1, + ) + ] + + +def create_sim_cfg(): + """Create the proxy-coupled MJWarp and MPM simulation configuration.""" + from isaaclab_newton.physics import MJWarpSolverCfg, MPMSolverCfg, NewtonCfg + + import isaaclab.sim as sim_utils + from isaaclab_contrib.coupling import CouplerEntryCfg, CouplerProxyCfg, CouplerProxyMappingCfg + + solver_cfg = CouplerProxyCfg( + entries=[ + CouplerEntryCfg( + name="rigid", + solver_cfg=MJWarpSolverCfg(use_mujoco_contacts=False, njmax=128), + bodies=[BOX_BODY_PATTERN], + include_static_shapes=True, + substeps=args_cli.rigid_substeps, + ), + CouplerEntryCfg( + name="mpm", + solver_cfg=MPMSolverCfg( + voxel_size=args_cli.voxel_size, + grid_type="fixed", + grid_padding=50, + max_active_cell_count=1 << 15, + strain_basis="P0", + max_iterations=50, + critical_fraction=0.0, + ), + all_particles=True, + in_place=True, + ), + ], + proxies=[ + CouplerProxyMappingCfg( + source="rigid", + destination="mpm", + bodies=[BOX_BODY_PATTERN], + mode="lagged", + collision_pipeline=None, + ) + ], + iterations=1, + ) + return sim_utils.SimulationCfg( + dt=1.0 / FPS, + device=args_cli.device, + gravity=GRAVITY, + visualizer_cfgs=create_visualizer_cfgs(), + physics=NewtonCfg(solver_cfg=solver_cfg), + ) + + +def create_scene_cfg(): + """Create the declarative rigid-box and granular-bed scene.""" + from isaaclab_newton.assets.mpm_object import MPMObjectCfg + from isaaclab_newton.sim.spawners.mpm import MPMGridCfg, MPMParticleMaterialCfg + + import isaaclab.sim as sim_utils + from isaaclab.assets import AssetBaseCfg, RigidObjectCfg, RigidObjectCollectionCfg + from isaaclab.scene import InteractiveSceneCfg + from isaaclab.utils.configclass import configclass + + rigid_objects = {} + for index, (half_extents, mass, offset_xy) in enumerate( + zip(BOX_HALF_EXTENTS, BOX_MASSES, BOX_OFFSETS_XY, strict=True) + ): + rigid_objects[f"box_{index}"] = RigidObjectCfg( + prim_path=f"{{ENV_REGEX_NS}}/Box_{index}", + spawn=sim_utils.CuboidCfg( + size=tuple(2.0 * extent for extent in half_extents), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(mass=mass), + collision_props=sim_utils.NewtonCollisionPropertiesCfg(contact_gap=0.1), + physics_material=sim_utils.NewtonMaterialPropertiesCfg( + static_friction=0.5, + dynamic_friction=0.5, + ), + ), + init_state=RigidObjectCfg.InitialStateCfg( + pos=(offset_xy[0], offset_xy[1], 2.0 + 0.6 * index), + ), + ) + + @configclass + class CoupledSceneCfg(InteractiveSceneCfg): + """Scene containing dynamic rigid boxes and one Newton MPM object.""" + + ground = AssetBaseCfg( + prim_path="/World/Ground", + spawn=sim_utils.GroundPlaneCfg(size=(6.0, 6.0), color=(0.30, 0.30, 0.30)), + ) + + boxes = RigidObjectCollectionCfg(rigid_objects=rigid_objects) + + sand = MPMObjectCfg( + prim_path="{ENV_REGEX_NS}/Sand", + spawn=MPMGridCfg( + lower=(-1.0, -1.0, 0.0), + upper=(1.0, 1.0, 0.5), + voxel_size=args_cli.voxel_size, + particles_per_cell=PARTICLES_PER_CELL, + jitter=args_cli.voxel_size / PARTICLES_PER_CELL, + material=MPMParticleMaterialCfg(density=2500.0, friction=0.75, yield_pressure=1.0e15), + visual_color=PARTICLE_COLOR, + ), + ) + + return CoupledSceneCfg(num_envs=1, env_spacing=0.0) + + +def run_simulator(sim, scene) -> None: + """Run until the viewer closes or the optional step limit is reached.""" + sim_dt = sim.get_physics_dt() + step_count = 0 + while sim.is_headless_or_exist_active_visualizer() and ( + args_cli.max_steps < 0 or step_count < args_cli.max_steps + ): + sim.step(render=False) + scene.update(sim_dt) + if sim.is_rendering: + sim.render() + step_count += 1 + + +def main() -> None: + """Launch the two-way rigid-MPM coupling demo.""" + sim_cfg = create_sim_cfg() + with launch_simulation(sim_cfg, args_cli): + import isaaclab.sim as sim_utils + from isaaclab.scene import InteractiveScene + + sim = sim_utils.SimulationContext(sim_cfg) + sim.set_camera_view(eye=(3.0, -4.0, 2.5), target=(0.0, 0.0, 0.8)) + scene = InteractiveScene(create_scene_cfg()) + sim.reset() + sand = scene["sand"] + particle_count = sand.num_instances * sand.particles_per_object + print( + f"[INFO]: Isaac Lab Newton two-way MPM demo ready. Spawned {particle_count} particles.", + flush=True, + ) + print("[INFO]: Right-click and drag a box in the Newton viewer.", flush=True) + run_simulator(sim, scene) + + +if __name__ == "__main__": + main() diff --git a/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst b/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst index a31d62d33910..95e34c1cf236 100644 --- a/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst +++ b/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst @@ -1,4 +1,5 @@ Added ^^^^^ -* Added a three-cube Newton MJWarp demo for interactive rigid-body dragging. +* Added three-cube MJWarp and coupled rigid-box/MPM Newton demos for + interactive rigid-body dragging. diff --git a/source/isaaclab/test/app/standalone_script_cases.py b/source/isaaclab/test/app/standalone_script_cases.py index 49cd537271b6..dbc835e88b06 100644 --- a/source/isaaclab/test/app/standalone_script_cases.py +++ b/source/isaaclab/test/app/standalone_script_cases.py @@ -159,6 +159,12 @@ class SmokeResult: readiness_pattern=r"Newton granular MPM demo ready", fixed_physics_backend="newton_mpm", ), + "scripts/demos/mpm/newton_mpm_twoway_coupling.py": ScriptOverride( + args=("--max_steps", "2", "--voxel_size", "0.2"), + readiness_pattern=r"Newton two-way MPM demo ready", + fixed_physics_backend="newton_coupler", + visualizers=("newton",), + ), "scripts/demos/mpm/particle_pour.py": ScriptOverride( args=("--max-steps", "200"), readiness_pattern=r"particle-pour MPM demo ready", diff --git a/source/isaaclab_contrib/changelog.d/max-newton-viewer-dragging.minor.rst b/source/isaaclab_contrib/changelog.d/max-newton-viewer-dragging.minor.rst new file mode 100644 index 000000000000..7d02ee57cbb3 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/max-newton-viewer-dragging.minor.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added Newton visualizer rigid-body dragging support to + :class:`~isaaclab_contrib.coupling.NewtonCouplerManager`. diff --git a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py index 7d39367e5b31..93c83ce72318 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py +++ b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py @@ -38,6 +38,8 @@ class NewtonCouplerManager(NewtonVBDManager): """Couple named Newton solver entries through proxy or ADMM interfaces.""" + _supports_rigid_body_force_input = True + @dataclass class _ResolvedEntry: """Entry configuration with model selectors resolved to indices.""" diff --git a/source/isaaclab_contrib/test/coupling/test_coupler.py b/source/isaaclab_contrib/test/coupling/test_coupler.py index cf4a3a10873f..a9fabb7c5e3f 100644 --- a/source/isaaclab_contrib/test/coupling/test_coupler.py +++ b/source/isaaclab_contrib/test/coupling/test_coupler.py @@ -61,6 +61,11 @@ def test_public_coupler_config_resolves_renamed_class(): assert CouplerCfg().class_type.__name__ == "NewtonCouplerManager" +def test_coupler_supports_rigid_body_force_input(): + """The coupler exposes parent-state rigid forces to viewer integrations.""" + assert NewtonCouplerManager._supports_rigid_body_force_input is True + + def test_public_coupling_exports_are_importable(): """The lazy-export stub must not retain deleted public symbols.""" import isaaclab_contrib.coupling as coupling diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py index 84575714cc0d..2ebb7ea899bf 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py @@ -64,7 +64,9 @@ class NewtonVisualizerCfg(VisualizerCfg): enable_picking: bool = True """Enable right-click dragging with Newton rigid-body solvers. - Disabled automatically for headless viewers, MPM, and non-Newton physics. + Supported coupled solvers may expose dragging through a rigid-body entry. + Disabled automatically for headless viewers, standalone MPM, and non-Newton + physics. MPM particles are not pickable. """ enable_shadows: bool = True From 76256a3b2b18ac7e7a330d2f0663d233fa20f865 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 30 Jul 2026 18:37:51 -0700 Subject: [PATCH 07/22] style: format coupled MPM demo --- scripts/demos/mpm/newton_mpm_twoway_coupling.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py index 9c1a98e41e74..2a8d8c833235 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -80,6 +80,7 @@ def create_sim_cfg(): from isaaclab_newton.physics import MJWarpSolverCfg, MPMSolverCfg, NewtonCfg import isaaclab.sim as sim_utils + from isaaclab_contrib.coupling import CouplerEntryCfg, CouplerProxyCfg, CouplerProxyMappingCfg solver_cfg = CouplerProxyCfg( @@ -188,9 +189,7 @@ def run_simulator(sim, scene) -> None: """Run until the viewer closes or the optional step limit is reached.""" sim_dt = sim.get_physics_dt() step_count = 0 - while sim.is_headless_or_exist_active_visualizer() and ( - args_cli.max_steps < 0 or step_count < args_cli.max_steps - ): + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): sim.step(render=False) scene.update(sim_dt) if sim.is_rendering: From 9eb9d3cbecd6a602db8ee517c20b7a61ec5820ba Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 30 Jul 2026 18:47:43 -0700 Subject: [PATCH 08/22] refactor: lighten coupled MPM demo --- scripts/demos/mpm/newton_mpm_twoway_coupling.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py index 2a8d8c833235..21b20bd1d17d 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Drag rigid boxes coupled to Newton implicit-MPM sand. +"""Drag three rigid boxes coupled to Newton implicit-MPM sand. This Isaac Lab port of Newton's ``mpm_twoway_coupling`` example uses a proxy coupler to expose dynamic rigid boxes as MPM colliders and feed the resulting @@ -25,7 +25,7 @@ parser = argparse.ArgumentParser(description="Newton rigid-box and MPM-sand two-way coupling demo.") parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many frames; negative runs forever.") -parser.add_argument("--voxel_size", type=float, default=0.05, help="MPM grid voxel size [m].") +parser.add_argument("--voxel_size", type=float, default=0.075, help="MPM grid voxel size [m].") parser.add_argument("--rigid_substeps", type=int, default=4, help="Rigid-solver substeps per coupled step.") add_launcher_args(parser) parser.set_defaults(visualizer=["newton"]) @@ -42,20 +42,14 @@ (0.25, 0.35, 0.25), (0.25, 0.25, 0.25), (0.30, 0.20, 0.20), - (0.25, 0.35, 0.25), - (0.25, 0.25, 0.25), - (0.30, 0.20, 0.20), ) # Match Newton's reference scene: 75 kg body mass plus the shape's # default-density contribution. -BOX_MASSES = (250.0, 200.0, 171.0, 250.0, 200.0, 171.0) +BOX_MASSES = (250.0, 200.0, 171.0) BOX_OFFSETS_XY = ( (0.00, 0.00), (0.10, 0.00), (-0.10, 0.00), - (0.00, 0.10), - (0.00, -0.10), - (0.10, 0.10), ) From 2f92792109361f13b33798f5ed1325e1ac9a9690 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sat, 1 Aug 2026 10:53:30 -0700 Subject: [PATCH 09/22] refactor: canonicalize Newton force capability --- .../isaaclab/sim/simulation_context.py | 9 --- .../isaaclab_contrib/coupling/coupler.py | 3 +- .../coupled_featherstone_vbd_manager.py | 1 + .../deformable/coupled_mjwarp_vbd_manager.py | 1 + .../deformable/vbd_manager.py | 1 + .../test/coupling/test_coupler.py | 11 ++-- .../test/coupling/test_coupler_runtime.py | 1 + .../physics/featherstone_manager.py | 3 +- .../isaaclab_newton/physics/kamino_manager.py | 3 +- .../isaaclab_newton/physics/mjwarp_manager.py | 3 +- .../isaaclab_newton/physics/mpm_manager.py | 1 + .../isaaclab_newton/physics/newton_manager.py | 13 ++++- .../isaaclab_newton/physics/xpbd_manager.py | 3 +- .../test_newton_manager_abstraction.py | 55 ++++++++++++++++++- 14 files changed, 82 insertions(+), 26 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 0794aa6d3c37..d336e594e6fb 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -239,15 +239,6 @@ def __init__(self, cfg: SimulationCfg | None = None): PhysicsEvent.PHYSICS_READY, order=5, ) - supports_newton_picking = bool(getattr(self.physics_manager, "_supports_rigid_body_force_input", False)) - if supports_newton_picking and any(self._is_interactive_newton_cfg(cfg) for cfg in self._get_visualizer_cfgs()): - self.physics_manager.register_callback( - self._prepare_newton_visualizer_for_capture, - PhysicsEvent.PHYSICS_READY, - order=30, - name="newton_visualizer_pre_capture", - ) - self._services = ServiceLocator() type(self)._instance = self # Mark as valid singleton only after successful init diff --git a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py index 93c83ce72318..f8948d6b0868 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py +++ b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py @@ -38,8 +38,6 @@ class NewtonCouplerManager(NewtonVBDManager): """Couple named Newton solver entries through proxy or ADMM interfaces.""" - _supports_rigid_body_force_input = True - @dataclass class _ResolvedEntry: """Entry configuration with model selectors resolved to indices.""" @@ -114,6 +112,7 @@ def _build_solver(cls, model: Model, solver_cfg: CouplerCfg) -> None: NewtonManager._use_single_state = False NewtonManager._supports_contact_sensors = False NewtonManager._needs_collision_pipeline = needs_collision_pipeline + NewtonManager._supports_rigid_body_force_input = True @classmethod def _validate_config(cls, solver_cfg: CouplerCfg) -> None: 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 d754168eb746..dbe8b4c7dd35 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 @@ -73,6 +73,7 @@ def _build_solver(cls, model: Model, solver_cfg: CoupledFeatherstoneVBDSolverCfg NewtonManager._use_single_state = False NewtonManager._needs_collision_pipeline = True + NewtonManager._supports_rigid_body_force_input = False if solver_cfg.coupling_mode == "kinematic": cls._gravity_zero = wp.zeros(1, dtype=wp.vec3) 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 33f3e8741c5b..82f9a7d5e9eb 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 @@ -70,6 +70,7 @@ def _build_solver(cls, model: Model, solver_cfg: CoupledMJWarpVBDSolverCfg) -> N NewtonManager._use_single_state = False NewtonManager._needs_collision_pipeline = True + NewtonManager._supports_rigid_body_force_input = False @classmethod def _step_solver( diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py index 02569d4906a2..35286d07dba1 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -249,6 +249,7 @@ def _build_solver(cls, model: Model, solver_cfg: VBDSolverCfg) -> None: NewtonManager._solver = cls._create_solver(model, solver_cfg) NewtonManager._use_single_state = False NewtonManager._needs_collision_pipeline = True + NewtonManager._supports_rigid_body_force_input = False @classmethod def _simulate_physics_only(cls) -> None: diff --git a/source/isaaclab_contrib/test/coupling/test_coupler.py b/source/isaaclab_contrib/test/coupling/test_coupler.py index a9fabb7c5e3f..7e1459aa9bde 100644 --- a/source/isaaclab_contrib/test/coupling/test_coupler.py +++ b/source/isaaclab_contrib/test/coupling/test_coupler.py @@ -61,11 +61,6 @@ def test_public_coupler_config_resolves_renamed_class(): assert CouplerCfg().class_type.__name__ == "NewtonCouplerManager" -def test_coupler_supports_rigid_body_force_input(): - """The coupler exposes parent-state rigid forces to viewer integrations.""" - assert NewtonCouplerManager._supports_rigid_body_force_input is True - - def test_public_coupling_exports_are_importable(): """The lazy-export stub must not retain deleted public symbols.""" import isaaclab_contrib.coupling as coupling @@ -606,6 +601,7 @@ def test_proxy_selects_expected_outer_collision_pipeline(monkeypatch, case, expe "_use_single_state", "_needs_collision_pipeline", "_supports_contact_sensors", + "_supports_rigid_body_force_input", "_report_contacts", ): monkeypatch.setattr(coupler.NewtonManager, attribute, getattr(coupler.NewtonManager, attribute)) @@ -649,6 +645,7 @@ def test_proxy_selects_expected_outer_collision_pipeline(monkeypatch, case, expe assert coupler.NewtonManager._needs_collision_pipeline is expected_outer assert coupler.NewtonManager._supports_contact_sensors is False + assert coupler.NewtonManager._supports_rigid_body_force_input is True assert recorded_entries == ["rigid", "soft"] @@ -661,6 +658,7 @@ def test_admm_always_requests_outer_collision_pipeline(monkeypatch): "_use_single_state", "_needs_collision_pipeline", "_supports_contact_sensors", + "_supports_rigid_body_force_input", "_report_contacts", ): monkeypatch.setattr(coupler.NewtonManager, attribute, getattr(coupler.NewtonManager, attribute)) @@ -687,6 +685,7 @@ def test_admm_always_requests_outer_collision_pipeline(monkeypatch): NewtonCouplerManager._build_solver(model, cfg) assert coupler.NewtonManager._needs_collision_pipeline is True + assert coupler.NewtonManager._supports_rigid_body_force_input is True def test_contact_sensor_guard_does_not_mutate_manager_state(monkeypatch): @@ -695,6 +694,7 @@ def test_contact_sensor_guard_does_not_mutate_manager_state(monkeypatch): monkeypatch.setattr(coupler.NewtonManager, "_use_single_state", True) monkeypatch.setattr(coupler.NewtonManager, "_needs_collision_pipeline", True) monkeypatch.setattr(coupler.NewtonManager, "_supports_contact_sensors", True) + monkeypatch.setattr(coupler.NewtonManager, "_supports_rigid_body_force_input", False) monkeypatch.setattr(coupler.NewtonManager, "_report_contacts", True) with pytest.raises(NotImplementedError, match="contact sensors"): @@ -704,6 +704,7 @@ def test_contact_sensor_guard_does_not_mutate_manager_state(monkeypatch): assert coupler.NewtonManager._use_single_state is True assert coupler.NewtonManager._needs_collision_pipeline is True assert coupler.NewtonManager._supports_contact_sensors is True + assert coupler.NewtonManager._supports_rigid_body_force_input is False class _RecordingAdmm: diff --git a/source/isaaclab_contrib/test/coupling/test_coupler_runtime.py b/source/isaaclab_contrib/test/coupling/test_coupler_runtime.py index 816cc037c00c..8bb0627b15f1 100644 --- a/source/isaaclab_contrib/test/coupling/test_coupler_runtime.py +++ b/source/isaaclab_contrib/test/coupling/test_coupler_runtime.py @@ -38,6 +38,7 @@ def isolated_newton_manager(monkeypatch: pytest.MonkeyPatch): "_collision_cfg": None, "_needs_collision_pipeline": False, "_supports_contact_sensors": True, + "_supports_rigid_body_force_input": False, "_report_contacts": False, } for name, value in clean_values.items(): diff --git a/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager.py index 5b536f3ed082..1f8f04eff319 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager.py @@ -20,8 +20,6 @@ class NewtonFeatherstoneManager(NewtonManager): Always uses Newton's :class:`CollisionPipeline` for contact handling. """ - _supports_rigid_body_force_input = True - @classmethod def _create_solver(cls, model: Model, solver_cfg: FeatherstoneSolverCfg) -> SolverFeatherstone: """Construct the configured Featherstone solver.""" @@ -37,3 +35,4 @@ def _build_solver(cls, model: Model, solver_cfg: FeatherstoneSolverCfg) -> None: NewtonManager._solver = cls._create_solver(model, solver_cfg) NewtonManager._use_single_state = False NewtonManager._needs_collision_pipeline = True + NewtonManager._supports_rigid_body_force_input = True diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py index 74bc5ef2fee8..51eb1bfb2d9d 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py @@ -54,8 +54,6 @@ class NewtonKaminoManager(NewtonManager): Kamino's internal collision detector handles contact generation. """ - _supports_rigid_body_force_input = True - # Annotate the concrete solver type. _solver: SolverKamino @@ -158,3 +156,4 @@ def _build_solver(cls, model: Model, solver_cfg: KaminoSolverCfg) -> None: NewtonManager._solver = cls._create_solver(model, solver_cfg) NewtonManager._use_single_state = False NewtonManager._needs_collision_pipeline = not solver_cfg.use_collision_detector + NewtonManager._supports_rigid_body_force_input = True diff --git a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py index fe15431f88a2..0dbed37ed039 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py @@ -31,8 +31,6 @@ class NewtonMJWarpManager(NewtonManager): :attr:`NewtonCfg.debug_mode` is enabled. """ - _supports_rigid_body_force_input = True - @classmethod def _create_solver(cls, model: Model, solver_cfg: MJWarpSolverCfg) -> SolverMuJoCo: """Construct the configured MuJoCo Warp solver.""" @@ -54,6 +52,7 @@ def _build_solver(cls, model: Model, solver_cfg: MJWarpSolverCfg) -> None: NewtonManager._solver = cls._create_solver(model, solver_cfg) NewtonManager._use_single_state = True NewtonManager._needs_collision_pipeline = not solver_cfg.use_mujoco_contacts + NewtonManager._supports_rigid_body_force_input = True cfg = PhysicsManager._cfg # Cross-config validation that needs both halves. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/mpm_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/mpm_manager.py index aba2aa85107c..4cc6f6798270 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/mpm_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/mpm_manager.py @@ -117,6 +117,7 @@ def _build_solver(cls, model: Model, solver_cfg: MPMSolverCfg) -> None: NewtonManager._solver = cls._create_solver(model, solver_cfg) NewtonManager._use_single_state = True NewtonManager._needs_collision_pipeline = False + NewtonManager._supports_rigid_body_force_input = False cls._project_outside_colliders = solver_cfg.project_outside_colliders @classmethod diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index dbaaf0b32b9b..83b054f21375 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -909,6 +909,7 @@ def clear(cls): NewtonManager._model = None NewtonManager._solver = None NewtonManager._use_single_state = None + NewtonManager._supports_rigid_body_force_input = False NewtonManager._state_0 = None NewtonManager._state_1 = None NewtonManager._control = None @@ -1650,6 +1651,9 @@ def _build_solver(cls, model: Model, solver_cfg) -> None: manager owns Newton's :class:`CollisionPipeline` for contact generation; ``False`` if the solver runs internal collision detection (MuJoCo internal contacts, Kamino with its own detector). + * :attr:`NewtonManager._supports_rigid_body_force_input` — ``True`` if + the solver consumes external rigid-body forces from + :attr:`State.body_f`; ``False`` otherwise. Writing through ``NewtonManager._foo`` (rather than ``cls._foo``) keeps the canonical state visible to external readers regardless of @@ -1753,10 +1757,17 @@ def initialize_solver(cls) -> None: raise RuntimeError( f"{cls.__name__}._build_solver did not assign NewtonManager._solver. " "Subclasses of NewtonManager must populate NewtonManager._solver, " - "NewtonManager._use_single_state, and NewtonManager._needs_collision_pipeline." + "NewtonManager._use_single_state, NewtonManager._needs_collision_pipeline, and " + "NewtonManager._supports_rigid_body_force_input." ) cls._initialize_contacts() + # Picking callbacks must be registered after the concrete solver has + # published its force-input capability, but before CUDA graph capture. + sim = PhysicsManager._sim + if NewtonManager._supports_rigid_body_force_input and sim is not None: + sim._prepare_newton_visualizer_for_capture() + # Bind the solver-specialized FK delegate to the active subclass's _eval_fk_impl so # that forward()/step() dispatch correctly even when forward() is invoked through the # base class (the data layer imports NewtonManager directly). ``cls`` is the concrete diff --git a/source/isaaclab_newton/isaaclab_newton/physics/xpbd_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/xpbd_manager.py index eb68d94e4c0c..d38d098a853a 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/xpbd_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/xpbd_manager.py @@ -20,8 +20,6 @@ class NewtonXPBDManager(NewtonManager): Always uses Newton's :class:`CollisionPipeline` for contact handling. """ - _supports_rigid_body_force_input = True - @classmethod def _create_solver(cls, model: Model, solver_cfg: XPBDSolverCfg) -> SolverXPBD: """Construct the configured XPBD solver.""" @@ -37,3 +35,4 @@ def _build_solver(cls, model: Model, solver_cfg: XPBDSolverCfg) -> None: NewtonManager._solver = cls._create_solver(model, solver_cfg) NewtonManager._use_single_state = False NewtonManager._needs_collision_pipeline = True + NewtonManager._supports_rigid_body_force_input = True diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index fb8f8d006637..1a83351fb052 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -117,6 +117,14 @@ ), ] +RIGID_BODY_FORCE_INPUT_SUPPORT = { + NewtonMJWarpManager: True, + NewtonXPBDManager: True, + NewtonFeatherstoneManager: True, + NewtonKaminoManager: True, + NewtonMPMManager: False, +} + # --------------------------------------------------------------------------- # class_type wiring (no SimulationContext required) @@ -657,7 +665,49 @@ def test_subclass_of_newton_manager(manager): # Subclasses must override the abstract factory. assert manager._build_solver is not NewtonManager._build_solver assert manager._create_solver is not NewtonManager._create_solver - assert manager._supports_rigid_body_force_input is (manager is not NewtonMPMManager) + + +def test_clear_resets_rigid_body_force_capability(monkeypatch): + """Teardown clears the canonical solver capability without subclass shadowing.""" + monkeypatch.setattr(NewtonManager, "_supports_rigid_body_force_input", True) + + NewtonManager.clear() + + assert NewtonManager._supports_rigid_body_force_input is False + for manager in ( + NewtonMJWarpManager, + NewtonXPBDManager, + NewtonFeatherstoneManager, + NewtonKaminoManager, + NewtonMPMManager, + ): + assert manager._supports_rigid_body_force_input is False + + +def test_initialize_solver_prepares_picking_before_graph_capture(monkeypatch): + """Viewer force callbacks are registered after capability publication and before capture.""" + events: list[str] = [] + sim_cfg = SimulationCfg( + dt=1.0 / 120.0, + device="cuda:0", + physics=NewtonCfg(solver_cfg=MJWarpSolverCfg(), use_cuda_graph=False), + ) + + with build_simulation_context(sim_cfg=sim_cfg) as sim: + builder = sim.physics_manager.create_builder() + body = builder.add_body(mass=1.0) + builder.add_joint_revolute(parent=-1, child=body, axis=(0, 0, 1)) + NewtonManager.set_builder(builder) + monkeypatch.setattr(sim, "_prepare_newton_visualizer_for_capture", lambda: events.append("prepare")) + monkeypatch.setattr( + NewtonMJWarpManager, + "_capture_or_defer_graph", + classmethod(lambda cls: events.append("capture")), + ) + + sim.reset() + + assert events == ["prepare", "capture"] def test_abstract_build_solver_raises(): @@ -763,6 +813,8 @@ def test_initialize_solver_populates_canonical_state( NewtonManager.set_builder(builder) # Force resolution and bring up the solver. + expected_supports_force_input = RIGID_BODY_FORCE_INPUT_SUPPORT[expected_manager] + NewtonManager._supports_rigid_body_force_input = not expected_supports_force_input sim.reset() # Canonical state lives on the base class. @@ -770,6 +822,7 @@ def test_initialize_solver_populates_canonical_state( assert isinstance(NewtonManager._solver, expected_solver_cls) assert NewtonManager._use_single_state is expected_use_single_state assert NewtonManager._needs_collision_pipeline is expected_needs_collision_pipeline + assert NewtonManager._supports_rigid_body_force_input is expected_supports_force_input assert NewtonManager._reset_solver_internals_delegate.__self__ is expected_manager assert ( NewtonManager._reset_solver_internals_delegate.__func__ is expected_manager._reset_solver_internals.__func__ From fb7e7a06d4ff33c8f061eee8e5df2bfe6fff1d5a Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 6 Aug 2026 11:39:41 -0700 Subject: [PATCH 10/22] refactor: remove coupled MPM viewer demo --- .../demos/mpm/newton_mpm_twoway_coupling.py | 216 ------------------ .../max-newton-viewer-dragging.minor.rst | 3 +- .../test/app/standalone_script_cases.py | 6 - 3 files changed, 1 insertion(+), 224 deletions(-) delete mode 100644 scripts/demos/mpm/newton_mpm_twoway_coupling.py diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py deleted file mode 100644 index 21b20bd1d17d..000000000000 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ /dev/null @@ -1,216 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Drag three rigid boxes coupled to Newton implicit-MPM sand. - -This Isaac Lab port of Newton's ``mpm_twoway_coupling`` example uses a proxy -coupler to expose dynamic rigid boxes as MPM colliders and feed the resulting -impulses back into the rigid-body solver. - -.. code-block:: bash - - uv run python scripts/demos/mpm/newton_mpm_twoway_coupling.py - -Right-click and drag a box to apply an interactive force. Use ``Space`` to -pause or resume the simulation and ``.`` to advance one step while paused. -""" - -from __future__ import annotations - -import argparse - -from isaaclab.app import add_launcher_args, launch_simulation - -parser = argparse.ArgumentParser(description="Newton rigid-box and MPM-sand two-way coupling demo.") -parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many frames; negative runs forever.") -parser.add_argument("--voxel_size", type=float, default=0.075, help="MPM grid voxel size [m].") -parser.add_argument("--rigid_substeps", type=int, default=4, help="Rigid-solver substeps per coupled step.") -add_launcher_args(parser) -parser.set_defaults(visualizer=["newton"]) -args_cli = parser.parse_args() - - -FPS = 100.0 -GRAVITY = (0.0, 0.0, -9.81) -PARTICLES_PER_CELL = 3.0 -PARTICLE_COLOR = (0.7, 0.6, 0.4) - -BOX_BODY_PATTERN = r"/World/envs/env_.*/Box_[0-9]+" -BOX_HALF_EXTENTS = ( - (0.25, 0.35, 0.25), - (0.25, 0.25, 0.25), - (0.30, 0.20, 0.20), -) -# Match Newton's reference scene: 75 kg body mass plus the shape's -# default-density contribution. -BOX_MASSES = (250.0, 200.0, 171.0) -BOX_OFFSETS_XY = ( - (0.00, 0.00), - (0.10, 0.00), - (-0.10, 0.00), -) - - -def create_visualizer_cfgs(): - """Create the demo-specific Newton visualizer configuration.""" - if "newton" not in (args_cli.visualizer or []): - return [] - - from isaaclab_visualizers.newton import NewtonVisualizerCfg - - return [ - NewtonVisualizerCfg( - show_particles=True, - particle_color=PARTICLE_COLOR, - update_frequency=1, - ) - ] - - -def create_sim_cfg(): - """Create the proxy-coupled MJWarp and MPM simulation configuration.""" - from isaaclab_newton.physics import MJWarpSolverCfg, MPMSolverCfg, NewtonCfg - - import isaaclab.sim as sim_utils - - from isaaclab_contrib.coupling import CouplerEntryCfg, CouplerProxyCfg, CouplerProxyMappingCfg - - solver_cfg = CouplerProxyCfg( - entries=[ - CouplerEntryCfg( - name="rigid", - solver_cfg=MJWarpSolverCfg(use_mujoco_contacts=False, njmax=128), - bodies=[BOX_BODY_PATTERN], - include_static_shapes=True, - substeps=args_cli.rigid_substeps, - ), - CouplerEntryCfg( - name="mpm", - solver_cfg=MPMSolverCfg( - voxel_size=args_cli.voxel_size, - grid_type="fixed", - grid_padding=50, - max_active_cell_count=1 << 15, - strain_basis="P0", - max_iterations=50, - critical_fraction=0.0, - ), - all_particles=True, - in_place=True, - ), - ], - proxies=[ - CouplerProxyMappingCfg( - source="rigid", - destination="mpm", - bodies=[BOX_BODY_PATTERN], - mode="lagged", - collision_pipeline=None, - ) - ], - iterations=1, - ) - return sim_utils.SimulationCfg( - dt=1.0 / FPS, - device=args_cli.device, - gravity=GRAVITY, - visualizer_cfgs=create_visualizer_cfgs(), - physics=NewtonCfg(solver_cfg=solver_cfg), - ) - - -def create_scene_cfg(): - """Create the declarative rigid-box and granular-bed scene.""" - from isaaclab_newton.assets.mpm_object import MPMObjectCfg - from isaaclab_newton.sim.spawners.mpm import MPMGridCfg, MPMParticleMaterialCfg - - import isaaclab.sim as sim_utils - from isaaclab.assets import AssetBaseCfg, RigidObjectCfg, RigidObjectCollectionCfg - from isaaclab.scene import InteractiveSceneCfg - from isaaclab.utils.configclass import configclass - - rigid_objects = {} - for index, (half_extents, mass, offset_xy) in enumerate( - zip(BOX_HALF_EXTENTS, BOX_MASSES, BOX_OFFSETS_XY, strict=True) - ): - rigid_objects[f"box_{index}"] = RigidObjectCfg( - prim_path=f"{{ENV_REGEX_NS}}/Box_{index}", - spawn=sim_utils.CuboidCfg( - size=tuple(2.0 * extent for extent in half_extents), - rigid_props=sim_utils.RigidBodyPropertiesCfg(), - mass_props=sim_utils.MassPropertiesCfg(mass=mass), - collision_props=sim_utils.NewtonCollisionPropertiesCfg(contact_gap=0.1), - physics_material=sim_utils.NewtonMaterialPropertiesCfg( - static_friction=0.5, - dynamic_friction=0.5, - ), - ), - init_state=RigidObjectCfg.InitialStateCfg( - pos=(offset_xy[0], offset_xy[1], 2.0 + 0.6 * index), - ), - ) - - @configclass - class CoupledSceneCfg(InteractiveSceneCfg): - """Scene containing dynamic rigid boxes and one Newton MPM object.""" - - ground = AssetBaseCfg( - prim_path="/World/Ground", - spawn=sim_utils.GroundPlaneCfg(size=(6.0, 6.0), color=(0.30, 0.30, 0.30)), - ) - - boxes = RigidObjectCollectionCfg(rigid_objects=rigid_objects) - - sand = MPMObjectCfg( - prim_path="{ENV_REGEX_NS}/Sand", - spawn=MPMGridCfg( - lower=(-1.0, -1.0, 0.0), - upper=(1.0, 1.0, 0.5), - voxel_size=args_cli.voxel_size, - particles_per_cell=PARTICLES_PER_CELL, - jitter=args_cli.voxel_size / PARTICLES_PER_CELL, - material=MPMParticleMaterialCfg(density=2500.0, friction=0.75, yield_pressure=1.0e15), - visual_color=PARTICLE_COLOR, - ), - ) - - return CoupledSceneCfg(num_envs=1, env_spacing=0.0) - - -def run_simulator(sim, scene) -> None: - """Run until the viewer closes or the optional step limit is reached.""" - sim_dt = sim.get_physics_dt() - step_count = 0 - while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): - sim.step(render=False) - scene.update(sim_dt) - if sim.is_rendering: - sim.render() - step_count += 1 - - -def main() -> None: - """Launch the two-way rigid-MPM coupling demo.""" - sim_cfg = create_sim_cfg() - with launch_simulation(sim_cfg, args_cli): - import isaaclab.sim as sim_utils - from isaaclab.scene import InteractiveScene - - sim = sim_utils.SimulationContext(sim_cfg) - sim.set_camera_view(eye=(3.0, -4.0, 2.5), target=(0.0, 0.0, 0.8)) - scene = InteractiveScene(create_scene_cfg()) - sim.reset() - sand = scene["sand"] - particle_count = sand.num_instances * sand.particles_per_object - print( - f"[INFO]: Isaac Lab Newton two-way MPM demo ready. Spawned {particle_count} particles.", - flush=True, - ) - print("[INFO]: Right-click and drag a box in the Newton viewer.", flush=True) - run_simulator(sim, scene) - - -if __name__ == "__main__": - main() diff --git a/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst b/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst index 95e34c1cf236..52199a68066b 100644 --- a/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst +++ b/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst @@ -1,5 +1,4 @@ Added ^^^^^ -* Added three-cube MJWarp and coupled rigid-box/MPM Newton demos for - interactive rigid-body dragging. +* Added a three-cube MJWarp Newton demo for interactive rigid-body dragging. diff --git a/source/isaaclab/test/app/standalone_script_cases.py b/source/isaaclab/test/app/standalone_script_cases.py index dbc835e88b06..49cd537271b6 100644 --- a/source/isaaclab/test/app/standalone_script_cases.py +++ b/source/isaaclab/test/app/standalone_script_cases.py @@ -159,12 +159,6 @@ class SmokeResult: readiness_pattern=r"Newton granular MPM demo ready", fixed_physics_backend="newton_mpm", ), - "scripts/demos/mpm/newton_mpm_twoway_coupling.py": ScriptOverride( - args=("--max_steps", "2", "--voxel_size", "0.2"), - readiness_pattern=r"Newton two-way MPM demo ready", - fixed_physics_backend="newton_coupler", - visualizers=("newton",), - ), "scripts/demos/mpm/particle_pour.py": ScriptOverride( args=("--max-steps", "200"), readiness_pattern=r"particle-pour MPM demo ready", From 5e593678e8bb9b8f5f75adc566468bad6859dfee Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 6 Aug 2026 20:55:08 -0700 Subject: [PATCH 11/22] Add focused Newton dragging demos Replace the generic cube example with solver-specific XPBD domino and VBD block-and-tackle demos. Add the coupled rigid-MPM three-box example and the single-world MPM reset handling it requires. --- .../overview/core-concepts/visualization.rst | 2 +- .../demos/assets/nvidia_logo_domino_poses.pth | Bin 0 -> 159648 bytes .../demos/mpm/newton_mpm_twoway_coupling.py | 216 ++++++++++++ .../demos/newton_viewer_block_and_tackle.py | 323 ++++++++++++++++++ scripts/demos/newton_viewer_dominoes.py | 163 +++++++++ scripts/demos/newton_viewer_dragging.py | 86 ----- .../max-newton-viewer-dragging.minor.rst | 2 +- .../test/app/standalone_script_cases.py | 17 +- .../max-newton-viewer-dragging.minor.rst | 7 +- .../isaaclab_contrib/coupling/coupler.py | 16 + .../deformable/newton_manager_cfg.py | 6 + .../deformable/vbd_manager.py | 2 +- .../test/coupling/test_coupler.py | 65 ++++ .../test_deformable_builder_hooks.py | 17 + 14 files changed, 830 insertions(+), 92 deletions(-) create mode 100644 scripts/demos/assets/nvidia_logo_domino_poses.pth create mode 100644 scripts/demos/mpm/newton_mpm_twoway_coupling.py create mode 100644 scripts/demos/newton_viewer_block_and_tackle.py create mode 100644 scripts/demos/newton_viewer_dominoes.py delete mode 100644 scripts/demos/newton_viewer_dragging.py diff --git a/docs/source/overview/core-concepts/visualization.rst b/docs/source/overview/core-concepts/visualization.rst index 5214f4d9f325..e5799175a662 100644 --- a/docs/source/overview/core-concepts/visualization.rst +++ b/docs/source/overview/core-concepts/visualization.rst @@ -455,7 +455,7 @@ Newton Visualizer .. note:: Object dragging requires an interactive Newton visualizer with a Newton - rigid-body solver (MJWarp, XPBD, Featherstone, or Kamino), either standalone + rigid-body solver (MJWarp, XPBD, VBD, Featherstone, or Kamino), either standalone or in a supported coupled solver with a rigid-body entry. Static and kinematic bodies and MPM particles are not moved. Picking is disabled automatically for headless viewers, standalone MPM, and non-Newton physics. diff --git a/scripts/demos/assets/nvidia_logo_domino_poses.pth b/scripts/demos/assets/nvidia_logo_domino_poses.pth new file mode 100644 index 0000000000000000000000000000000000000000..7973b6fe11458e9df5f22c24a4366192a5726fbb GIT binary patch literal 159648 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 Pvx0)3fgK1z>LF?Y6~Q None: + """Run until the viewer closes or the optional step limit is reached.""" + sim_dt = sim.get_physics_dt() + step_count = 0 + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): + sim.step(render=False) + scene.update(sim_dt) + if sim.is_rendering: + sim.render() + step_count += 1 + + +def main() -> None: + """Launch the two-way rigid-MPM coupling demo.""" + sim_cfg = create_sim_cfg() + with launch_simulation(sim_cfg, args_cli): + import isaaclab.sim as sim_utils + from isaaclab.scene import InteractiveScene + + sim = sim_utils.SimulationContext(sim_cfg) + sim.set_camera_view(eye=(3.0, -4.0, 2.5), target=(0.0, 0.0, 0.8)) + scene = InteractiveScene(create_scene_cfg()) + sim.reset() + sand = scene["sand"] + particle_count = sand.num_instances * sand.particles_per_object + print( + f"[INFO]: Isaac Lab Newton two-way MPM demo ready. Spawned {particle_count} particles.", + flush=True, + ) + print("[INFO]: Right-click and drag a box in the Newton viewer.", flush=True) + run_simulator(sim, scene) + + +if __name__ == "__main__": + main() diff --git a/scripts/demos/newton_viewer_block_and_tackle.py b/scripts/demos/newton_viewer_block_and_tackle.py new file mode 100644 index 000000000000..542f15000399 --- /dev/null +++ b/scripts/demos/newton_viewer_block_and_tackle.py @@ -0,0 +1,323 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Drag a cable handle to lift a load through a single 4:1 pulley system. + +.. code-block:: bash + + uv run python scripts/demos/newton_viewer_block_and_tackle.py +""" + +import argparse +import math + +from isaaclab.app import add_launcher_args, launch_simulation + +parser = argparse.ArgumentParser(description="Newton block-and-tackle viewer dragging demo.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") +add_launcher_args(parser) +parser.set_defaults(visualizer=["newton"]) +args_cli = parser.parse_args() + +import newton +import newton.utils +import warp as wp +from isaaclab_newton.physics import NewtonCfg, NewtonManager, NewtonShapeCfg + +import isaaclab.sim as sim_utils + +from isaaclab_contrib.deformable import VBDSolverCfg + +MECHANICAL_ADVANTAGE = 4 +LOAD_MASS = 5.0 +HANDLE_MASS = 0.5 # Compensated in the load mass; raises Newton's picking-force limit. + +CABLE_RADIUS = 0.004 +CABLE_SEGMENT_LENGTH = 0.02 +CABLE_GAP = 0.5 * CABLE_RADIUS +PULLEY_RADIUS = 0.045 +WRAP_RADIUS = PULLEY_RADIUS + 1.25 * CABLE_RADIUS +SHEAVE_SPACING = 5.5 * CABLE_RADIUS + +BLOCK_X = 0.50 +MOVING_Z = 0.72 +FIXED_Z = 1.10 +LEAD_X = BLOCK_X + 3.0 * WRAP_RADIUS +PULL_X = LEAD_X + WRAP_RADIUS +LOAD_CENTER = wp.vec3(BLOCK_X, 0.0, 0.49) +HANDLE_HALF_EXTENTS = (0.025, 0.025, 0.03) + + +def _append_arc(points: list[wp.vec3], center: wp.vec3, start: float, end: float) -> None: + """Append a clockwise pulley arc in the vertical XZ plane.""" + delta = (end - start + math.pi) % (2.0 * math.pi) - math.pi + if delta > 0.0: + delta -= 2.0 * math.pi + count = max(3, math.ceil(abs(delta) * WRAP_RADIUS / CABLE_SEGMENT_LENGTH)) + for index in range(count + 1): + angle = start + delta * index / count + point = wp.vec3( + float(center[0]) + WRAP_RADIUS * math.cos(angle), + float(center[1]), + float(center[2]) + WRAP_RADIUS * math.sin(angle), + ) + if float(wp.length(point - points[-1])) > 1.0e-8: + points.append(point) + + +def _resample_route(points: list[wp.vec3]) -> tuple[list[wp.vec3], float]: + """Resample a route into equal-length cable segments.""" + lengths = [0.0] + for start, end in zip(points, points[1:]): + lengths.append(lengths[-1] + float(wp.length(end - start))) + segment_count = math.ceil(lengths[-1] / CABLE_SEGMENT_LENGTH) + spacing = lengths[-1] / segment_count + result = [points[0]] + point_index = 1 + for segment_index in range(1, segment_count): + distance = spacing * segment_index + while lengths[point_index] < distance: + point_index += 1 + alpha = (distance - lengths[point_index - 1]) / (lengths[point_index] - lengths[point_index - 1]) + result.append(points[point_index - 1] * (1.0 - alpha) + points[point_index] * alpha) + result.append(points[-1]) + return result, spacing + + +def _cable_route( + moving_centers: list[wp.vec3], fixed_centers: list[wp.vec3], lead_center: wp.vec3 +) -> tuple[list[wp.vec3], float]: + """Route one cable through the moving and fixed pulley blocks.""" + points = [ + wp.vec3( + float(moving_centers[0][0]) + WRAP_RADIUS, + float(moving_centers[0][1]), + float(fixed_centers[0][2]) - 2.0 * WRAP_RADIUS, + ) + ] + for index, (moving_center, fixed_center) in enumerate(zip(moving_centers, fixed_centers, strict=True)): + _append_arc(points, moving_center, 0.0, -math.pi) + _append_arc(points, fixed_center, math.pi, 0.0 if index == 0 else 0.5 * math.pi) + _append_arc(points, lead_center, 0.5 * math.pi, 0.0) + points.append(wp.vec3(PULL_X, float(lead_center[1]), 0.50)) + return _resample_route(points) + + +def _add_box( + builder: newton.ModelBuilder, + body: int, + center: wp.vec3, + half_extents: tuple[float, float, float], + color: tuple[float, float, float], + *, + density: float = 0.0, + collision: bool = False, +) -> None: + """Add a compact visual or colliding box.""" + builder.add_shape_box( + body=body, + xform=wp.transform(center, wp.quat_identity()), + hx=half_extents[0], + hy=half_extents[1], + hz=half_extents[2], + cfg=newton.ModelBuilder.ShapeConfig( + density=density, + ke=1.0e5, + kd=20.0, + mu=0.8, + has_shape_collision=collision, + has_particle_collision=collision, + ), + color=color, + ) + + +def _add_pulley( + builder: newton.ModelBuilder, + center: wp.vec3, + parent: int, + parent_origin: wp.vec3, + color: tuple[float, float, float], +) -> int: + """Add one passive grooved sheave and return its revolute joint.""" + body = builder.add_link(xform=wp.transform(center, wp.quat_identity())) + joint = builder.add_joint_revolute( + parent=parent, + child=body, + axis=wp.vec3(0.0, 1.0, 0.0), + parent_xform=wp.transform(center - parent_origin, wp.quat_identity()), + armature=1.0e-4, + friction=0.0, + ) + align_to_y = wp.quat_from_axis_angle(wp.vec3(1.0, 0.0, 0.0), -0.5 * math.pi) + groove_half_width = 1.55 * CABLE_RADIUS + flange_half_width = 0.6 * CABLE_RADIUS + for y, radius, half_width, shade, friction in ( + (0.0, PULLEY_RADIUS, groove_half_width, color, 0.1), + ( + -(groove_half_width + flange_half_width), + PULLEY_RADIUS + 3.2 * CABLE_RADIUS, + flange_half_width, + tuple(0.68 * value for value in color), + 0.05, + ), + ( + groove_half_width + flange_half_width, + PULLEY_RADIUS + 3.2 * CABLE_RADIUS, + flange_half_width, + tuple(0.68 * value for value in color), + 0.05, + ), + ): + builder.add_shape_cylinder( + body=body, + xform=wp.transform(wp.vec3(0.0, y, 0.0), align_to_y), + radius=radius, + half_height=half_width, + cfg=newton.ModelBuilder.ShapeConfig(density=0.1, ke=1.0e5, kd=0.0, mu=friction), + color=shade, + ) + return joint + + +def _build_system(builder: newton.ModelBuilder) -> tuple[int, list[wp.transform]]: + """Build one complete 4:1 block-and-tackle system.""" + sheave_y = (-0.5 * SHEAVE_SPACING, 0.5 * SHEAVE_SPACING) + moving_centers = [wp.vec3(BLOCK_X, y, MOVING_Z) for y in sheave_y] + fixed_centers = [wp.vec3(BLOCK_X, y, FIXED_Z) for y in sheave_y] + lead_center = wp.vec3(LEAD_X, sheave_y[-1], FIXED_Z) + + _add_box(builder, -1, wp.vec3(0.5 * (BLOCK_X + PULL_X), 0.0, 1.19), (0.18, 0.07, 0.025), (0.16, 0.22, 0.30)) + _add_box(builder, -1, wp.vec3(BLOCK_X, 0.0, 1.145), (0.025, 0.065, 0.045), (0.16, 0.22, 0.30)) + _add_box(builder, -1, wp.vec3(LEAD_X, sheave_y[-1], 1.145), (0.025, 0.025, 0.045), (0.16, 0.22, 0.30)) + _add_box(builder, -1, wp.vec3(BLOCK_X, 0.0, 0.39), (0.14, 0.12, 0.01), (0.24, 0.27, 0.30), collision=True) + + load_body = builder.add_link(xform=wp.transform(LOAD_CENTER, wp.quat_identity()), label="load") + load_half_extents = (0.085, 0.08, 0.09) + load_volume = 8.0 * math.prod(load_half_extents) + _add_box( + builder, + load_body, + wp.vec3(0.0), + load_half_extents, + (0.72, 0.16, 0.12), + density=(LOAD_MASS + HANDLE_MASS * MECHANICAL_ADVANTAGE) / load_volume, + collision=True, + ) + _add_box(builder, load_body, wp.vec3(0.0, 0.0, 0.16), (0.025, 0.065, 0.07), (0.50, 0.18, 0.12)) + load_joint = builder.add_joint_prismatic( + parent=-1, + child=load_body, + axis=wp.vec3(0.0, 0.0, 1.0), + parent_xform=wp.transform(LOAD_CENTER, wp.quat_identity()), + target_kd=20.0, + ) + + moving_joints = [ + _add_pulley(builder, center, load_body, LOAD_CENTER, (0.88, 0.54, 0.12)) for center in moving_centers + ] + fixed_joints = [_add_pulley(builder, center, -1, wp.vec3(0.0), (0.12, 0.38, 0.78)) for center in fixed_centers] + lead_joint = _add_pulley(builder, lead_center, -1, wp.vec3(0.0), (0.12, 0.38, 0.78)) + builder.add_articulation([load_joint, *moving_joints], label="moving_block") + for joint in [*fixed_joints, lead_joint]: + builder.add_articulation([joint]) + + route, segment_length = _cable_route(moving_centers, fixed_centers, lead_center) + route_quaternions = newton.utils.create_parallel_transport_cable_quaternions(route) + cable_segment_count = len(route) - 1 + straight_points, straight_quaternions = newton.utils.create_straight_cable_points_and_quaternions( + start=route[0], + direction=wp.vec3(1.0, 0.0, 0.0), + length=cable_segment_count * segment_length, + num_segments=cable_segment_count, + ) + cable_bodies, cable_joints = builder.add_rod( + positions=straight_points, + quaternions=straight_quaternions, + radius=CABLE_RADIUS, + body_frame_origin="com", + cfg=newton.ModelBuilder.ShapeConfig(density=10.0, ke=1.0e5, kd=0.0, mu=0.1, gap=CABLE_GAP), + wrap_in_articulation=False, + color=(0.82, 0.72, 0.46), + label="block_and_tackle_cable", + ) + endpoint = 0.5 * segment_length + anchor_joint = builder.add_joint_ball( + parent=-1, + child=cable_bodies[0], + parent_xform=wp.transform(route[0], wp.quat_identity()), + child_xform=wp.transform(wp.vec3(0.0, 0.0, -endpoint), wp.quat_identity()), + label="cable_anchor", + ) + handle_volume = 8.0 * math.prod(HANDLE_HALF_EXTENTS) + _add_box( + builder, + cable_bodies[-1], + wp.vec3(0.0, 0.0, endpoint + HANDLE_HALF_EXTENTS[2]), + HANDLE_HALF_EXTENTS, + (0.96, 0.78, 0.26), + density=HANDLE_MASS / handle_volume, + collision=True, + ) + builder.add_articulation([*cable_joints, anchor_joint], label="cable") + + for index, body_a in enumerate(cable_bodies): + for body_b in cable_bodies[index + 1 :]: + for shape_a in builder.body_shapes[body_a]: + for shape_b in builder.body_shapes[body_b]: + builder.add_shape_collision_filter_pair(shape_a, shape_b) + + wrapped_xforms = [ + wp.transform(route[index] + 0.5 * (route[index + 1] - route[index]), route_quaternions[index]) + for index in range(cable_segment_count) + ] + return cable_bodies[0], wrapped_xforms + + +def _initialize_wrapped_cable(cable_body_start: int, wrapped_xforms: list[wp.transform]) -> None: + """Restore model defaults and wrap the structurally straight cable.""" + model = NewtonManager.get_model() + wrapped = wp.array(wrapped_xforms, dtype=wp.transform, device=model.device) + for state in (NewtonManager.get_state_0(), NewtonManager.get_state_1()): + wp.copy(state.body_q, model.body_q) + wp.copy(state.body_qd, model.body_qd) + wp.copy(state.body_q, wrapped, dest_offset=cable_body_start, count=len(wrapped_xforms)) + state.body_f.zero_() + NewtonManager._solver.reset(NewtonManager.get_state_0(), flags=0) + + +def run_simulator(sim: sim_utils.SimulationContext) -> None: + """Run until the viewer closes or the optional step limit is reached.""" + step_count = 0 + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): + sim.step() + step_count += 1 + + +def main() -> None: + """Launch the block-and-tackle dragging demo.""" + physics_cfg = NewtonCfg( + num_substeps=16, + collision_decimation=1, + default_shape_cfg=NewtonShapeCfg(gap=CABLE_GAP, ke=1.0e5, kd=20.0, mu=0.5), + solver_cfg=VBDSolverCfg(iterations=10, rigid_contact_hard=False, rigid_body_contact_buffer_size=512), + ) + with launch_simulation(cfg=physics_cfg, launcher_args=args_cli) as resolved_physics_cfg: + sim_cfg = sim_utils.SimulationCfg(dt=1.0 / 60.0, device=args_cli.device, physics=resolved_physics_cfg) + sim = sim_utils.SimulationContext(sim_cfg) + sim.set_camera_view(eye=(1.35, -2.1, 1.25), target=(0.50, 0.0, 0.72)) + builder = NewtonManager.create_builder() + builder.rigid_gap = CABLE_GAP + cable_body_start, wrapped_xforms = _build_system(builder) + builder.color(balance_colors=False) + NewtonManager.set_builder(builder) + sim.reset() + _initialize_wrapped_cable(cable_body_start, wrapped_xforms) + print("[INFO]: Setup complete. Right-drag the yellow cable handle downward to lift the red load.", flush=True) + run_simulator(sim) + + +if __name__ == "__main__": + main() diff --git a/scripts/demos/newton_viewer_dominoes.py b/scripts/demos/newton_viewer_dominoes.py new file mode 100644 index 000000000000..c30cf18b0ce3 --- /dev/null +++ b/scripts/demos/newton_viewer_dominoes.py @@ -0,0 +1,163 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Topple an NVIDIA-logo domino layout with Newton viewer dragging. + +Right-click and drag the black trigger slab on the right to start the cascade +across all rows. + +.. code-block:: bash + + uv run python scripts/demos/newton_viewer_dominoes.py +""" + +import argparse +from pathlib import Path + +from isaaclab.app import add_launcher_args, launch_simulation + +parser = argparse.ArgumentParser(description="NVIDIA-logo domino dragging demo (XPBD).") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") +add_launcher_args(parser) +parser.set_defaults(visualizer=["newton"]) +args_cli = parser.parse_args() + +import torch +from isaaclab_newton.physics import NewtonCfg, NewtonManager, NewtonShapeCfg, XPBDSolverCfg + +from pxr import Gf, UsdGeom + +import isaaclab.sim as sim_utils +from isaaclab.assets import AssetBaseCfg, RigidObjectCfg, RigidObjectCollectionCfg +from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.utils.configclass import configclass + +DOMINO_SIZE = (0.12, 0.032, 0.36) +DOMINO_SPACING = 0.12 +LOGO_FOOTPRINT = (29.4, 8.4) +NVIDIA_GREEN = (0.24, 0.50, 0.0) + +_POSES_PATH = Path(__file__).with_name("assets") / "nvidia_logo_domino_poses.pth" +_POSES = torch.load(_POSES_PATH, map_location="cpu", weights_only=True).tolist() +LOGO_DOMINO_POSES = [(tuple(pose[:3]), tuple(pose[3:])) for pose in _POSES] + + +def _set_display_color(prim_path: str, color: tuple[float, float, float]) -> None: + """Set a mesh display color for the Newton model builder.""" + mesh = sim_utils.get_current_stage().GetPrimAtPath(f"{prim_path}/geometry/mesh") + UsdGeom.Gprim(mesh).CreateDisplayColorAttr().Set([Gf.Vec3f(*color)]) + + +def _domino_cfg(position: tuple[float, float, float], orientation: tuple[float, float, float, float]) -> RigidObjectCfg: + """Return one domino at a saved pose.""" + return RigidObjectCfg( + prim_path="", + spawn=sim_utils.CuboidCfg( + size=DOMINO_SIZE, + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(density=580.0), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=sim_utils.RigidBodyMaterialCfg( + static_friction=1.0, + dynamic_friction=1.0, + restitution=0.15, + ), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=position, rot=orientation), + ) + + +def _trigger_cfg() -> RigidObjectCfg: + """Place a low-mass black trigger slab across every row on the right.""" + right_edge = max(position[0] for position, _ in LOGO_DOMINO_POSES) + return RigidObjectCfg( + prim_path="/World/Dominoes/Trigger", + spawn=sim_utils.CuboidCfg( + size=(DOMINO_SIZE[1], LOGO_FOOTPRINT[1], DOMINO_SIZE[2]), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(density=20.0), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=sim_utils.RigidBodyMaterialCfg( + static_friction=1.0, + dynamic_friction=1.0, + restitution=0.15, + ), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(right_edge + DOMINO_SPACING, 0.0, DOMINO_SIZE[2] / 2.0)), + ) + + +@configclass +class DominoSceneCfg(InteractiveSceneCfg): + """White floor, saved domino poses, and the trigger slab.""" + + floor: AssetBaseCfg = AssetBaseCfg( + prim_path="/World/Floor", + spawn=sim_utils.CuboidCfg( + size=(LOGO_FOOTPRINT[0] + 4.0, LOGO_FOOTPRINT[1] + 4.0, 0.10), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=sim_utils.RigidBodyMaterialCfg( + static_friction=1.0, + dynamic_friction=1.0, + restitution=0.15, + ), + ), + init_state=AssetBaseCfg.InitialStateCfg(pos=(0.0, 0.0, -0.05)), + ) + dominoes: RigidObjectCollectionCfg = RigidObjectCollectionCfg( + rigid_objects={ + f"domino_{index:04d}": _domino_cfg(position, orientation).replace( + prim_path=f"/World/Dominoes/Domino{index:04d}" + ) + for index, (position, orientation) in enumerate(LOGO_DOMINO_POSES) + } + ) + trigger: RigidObjectCfg = _trigger_cfg() + + +def _apply_display_colors() -> None: + """Apply viewer colors after InteractiveScene has authored the prims.""" + _set_display_color("/World/Floor", (1.0, 1.0, 1.0)) + for index in range(len(LOGO_DOMINO_POSES)): + _set_display_color(f"/World/Dominoes/Domino{index:04d}", NVIDIA_GREEN) + _set_display_color("/World/Dominoes/Trigger", (0.02, 0.02, 0.02)) + + +def run_simulator(sim: sim_utils.SimulationContext) -> None: + """Run until the viewer closes or the optional step limit is reached.""" + step_count = 0 + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): + sim.step() + step_count += 1 + + +def main() -> None: + """Launch the Newton XPBD domino dragging demo.""" + physics_cfg = NewtonCfg( + num_substeps=10, + collision_decimation=1, + default_shape_cfg=NewtonShapeCfg(gap=0.001, ke=1.0e4, kd=0.0, mu=1.0), + solver_cfg=XPBDSolverCfg(iterations=20, enable_restitution=True), + ) + with launch_simulation(cfg=physics_cfg, launcher_args=args_cli) as resolved_physics_cfg: + sim_cfg = sim_utils.SimulationCfg(dt=1.0 / 120.0, device=args_cli.device, physics=resolved_physics_cfg) + sim = sim_utils.SimulationContext(sim_cfg) + sim.set_camera_view(eye=(0.0, -18.0, 15.0), target=(0.0, 0.0, 0.0)) + _scene = InteractiveScene(DominoSceneCfg(num_envs=1, env_spacing=1.0)) + _apply_display_colors() + if NewtonManager._builder is None: + NewtonManager.instantiate_builder_from_stage() + NewtonManager._builder.rigid_gap = 0.001 + sim.reset() + print( + f"[INFO]: Setup complete with {len(LOGO_DOMINO_POSES)} green dominoes. " + "Right-click and drag the black trigger slab on the right to topple the logo.", + flush=True, + ) + run_simulator(sim) + + +if __name__ == "__main__": + main() diff --git a/scripts/demos/newton_viewer_dragging.py b/scripts/demos/newton_viewer_dragging.py deleted file mode 100644 index fd98745860c8..000000000000 --- a/scripts/demos/newton_viewer_dragging.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Exercise Newton MJWarp rigid-body dragging. - -The scene uses only the Newton MJWarp rigid-body solver and contains three -dynamic cubes. Right-click and drag any cube to apply an interactive force. - -.. code-block:: bash - - uv run python scripts/demos/newton_viewer_dragging.py -""" - -import argparse - -from isaaclab.app import add_launcher_args, launch_simulation - -parser = argparse.ArgumentParser(description="Three-cube Newton viewer dragging demo (MJWarp).") -parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") -add_launcher_args(parser) -parser.set_defaults(visualizer=["newton"]) -args_cli = parser.parse_args() - -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg - -import isaaclab.sim as sim_utils -from isaaclab.assets import AssetBaseCfg, RigidObjectCfg -from isaaclab.scene import InteractiveScene, InteractiveSceneCfg -from isaaclab.utils.configclass import configclass - - -def cube_cfg(name: str, position: tuple[float, float, float]) -> RigidObjectCfg: - """Create one draggable cube configuration.""" - return RigidObjectCfg( - prim_path=f"{{ENV_REGEX_NS}}/{name}", - spawn=sim_utils.CuboidCfg( - size=(0.5, 0.5, 0.5), - rigid_props=sim_utils.RigidBodyPropertiesCfg(), - mass_props=sim_utils.MassPropertiesCfg(mass=1.0), - collision_props=sim_utils.CollisionPropertiesCfg(), - ), - init_state=RigidObjectCfg.InitialStateCfg(pos=position), - ) - - -@configclass -class ViewerDraggingSceneCfg(InteractiveSceneCfg): - """Ground plane and three dynamic cubes.""" - - ground = AssetBaseCfg( - prim_path="/World/Ground", - spawn=sim_utils.GroundPlaneCfg(size=(6.0, 6.0), color=(0.25, 0.25, 0.25)), - ) - left_cube = cube_cfg("LeftCube", (-0.75, 0.0, 0.5)) - center_cube = cube_cfg("CenterCube", (0.0, 0.0, 0.5)) - right_cube = cube_cfg("RightCube", (0.75, 0.0, 0.5)) - - -def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> None: - """Run until the viewer closes or the optional step limit is reached.""" - sim_dt = sim.get_physics_dt() - step_count = 0 - while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): - scene.write_data_to_sim() - sim.step() - scene.update(sim_dt) - step_count += 1 - - -def main() -> None: - """Launch the MJWarp viewer-dragging demo.""" - physics_cfg = NewtonCfg(solver_cfg=MJWarpSolverCfg()) - with launch_simulation(cfg=physics_cfg, launcher_args=args_cli) as resolved_physics_cfg: - sim_cfg = sim_utils.SimulationCfg(dt=1.0 / 100.0, device=args_cli.device, physics=resolved_physics_cfg) - sim = sim_utils.SimulationContext(sim_cfg) - sim.set_camera_view(eye=(3.0, -4.0, 2.5), target=(0.0, 0.0, 0.5)) - scene = InteractiveScene(ViewerDraggingSceneCfg(num_envs=1, env_spacing=1.0)) - sim.reset() - print("[INFO]: Setup complete. Right-click and drag any cube.", flush=True) - run_simulator(sim, scene) - - -if __name__ == "__main__": - main() diff --git a/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst b/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst index 52199a68066b..90bb1e473b2c 100644 --- a/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst +++ b/source/isaaclab/changelog.d/max-newton-viewer-dragging.minor.rst @@ -1,4 +1,4 @@ Added ^^^^^ -* Added a three-cube MJWarp Newton demo for interactive rigid-body dragging. +* Added XPBD domino, VBD block-and-tackle, and coupled rigid-box/MPM demos for Newton viewer dragging. diff --git a/source/isaaclab/test/app/standalone_script_cases.py b/source/isaaclab/test/app/standalone_script_cases.py index 49cd537271b6..43b6f31e2390 100644 --- a/source/isaaclab/test/app/standalone_script_cases.py +++ b/source/isaaclab/test/app/standalone_script_cases.py @@ -159,15 +159,28 @@ class SmokeResult: readiness_pattern=r"Newton granular MPM demo ready", fixed_physics_backend="newton_mpm", ), + "scripts/demos/mpm/newton_mpm_twoway_coupling.py": ScriptOverride( + args=("--max_steps", "2", "--voxel_size", "0.2"), + readiness_pattern=r"Newton two-way MPM demo ready", + fixed_physics_backend="newton_coupler", + visualizers=("newton",), + required_modules=("isaaclab_contrib",), + ), "scripts/demos/mpm/particle_pour.py": ScriptOverride( args=("--max-steps", "200"), readiness_pattern=r"particle-pour MPM demo ready", fixed_physics_backend="newton_mpm", ), "scripts/demos/multi_asset.py": ScriptOverride(args=("--num_envs", "4")), - "scripts/demos/newton_viewer_dragging.py": ScriptOverride( + "scripts/demos/newton_viewer_block_and_tackle.py": ScriptOverride( + args=("--max_steps", "20"), + fixed_physics_backend="newton_vbd", + visualizers=("newton",), + required_modules=("isaaclab_contrib",), + ), + "scripts/demos/newton_viewer_dominoes.py": ScriptOverride( args=("--max_steps", "20"), - fixed_physics_backend="newton_mjwarp", + fixed_physics_backend="newton_xpbd", visualizers=("newton",), ), "scripts/demos/sensors/cameras.py": ScriptOverride(args=("--num_envs", "1"), startup_timeout=600.0), diff --git a/source/isaaclab_contrib/changelog.d/max-newton-viewer-dragging.minor.rst b/source/isaaclab_contrib/changelog.d/max-newton-viewer-dragging.minor.rst index 7d02ee57cbb3..af9648c81d97 100644 --- a/source/isaaclab_contrib/changelog.d/max-newton-viewer-dragging.minor.rst +++ b/source/isaaclab_contrib/changelog.d/max-newton-viewer-dragging.minor.rst @@ -1,5 +1,10 @@ Added ^^^^^ -* Added Newton visualizer rigid-body dragging support to +* Added Newton visualizer rigid-body dragging support to VBD and :class:`~isaaclab_contrib.coupling.NewtonCouplerManager`. + +Fixed +^^^^^ + +* Fixed single-world coupled MPM resets by promoting the only local world to a full-grid reset. diff --git a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py index f8948d6b0868..f88fae66d9da 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py +++ b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py @@ -10,6 +10,7 @@ from dataclasses import dataclass from functools import partial +import warp as wp from isaaclab_newton.physics import ( KaminoSolverCfg, MJWarpSolverCfg, @@ -219,6 +220,21 @@ def _supports_cuda_graph_capture(cls) -> bool: for entry in getattr(solver_cfg, "entries", ()) ) + @classmethod + def _reset_solver_internals(cls, world_mask: wp.array | None) -> None: + """Promote a selected single MPM world to the solver's full-reset path.""" + model = NewtonManager._model + solver_cfg = getattr(PhysicsManager._cfg, "solver_cfg", None) + has_mpm_entry = any(isinstance(entry.solver_cfg, MPMSolverCfg) for entry in getattr(solver_cfg, "entries", ())) + if world_mask is not None and model is not None and model.world_count == 1 and has_mpm_entry: + selected = world_mask.numpy() + if not selected.any(): + return + if selected[0] and not selected[-1]: + NewtonManager._solver.reset(NewtonManager._state_0, world_mask=None, flags=0) + return + super()._reset_solver_internals(world_mask) + @classmethod def _resolve_entry( cls, 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 70d04ed2f7fd..7ffeae60f56c 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py @@ -115,9 +115,15 @@ class VBDSolverCfg(NewtonModelSolverCfg): Only used when ``particle_enable_self_contact`` is ``True``. """ + rigid_contact_hard: bool = True + """Whether rigid body contacts use augmented-Lagrangian constraints instead of soft penalties.""" + rigid_contact_k_start: float = 1.0e2 """Initial stiffness seed for all rigid body contacts [N/m].""" + rigid_body_contact_buffer_size: int = 64 + """Maximum number of rigid contacts stored per body.""" + @configclass class CoupledMJWarpVBDSolverCfg(NewtonModelSolverCfg): diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py index 5d2c8a1a1600..ca49ec379816 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/vbd_manager.py @@ -249,7 +249,7 @@ def _build_solver(cls, model: Model, solver_cfg: VBDSolverCfg) -> None: NewtonManager._solver = cls._create_solver(model, solver_cfg) NewtonManager._use_single_state = False NewtonManager._needs_collision_pipeline = True - NewtonManager._supports_rigid_body_force_input = False + NewtonManager._supports_rigid_body_force_input = not solver_cfg.integrate_with_external_rigid_solver @classmethod def _simulate_physics_only(cls) -> None: diff --git a/source/isaaclab_contrib/test/coupling/test_coupler.py b/source/isaaclab_contrib/test/coupling/test_coupler.py index 7e1459aa9bde..4a9b06ee76e1 100644 --- a/source/isaaclab_contrib/test/coupling/test_coupler.py +++ b/source/isaaclab_contrib/test/coupling/test_coupler.py @@ -564,6 +564,71 @@ def test_contact_initialization_prepares_coupled_solver_buffers(monkeypatch): assert events == [("initialize", None), ("prepare", contacts)] +@pytest.mark.parametrize(("mask_values", "should_reset"), [([True, False], True), ([False, False], False)]) +def test_single_world_mpm_reset_promotes_local_mask(monkeypatch, mask_values, should_reset): + """The only selected local MPM world uses the full-grid reset path.""" + state_0 = object() + calls: list[tuple[object, object | None, int | None]] = [] + solver = SimpleNamespace( + reset=lambda state, world_mask=None, flags=None: calls.append((state, world_mask, flags)), + ) + mask = _FakeArray(np.asarray(mask_values, dtype=np.bool_)) + solver_cfg = CouplerProxyCfg(entries=[CouplerEntryCfg(name="mpm", solver_cfg=MPMSolverCfg(), in_place=True)]) + + monkeypatch.setattr(coupler.PhysicsManager, "_cfg", SimpleNamespace(solver_cfg=solver_cfg)) + monkeypatch.setattr(coupler.NewtonManager, "_model", SimpleNamespace(world_count=1)) + monkeypatch.setattr(coupler.NewtonManager, "_solver", solver) + monkeypatch.setattr(coupler.NewtonManager, "_state_0", state_0) + + NewtonCouplerManager._reset_solver_internals(mask) + + assert calls == ([(state_0, None, 0)] if should_reset else []) + + +def test_single_world_non_mpm_reset_does_not_read_mask_on_host(monkeypatch): + """A non-MPM coupled reset must keep the device mask on the device.""" + state = object() + calls: list[tuple[object, object, int]] = [] + solver = SimpleNamespace( + reset=lambda reset_state, world_mask=None, flags=0: calls.append((reset_state, world_mask, flags)), + ) + + class _DeviceMask: + def numpy(self): + raise AssertionError("non-MPM reset unexpectedly copied its mask to the host") + + mask = _DeviceMask() + solver_cfg = CouplerProxyCfg(entries=[CouplerEntryCfg(name="rigid", solver_cfg=XPBDSolverCfg())]) + monkeypatch.setattr(coupler.PhysicsManager, "_cfg", SimpleNamespace(solver_cfg=solver_cfg)) + monkeypatch.setattr(coupler.NewtonManager, "_model", SimpleNamespace(world_count=1)) + monkeypatch.setattr(coupler.NewtonManager, "_solver", solver) + monkeypatch.setattr(coupler.NewtonManager, "_state_0", state) + + NewtonCouplerManager._reset_solver_internals(mask) + + assert calls == [(state, mask, 0)] + + +def test_multi_world_mpm_reset_is_not_promoted(monkeypatch): + """A partial multi-world MPM reset must not clear every world's grid.""" + state = object() + calls: list[tuple[object, object, int]] = [] + solver = SimpleNamespace( + reset=lambda reset_state, world_mask=None, flags=0: calls.append((reset_state, world_mask, flags)), + ) + mask = _FakeArray(np.asarray([True, False, False], dtype=np.bool_)) + solver_cfg = CouplerProxyCfg(entries=[CouplerEntryCfg(name="mpm", solver_cfg=MPMSolverCfg(), in_place=True)]) + + monkeypatch.setattr(coupler.PhysicsManager, "_cfg", SimpleNamespace(solver_cfg=solver_cfg)) + monkeypatch.setattr(coupler.NewtonManager, "_model", SimpleNamespace(world_count=2)) + monkeypatch.setattr(coupler.NewtonManager, "_solver", solver) + monkeypatch.setattr(coupler.NewtonManager, "_state_0", state) + + NewtonCouplerManager._reset_solver_internals(mask) + + assert calls == [(state, mask, 0)] + + @pytest.mark.parametrize( ("case", "expected_outer"), [ diff --git a/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py b/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py index fa886612ca1e..db6dddc3b3a4 100644 --- a/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py +++ b/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py @@ -19,6 +19,7 @@ add_deformable_entry_to_builder, setup_registered_deformable_fabric_sync, ) +from isaaclab_contrib.deformable.vbd_manager import NewtonVBDManager class _FakeBuilder: @@ -84,6 +85,22 @@ def test_deformable_package_exports_public_symbols(): assert VBDSolverCfg.__name__ == "VBDSolverCfg" +@pytest.mark.parametrize("external_rigid_solver", [False, True]) +def test_vbd_solver_force_input_capability(monkeypatch, external_rigid_solver: bool): + """VBD consumes rigid forces only when it owns AVBD rigid integration.""" + solver = object() + monkeypatch.setattr(NewtonVBDManager, "_create_solver", lambda model, cfg: solver) + monkeypatch.setattr(NewtonManager, "_solver", None) + monkeypatch.setattr(NewtonManager, "_use_single_state", True) + monkeypatch.setattr(NewtonManager, "_needs_collision_pipeline", False) + monkeypatch.setattr(NewtonManager, "_supports_rigid_body_force_input", False) + + NewtonVBDManager._build_solver(object(), VBDSolverCfg(integrate_with_external_rigid_solver=external_rigid_solver)) + + assert NewtonManager._solver is solver + assert NewtonManager._supports_rigid_body_force_input is not external_rigid_solver + + def test_newton_material_defaults_match_registry_defaults(): """Test that Newton material cfg defaults match the deformable registry defaults.""" material_cfg = NewtonDeformableMaterialCfg() From 10b8772db4bc88bda90c682104cd29d2ac64b8ac Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 6 Aug 2026 23:24:29 -0700 Subject: [PATCH 12/22] Align viewer branch with landed MPM fix Remove the duplicate reset changelog entry now that the scoped coupled-MPM fix is part of develop. Restore the surrounding core formatting to keep the viewer diff focused. --- source/isaaclab/isaaclab/sim/simulation_context.py | 1 + .../changelog.d/max-newton-viewer-dragging.minor.rst | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 51a4172ca42a..291fb0845bc7 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -234,6 +234,7 @@ def __init__(self, cfg: SimulationCfg | None = None): PhysicsEvent.PHYSICS_READY, order=5, ) + self._services = ServiceLocator() type(self)._instance = self # Mark as valid singleton only after successful init diff --git a/source/isaaclab_contrib/changelog.d/max-newton-viewer-dragging.minor.rst b/source/isaaclab_contrib/changelog.d/max-newton-viewer-dragging.minor.rst index af9648c81d97..cdea16167d22 100644 --- a/source/isaaclab_contrib/changelog.d/max-newton-viewer-dragging.minor.rst +++ b/source/isaaclab_contrib/changelog.d/max-newton-viewer-dragging.minor.rst @@ -3,8 +3,3 @@ Added * Added Newton visualizer rigid-body dragging support to VBD and :class:`~isaaclab_contrib.coupling.NewtonCouplerManager`. - -Fixed -^^^^^ - -* Fixed single-world coupled MPM resets by promoting the only local world to a full-grid reset. From ee8999441f7128069036182502ecd466cb92c066 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 7 Aug 2026 11:01:45 -0700 Subject: [PATCH 13/22] Clarify viewer capture and demo settings Keep pulley-specific VBD contact tuning local to the standalone example instead of expanding the public solver config. Clarify that only picking inputs require initialization before CUDA graph capture. --- scripts/demos/newton_viewer_block_and_tackle.py | 11 ++++++++++- source/isaaclab/isaaclab/sim/simulation_context.py | 10 ++++++---- .../isaaclab_contrib/deformable/newton_manager_cfg.py | 6 ------ 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/scripts/demos/newton_viewer_block_and_tackle.py b/scripts/demos/newton_viewer_block_and_tackle.py index 542f15000399..381d487bedce 100644 --- a/scripts/demos/newton_viewer_block_and_tackle.py +++ b/scripts/demos/newton_viewer_block_and_tackle.py @@ -27,6 +27,7 @@ from isaaclab_newton.physics import NewtonCfg, NewtonManager, NewtonShapeCfg import isaaclab.sim as sim_utils +from isaaclab.utils.configclass import configclass from isaaclab_contrib.deformable import VBDSolverCfg @@ -50,6 +51,14 @@ HANDLE_HALF_EXTENTS = (0.025, 0.025, 0.03) +@configclass +class _BlockAndTackleVBDSolverCfg(VBDSolverCfg): + """VBD contact settings for this cable and pulley scene.""" + + rigid_contact_hard: bool = False + rigid_body_contact_buffer_size: int = 512 + + def _append_arc(points: list[wp.vec3], center: wp.vec3, start: float, end: float) -> None: """Append a clockwise pulley arc in the vertical XZ plane.""" delta = (end - start + math.pi) % (2.0 * math.pi) - math.pi @@ -302,7 +311,7 @@ def main() -> None: num_substeps=16, collision_decimation=1, default_shape_cfg=NewtonShapeCfg(gap=CABLE_GAP, ke=1.0e5, kd=20.0, mu=0.5), - solver_cfg=VBDSolverCfg(iterations=10, rigid_contact_hard=False, rigid_body_contact_buffer_size=512), + solver_cfg=_BlockAndTackleVBDSolverCfg(), ) with launch_simulation(cfg=physics_cfg, launcher_args=args_cli) as resolved_physics_cfg: sim_cfg = sim_utils.SimulationCfg(dt=1.0 / 60.0, device=args_cli.device, physics=resolved_physics_cfg) diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 291fb0845bc7..dea9c8aac2fc 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -714,13 +714,15 @@ def forward(self) -> None: def _prepare_newton_visualizer_for_capture(self, _payload=None) -> None: """Initialize or rebind the Newton viewer before solver graph capture.""" - self._initialize_visualizers(self._is_interactive_newton_cfg) - for viz in (viz for viz in self._visualizers if self._is_interactive_newton_cfg(viz.cfg)): + # Picking applies forces inside solver substeps, so its kernels and buffers + # must exist during graph capture. Render-only viewers can initialize later. + self._initialize_visualizers(self._requires_pre_capture_newton_init) + for viz in (viz for viz in self._visualizers if self._requires_pre_capture_newton_init(viz.cfg)): viz.reset(soft=False) @staticmethod - def _is_interactive_newton_cfg(cfg: Any) -> bool: - """Return whether a config can create interactive Newton picking inputs.""" + def _requires_pre_capture_newton_init(cfg: Any) -> bool: + """Return whether a config contributes Newton picking inputs to capture.""" return ( getattr(cfg, "visualizer_type", None) == "newton" and bool(getattr(cfg, "enable_picking", False)) 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 7ffeae60f56c..70d04ed2f7fd 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/newton_manager_cfg.py @@ -115,15 +115,9 @@ class VBDSolverCfg(NewtonModelSolverCfg): Only used when ``particle_enable_self_contact`` is ``True``. """ - rigid_contact_hard: bool = True - """Whether rigid body contacts use augmented-Lagrangian constraints instead of soft penalties.""" - rigid_contact_k_start: float = 1.0e2 """Initial stiffness seed for all rigid body contacts [N/m].""" - rigid_body_contact_buffer_size: int = 64 - """Maximum number of rigid contacts stored per body.""" - @configclass class CoupledMJWarpVBDSolverCfg(NewtonModelSolverCfg): From 36f1d37903e682b088ee3e47da9b33085b7734ea Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 7 Aug 2026 14:29:33 -0700 Subject: [PATCH 14/22] Move MPM demo import to module scope --- scripts/demos/mpm/newton_mpm_twoway_coupling.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py index 94c0cd9c61de..0f76964af470 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -21,6 +21,8 @@ import argparse +from isaaclab_visualizers.newton import NewtonGLVisualizerCfg + from isaaclab.app import add_launcher_args, launch_simulation parser = argparse.ArgumentParser(description="Newton rigid-box and MPM-sand two-way coupling demo.") @@ -58,8 +60,6 @@ def create_visualizer_cfgs(): if not {"newton", "newton_gl"}.intersection(args_cli.visualizer or []): return [] - from isaaclab_visualizers.newton import NewtonGLVisualizerCfg - return [ NewtonGLVisualizerCfg( streaming_view=False, From 98b2abf48a7caf876998a30ab5b9189bb91f8b76 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 7 Aug 2026 16:16:53 -0700 Subject: [PATCH 15/22] Contain coupled MPM demo in particle bath --- .../demos/mpm/newton_mpm_twoway_coupling.py | 122 +++++++++++------- 1 file changed, 77 insertions(+), 45 deletions(-) diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py index 0f76964af470..5b92d7892b14 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -3,17 +3,17 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Drag three rigid boxes coupled to Newton implicit-MPM sand. +"""Drag three rigid spheres in a bath of Newton implicit-MPM sand. This Isaac Lab port of Newton's ``mpm_twoway_coupling`` example uses a proxy -coupler to expose dynamic rigid boxes as MPM colliders and feed the resulting +coupler to expose dynamic rigid spheres as MPM colliders and feed the resulting impulses back into the rigid-body solver. .. code-block:: bash uv run python scripts/demos/mpm/newton_mpm_twoway_coupling.py -Right-click and drag a box to apply an interactive force. Use ``Space`` to +Right-click and drag a sphere to apply an interactive force. Use ``Space`` to pause or resume the simulation and ``.`` to advance one step while paused. """ @@ -25,9 +25,9 @@ from isaaclab.app import add_launcher_args, launch_simulation -parser = argparse.ArgumentParser(description="Newton rigid-box and MPM-sand two-way coupling demo.") +parser = argparse.ArgumentParser(description="Newton rigid-sphere and MPM-sand two-way coupling demo.") parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many frames; negative runs forever.") -parser.add_argument("--voxel_size", type=float, default=0.075, help="MPM grid voxel size [m].") +parser.add_argument("--voxel_size", type=float, default=0.08, help="MPM grid voxel size [m].") parser.add_argument("--rigid_substeps", type=int, default=4, help="Rigid-solver substeps per coupled step.") add_launcher_args(parser) parser.set_defaults(visualizer=["newton"]) @@ -36,23 +36,18 @@ FPS = 100.0 GRAVITY = (0.0, 0.0, -9.81) -PARTICLES_PER_CELL = 3.0 +PARTICLES_PER_CELL = 2.0 PARTICLE_COLOR = (0.7, 0.6, 0.4) +SPHERE_BODY_PATTERN = r"/World/envs/env_.*/Sphere_[0-9]+" +SPHERE_RADIUS = 0.30 +SPHERE_MASS = 150.0 +SPHERE_POSITIONS = ((-0.90, 0.25, 1.10), (0.0, -0.30, 1.10), (0.90, 0.30, 1.10)) -BOX_BODY_PATTERN = r"/World/envs/env_.*/Box_[0-9]+" -BOX_HALF_EXTENTS = ( - (0.25, 0.35, 0.25), - (0.25, 0.25, 0.25), - (0.30, 0.20, 0.20), -) -# Match Newton's reference scene: 75 kg body mass plus the shape's -# default-density contribution. -BOX_MASSES = (250.0, 200.0, 171.0) -BOX_OFFSETS_XY = ( - (0.00, 0.00), - (0.10, 0.00), - (-0.10, 0.00), -) +BATH_INTERIOR_SIZE = (3.6, 2.6) +BATH_WALL_HEIGHT = 1.5 +BATH_WALL_THICKNESS = 0.15 +SAND_LOWER = (-1.65, -1.15, 0.05) +SAND_UPPER = (1.65, 1.15, 0.72) def create_visualizer_cfgs(): @@ -83,7 +78,7 @@ def create_sim_cfg(): CouplerEntryCfg( name="rigid", solver_cfg=MJWarpSolverCfg(use_mujoco_contacts=False, njmax=128), - bodies=[BOX_BODY_PATTERN], + bodies=[SPHERE_BODY_PATTERN], include_static_shapes=True, substeps=args_cli.rigid_substeps, ), @@ -93,7 +88,7 @@ def create_sim_cfg(): voxel_size=args_cli.voxel_size, grid_type="fixed", grid_padding=50, - max_active_cell_count=1 << 15, + max_active_cell_count=1 << 16, strain_basis="P0", max_iterations=50, critical_fraction=0.0, @@ -106,7 +101,7 @@ def create_sim_cfg(): CouplerProxyMappingCfg( source="rigid", destination="mpm", - bodies=[BOX_BODY_PATTERN], + bodies=[SPHERE_BODY_PATTERN], mode="lagged", collision_pipeline=None, ) @@ -123,7 +118,7 @@ def create_sim_cfg(): def create_scene_cfg(): - """Create the declarative rigid-box and granular-bed scene.""" + """Create the declarative rigid-sphere and granular-bath scene.""" from isaaclab_newton.assets.mpm_object import MPMObjectCfg from isaaclab_newton.sim.spawners.mpm import MPMGridCfg, MPMParticleMaterialCfg @@ -132,43 +127,80 @@ def create_scene_cfg(): from isaaclab.scene import InteractiveSceneCfg from isaaclab.utils.configclass import configclass - rigid_objects = {} - for index, (half_extents, mass, offset_xy) in enumerate( - zip(BOX_HALF_EXTENTS, BOX_MASSES, BOX_OFFSETS_XY, strict=True) - ): - rigid_objects[f"box_{index}"] = RigidObjectCfg( - prim_path=f"{{ENV_REGEX_NS}}/Box_{index}", + def bath_collider( + prim_path: str, size: tuple[float, float, float], position: tuple[float, float, float] + ) -> AssetBaseCfg: + return AssetBaseCfg( + prim_path=prim_path, spawn=sim_utils.CuboidCfg( - size=tuple(2.0 * extent for extent in half_extents), + size=size, + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=sim_utils.NewtonMaterialPropertiesCfg( + static_friction=0.6, + dynamic_friction=0.6, + ), + ), + init_state=AssetBaseCfg.InitialStateCfg(pos=position), + ) + + rigid_objects = {} + for index, position in enumerate(SPHERE_POSITIONS): + rigid_objects[f"sphere_{index}"] = RigidObjectCfg( + prim_path=f"{{ENV_REGEX_NS}}/Sphere_{index}", + spawn=sim_utils.SphereCfg( + radius=SPHERE_RADIUS, rigid_props=sim_utils.RigidBodyPropertiesCfg(), - mass_props=sim_utils.MassPropertiesCfg(mass=mass), - collision_props=sim_utils.NewtonCollisionPropertiesCfg(contact_gap=0.1), + mass_props=sim_utils.MassPropertiesCfg(mass=SPHERE_MASS), + collision_props=sim_utils.NewtonCollisionPropertiesCfg(), physics_material=sim_utils.NewtonMaterialPropertiesCfg( static_friction=0.5, dynamic_friction=0.5, ), ), - init_state=RigidObjectCfg.InitialStateCfg( - pos=(offset_xy[0], offset_xy[1], 2.0 + 0.6 * index), - ), + init_state=RigidObjectCfg.InitialStateCfg(pos=position), ) + bath_x, bath_y = BATH_INTERIOR_SIZE + wall_t = BATH_WALL_THICKNESS + wall_z = 0.5 * BATH_WALL_HEIGHT + @configclass class CoupledSceneCfg(InteractiveSceneCfg): - """Scene containing dynamic rigid boxes and one Newton MPM object.""" + """Scene containing a static bath, three rigid spheres, and MPM sand.""" - ground = AssetBaseCfg( - prim_path="/World/Ground", - spawn=sim_utils.GroundPlaneCfg(size=(6.0, 6.0), color=(0.30, 0.30, 0.30)), + bath_floor = bath_collider( + "/World/Bath/Floor", + (bath_x + 2.0 * wall_t, bath_y + 2.0 * wall_t, wall_t), + (0.0, 0.0, -0.5 * wall_t), + ) + bath_left = bath_collider( + "/World/Bath/LeftWall", + (wall_t, bath_y, BATH_WALL_HEIGHT), + (-0.5 * (bath_x + wall_t), 0.0, wall_z), + ) + bath_right = bath_collider( + "/World/Bath/RightWall", + (wall_t, bath_y, BATH_WALL_HEIGHT), + (0.5 * (bath_x + wall_t), 0.0, wall_z), + ) + bath_front = bath_collider( + "/World/Bath/FrontWall", + (bath_x + 2.0 * wall_t, wall_t, BATH_WALL_HEIGHT), + (0.0, -0.5 * (bath_y + wall_t), wall_z), + ) + bath_back = bath_collider( + "/World/Bath/BackWall", + (bath_x + 2.0 * wall_t, wall_t, BATH_WALL_HEIGHT), + (0.0, 0.5 * (bath_y + wall_t), wall_z), ) - boxes = RigidObjectCollectionCfg(rigid_objects=rigid_objects) + spheres = RigidObjectCollectionCfg(rigid_objects=rigid_objects) sand = MPMObjectCfg( prim_path="{ENV_REGEX_NS}/Sand", spawn=MPMGridCfg( - lower=(-1.0, -1.0, 0.0), - upper=(1.0, 1.0, 0.5), + lower=SAND_LOWER, + upper=SAND_UPPER, voxel_size=args_cli.voxel_size, particles_per_cell=PARTICLES_PER_CELL, jitter=args_cli.voxel_size / PARTICLES_PER_CELL, @@ -200,7 +232,7 @@ def main() -> None: from isaaclab.scene import InteractiveScene sim = sim_utils.SimulationContext(sim_cfg) - sim.set_camera_view(eye=(3.0, -4.0, 2.5), target=(0.0, 0.0, 0.8)) + sim.set_camera_view(eye=(4.5, -5.5, 3.5), target=(0.0, 0.0, 0.65)) scene = InteractiveScene(create_scene_cfg()) sim.reset() sand = scene["sand"] @@ -209,7 +241,7 @@ def main() -> None: f"[INFO]: Isaac Lab Newton two-way MPM demo ready. Spawned {particle_count} particles.", flush=True, ) - print("[INFO]: Right-click and drag a box in the Newton viewer.", flush=True) + print("[INFO]: Right-click and drag a sphere in the Newton viewer.", flush=True) run_simulator(sim, scene) From 064d1a482752fc3e03e69afac7e529a15d8ae0ae Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 7 Aug 2026 16:21:40 -0700 Subject: [PATCH 16/22] Increase coupled MPM bath density --- scripts/demos/mpm/newton_mpm_twoway_coupling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py index 5b92d7892b14..fede7e9bd07c 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -36,7 +36,7 @@ FPS = 100.0 GRAVITY = (0.0, 0.0, -9.81) -PARTICLES_PER_CELL = 2.0 +PARTICLES_PER_CELL = 3.0 PARTICLE_COLOR = (0.7, 0.6, 0.4) SPHERE_BODY_PATTERN = r"/World/envs/env_.*/Sphere_[0-9]+" SPHERE_RADIUS = 0.30 From d693680fb1914a1244dd3c17295c989b2c16a483 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 7 Aug 2026 16:25:28 -0700 Subject: [PATCH 17/22] Tune coupled MPM bath interaction --- scripts/demos/mpm/newton_mpm_twoway_coupling.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py index fede7e9bd07c..5d028cfd5e77 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -36,11 +36,11 @@ FPS = 100.0 GRAVITY = (0.0, 0.0, -9.81) -PARTICLES_PER_CELL = 3.0 +PARTICLES_PER_CELL = 2.0 PARTICLE_COLOR = (0.7, 0.6, 0.4) SPHERE_BODY_PATTERN = r"/World/envs/env_.*/Sphere_[0-9]+" SPHERE_RADIUS = 0.30 -SPHERE_MASS = 150.0 +SPHERE_MASS = 450.0 SPHERE_POSITIONS = ((-0.90, 0.25, 1.10), (0.0, -0.30, 1.10), (0.90, 0.30, 1.10)) BATH_INTERIOR_SIZE = (3.6, 2.6) @@ -134,7 +134,7 @@ def bath_collider( prim_path=prim_path, spawn=sim_utils.CuboidCfg( size=size, - collision_props=sim_utils.CollisionPropertiesCfg(), + collision_props=sim_utils.NewtonCollisionPropertiesCfg(contact_margin=0.04), physics_material=sim_utils.NewtonMaterialPropertiesCfg( static_friction=0.6, dynamic_friction=0.6, From 97657599632862cd006f924cc22f943da56eb7cd Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 7 Aug 2026 16:31:31 -0700 Subject: [PATCH 18/22] Soften coupled MPM bath material --- scripts/demos/mpm/newton_mpm_twoway_coupling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py index 5d028cfd5e77..c0e2a3345bf8 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -204,7 +204,7 @@ class CoupledSceneCfg(InteractiveSceneCfg): voxel_size=args_cli.voxel_size, particles_per_cell=PARTICLES_PER_CELL, jitter=args_cli.voxel_size / PARTICLES_PER_CELL, - material=MPMParticleMaterialCfg(density=2500.0, friction=0.75, yield_pressure=1.0e15), + material=MPMParticleMaterialCfg(density=2500.0, friction=0.5, yield_pressure=1.0e5), visual_color=PARTICLE_COLOR, ), ) From c56dd2f6af17c363ff66fef2721e2cccf6811ff4 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 7 Aug 2026 16:42:02 -0700 Subject: [PATCH 19/22] Color spheres and lower MPM bath walls --- .../demos/mpm/newton_mpm_twoway_coupling.py | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py index c0e2a3345bf8..496b9e0ee419 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -20,9 +20,13 @@ from __future__ import annotations import argparse +from functools import partial from isaaclab_visualizers.newton import NewtonGLVisualizerCfg +from pxr import Gf, Usd, UsdGeom + +import isaaclab.sim as sim_utils from isaaclab.app import add_launcher_args, launch_simulation parser = argparse.ArgumentParser(description="Newton rigid-sphere and MPM-sand two-way coupling demo.") @@ -42,14 +46,31 @@ SPHERE_RADIUS = 0.30 SPHERE_MASS = 450.0 SPHERE_POSITIONS = ((-0.90, 0.25, 1.10), (0.0, -0.30, 1.10), (0.90, 0.30, 1.10)) +SPHERE_COLORS = ((0.20, 0.45, 0.85), (0.85, 0.25, 0.20), (0.25, 0.70, 0.30)) BATH_INTERIOR_SIZE = (3.6, 2.6) -BATH_WALL_HEIGHT = 1.5 +BATH_WALL_HEIGHT = 1.2 BATH_WALL_THICKNESS = 0.15 SAND_LOWER = (-1.65, -1.15, 0.05) SAND_UPPER = (1.65, 1.15, 0.72) +@sim_utils.clone +def _spawn_colored_sphere( + prim_path: str, + cfg: sim_utils.SphereCfg, + translation: tuple[float, float, float] | None = None, + orientation: tuple[float, float, float, float] | None = None, + *, + color: tuple[float, float, float], +) -> Usd.Prim: + """Spawn a sphere with a display color understood by the Newton viewer.""" + prim = sim_utils.spawn_sphere(prim_path, cfg, translation, orientation) + mesh = UsdGeom.Gprim(prim.GetStage().GetPrimAtPath(f"{prim_path}/geometry/mesh")) + mesh.CreateDisplayColorAttr().Set([Gf.Vec3f(*color)]) + return prim + + def create_visualizer_cfgs(): """Create the demo-specific Newton visualizer configuration.""" if not {"newton", "newton_gl"}.intersection(args_cli.visualizer or []): @@ -69,8 +90,6 @@ def create_sim_cfg(): """Create the proxy-coupled MJWarp and MPM simulation configuration.""" from isaaclab_newton.physics import MJWarpSolverCfg, MPMSolverCfg, NewtonCfg - import isaaclab.sim as sim_utils - from isaaclab_contrib.coupling import CouplerEntryCfg, CouplerProxyCfg, CouplerProxyMappingCfg solver_cfg = CouplerProxyCfg( @@ -122,7 +141,6 @@ def create_scene_cfg(): from isaaclab_newton.assets.mpm_object import MPMObjectCfg from isaaclab_newton.sim.spawners.mpm import MPMGridCfg, MPMParticleMaterialCfg - import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg, RigidObjectCfg, RigidObjectCollectionCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.utils.configclass import configclass @@ -148,6 +166,7 @@ def bath_collider( rigid_objects[f"sphere_{index}"] = RigidObjectCfg( prim_path=f"{{ENV_REGEX_NS}}/Sphere_{index}", spawn=sim_utils.SphereCfg( + func=partial(_spawn_colored_sphere, color=SPHERE_COLORS[index]), radius=SPHERE_RADIUS, rigid_props=sim_utils.RigidBodyPropertiesCfg(), mass_props=sim_utils.MassPropertiesCfg(mass=SPHERE_MASS), @@ -228,7 +247,6 @@ def main() -> None: """Launch the two-way rigid-MPM coupling demo.""" sim_cfg = create_sim_cfg() with launch_simulation(sim_cfg, args_cli): - import isaaclab.sim as sim_utils from isaaclab.scene import InteractiveScene sim = sim_utils.SimulationContext(sim_cfg) From 11fe76241bb8649c30d47a5ff70129c009383964 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 7 Aug 2026 16:43:10 -0700 Subject: [PATCH 20/22] Use canonical Newton GL demo defaults --- scripts/demos/mpm/newton_mpm_twoway_coupling.py | 2 +- scripts/demos/newton_viewer_block_and_tackle.py | 2 +- scripts/demos/newton_viewer_dominoes.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py index 496b9e0ee419..d2e66f114bd8 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -34,7 +34,7 @@ parser.add_argument("--voxel_size", type=float, default=0.08, help="MPM grid voxel size [m].") parser.add_argument("--rigid_substeps", type=int, default=4, help="Rigid-solver substeps per coupled step.") add_launcher_args(parser) -parser.set_defaults(visualizer=["newton"]) +parser.set_defaults(visualizer=["newton_gl"]) args_cli = parser.parse_args() diff --git a/scripts/demos/newton_viewer_block_and_tackle.py b/scripts/demos/newton_viewer_block_and_tackle.py index 381d487bedce..5ce4ec1acd3e 100644 --- a/scripts/demos/newton_viewer_block_and_tackle.py +++ b/scripts/demos/newton_viewer_block_and_tackle.py @@ -18,7 +18,7 @@ parser = argparse.ArgumentParser(description="Newton block-and-tackle viewer dragging demo.") parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") add_launcher_args(parser) -parser.set_defaults(visualizer=["newton"]) +parser.set_defaults(visualizer=["newton_gl"]) args_cli = parser.parse_args() import newton diff --git a/scripts/demos/newton_viewer_dominoes.py b/scripts/demos/newton_viewer_dominoes.py index c30cf18b0ce3..e0941ceb1fdb 100644 --- a/scripts/demos/newton_viewer_dominoes.py +++ b/scripts/demos/newton_viewer_dominoes.py @@ -21,7 +21,7 @@ parser = argparse.ArgumentParser(description="NVIDIA-logo domino dragging demo (XPBD).") parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") add_launcher_args(parser) -parser.set_defaults(visualizer=["newton"]) +parser.set_defaults(visualizer=["newton_gl"]) args_cli = parser.parse_args() import torch From 16b6583bd62f3f5df4e753d8ed7fae1769a44049 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 7 Aug 2026 17:07:29 -0700 Subject: [PATCH 21/22] Add three-way chutes to coupled MPM demo --- .../demos/mpm/newton_mpm_twoway_coupling.py | 63 +++++++++++++++---- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py index d2e66f114bd8..b71bb3e6a676 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -13,13 +13,14 @@ uv run python scripts/demos/mpm/newton_mpm_twoway_coupling.py -Right-click and drag a sphere to apply an interactive force. Use ``Space`` to -pause or resume the simulation and ``.`` to advance one step while paused. +The spheres roll through three V-shaped chutes into the bath. Right-click and +drag any sphere to apply an interactive force. """ from __future__ import annotations import argparse +from collections.abc import Callable from functools import partial from isaaclab_visualizers.newton import NewtonGLVisualizerCfg @@ -45,8 +46,8 @@ SPHERE_BODY_PATTERN = r"/World/envs/env_.*/Sphere_[0-9]+" SPHERE_RADIUS = 0.30 SPHERE_MASS = 450.0 -SPHERE_POSITIONS = ((-0.90, 0.25, 1.10), (0.0, -0.30, 1.10), (0.90, 0.30, 1.10)) SPHERE_COLORS = ((0.20, 0.45, 0.85), (0.85, 0.25, 0.20), (0.25, 0.70, 0.30)) +SPHERE_POSITIONS = ((-3.392, 0.0, 2.167), (0.0, 2.872, 2.167), (3.392, 0.0, 2.167)) BATH_INTERIOR_SIZE = (3.6, 2.6) BATH_WALL_HEIGHT = 1.2 @@ -54,18 +55,31 @@ SAND_LOWER = (-1.65, -1.15, 0.05) SAND_UPPER = (1.65, 1.15, 0.72) +CHUTE_PANEL_SIZE = (2.4, 0.62, 0.08) +CHUTE_COLOR = (0.25, 0.28, 0.32) +# Paired poses form the left, back, and right V-shaped chutes. +CHUTE_PANEL_POSES = ( + ((-2.933, -0.274, 1.771), (-0.23957, 0.13504, 0.03367, 0.96085)), + ((-2.933, 0.274, 1.771), (0.23957, 0.13504, -0.03367, 0.96085)), + ((-0.274, 2.413, 1.771), (-0.07391, 0.26489, -0.65562, 0.70323)), + ((0.274, 2.413, 1.771), (0.26489, -0.07391, -0.70323, 0.65562)), + ((2.933, 0.274, 1.771), (0.13504, 0.23957, -0.96085, 0.03367)), + ((2.933, -0.274, 1.771), (-0.13504, 0.23957, 0.96085, 0.03367)), +) + @sim_utils.clone -def _spawn_colored_sphere( +def _spawn_colored_shape( prim_path: str, - cfg: sim_utils.SphereCfg, + cfg: sim_utils.SpawnerCfg, translation: tuple[float, float, float] | None = None, orientation: tuple[float, float, float, float] | None = None, *, + spawn_func: Callable[..., Usd.Prim], color: tuple[float, float, float], ) -> Usd.Prim: - """Spawn a sphere with a display color understood by the Newton viewer.""" - prim = sim_utils.spawn_sphere(prim_path, cfg, translation, orientation) + """Spawn a shape with a display color understood by the Newton viewer.""" + prim = spawn_func(prim_path, cfg, translation, orientation) mesh = UsdGeom.Gprim(prim.GetStage().GetPrimAtPath(f"{prim_path}/geometry/mesh")) mesh.CreateDisplayColorAttr().Set([Gf.Vec3f(*color)]) return prim @@ -146,11 +160,20 @@ def create_scene_cfg(): from isaaclab.utils.configclass import configclass def bath_collider( - prim_path: str, size: tuple[float, float, float], position: tuple[float, float, float] + prim_path: str, + size: tuple[float, float, float], + position: tuple[float, float, float], + orientation: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0), + color: tuple[float, float, float] | None = None, ) -> AssetBaseCfg: return AssetBaseCfg( prim_path=prim_path, spawn=sim_utils.CuboidCfg( + func=( + partial(_spawn_colored_shape, spawn_func=sim_utils.spawn_cuboid, color=color) + if color is not None + else sim_utils.spawn_cuboid + ), size=size, collision_props=sim_utils.NewtonCollisionPropertiesCfg(contact_margin=0.04), physics_material=sim_utils.NewtonMaterialPropertiesCfg( @@ -158,7 +181,7 @@ def bath_collider( dynamic_friction=0.6, ), ), - init_state=AssetBaseCfg.InitialStateCfg(pos=position), + init_state=AssetBaseCfg.InitialStateCfg(pos=position, rot=orientation), ) rigid_objects = {} @@ -166,7 +189,11 @@ def bath_collider( rigid_objects[f"sphere_{index}"] = RigidObjectCfg( prim_path=f"{{ENV_REGEX_NS}}/Sphere_{index}", spawn=sim_utils.SphereCfg( - func=partial(_spawn_colored_sphere, color=SPHERE_COLORS[index]), + func=partial( + _spawn_colored_shape, + spawn_func=sim_utils.spawn_sphere, + color=SPHERE_COLORS[index], + ), radius=SPHERE_RADIUS, rigid_props=sim_utils.RigidBodyPropertiesCfg(), mass_props=sim_utils.MassPropertiesCfg(mass=SPHERE_MASS), @@ -182,6 +209,16 @@ def bath_collider( bath_x, bath_y = BATH_INTERIOR_SIZE wall_t = BATH_WALL_THICKNESS wall_z = 0.5 * BATH_WALL_HEIGHT + chute_panels = tuple( + bath_collider( + f"/World/Chutes/Panel_{index}", + CHUTE_PANEL_SIZE, + position, + orientation, + CHUTE_COLOR, + ) + for index, (position, orientation) in enumerate(CHUTE_PANEL_POSES) + ) @configclass class CoupledSceneCfg(InteractiveSceneCfg): @@ -213,6 +250,8 @@ class CoupledSceneCfg(InteractiveSceneCfg): (0.0, 0.5 * (bath_y + wall_t), wall_z), ) + chute_left_a, chute_left_b, chute_back_a, chute_back_b, chute_right_a, chute_right_b = chute_panels + spheres = RigidObjectCollectionCfg(rigid_objects=rigid_objects) sand = MPMObjectCfg( @@ -250,7 +289,7 @@ def main() -> None: from isaaclab.scene import InteractiveScene sim = sim_utils.SimulationContext(sim_cfg) - sim.set_camera_view(eye=(4.5, -5.5, 3.5), target=(0.0, 0.0, 0.65)) + sim.set_camera_view(eye=(6.0, -7.0, 5.0), target=(0.0, 0.4, 1.3)) scene = InteractiveScene(create_scene_cfg()) sim.reset() sand = scene["sand"] @@ -259,7 +298,7 @@ def main() -> None: f"[INFO]: Isaac Lab Newton two-way MPM demo ready. Spawned {particle_count} particles.", flush=True, ) - print("[INFO]: Right-click and drag a sphere in the Newton viewer.", flush=True) + print("[INFO]: Right-click and drag any sphere in the Newton viewer.", flush=True) run_simulator(sim, scene) From 2bea3e5c79e1fa4e46679bdc514cb8b26196c8e1 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 7 Aug 2026 17:15:30 -0700 Subject: [PATCH 22/22] Address Newton viewer review feedback --- source/isaaclab/test/app/standalone_script_cases.py | 6 +++--- .../isaaclab_visualizers/newton/newton_visualizer.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/source/isaaclab/test/app/standalone_script_cases.py b/source/isaaclab/test/app/standalone_script_cases.py index 43b6f31e2390..ce53c529c5b7 100644 --- a/source/isaaclab/test/app/standalone_script_cases.py +++ b/source/isaaclab/test/app/standalone_script_cases.py @@ -163,7 +163,7 @@ class SmokeResult: args=("--max_steps", "2", "--voxel_size", "0.2"), readiness_pattern=r"Newton two-way MPM demo ready", fixed_physics_backend="newton_coupler", - visualizers=("newton",), + visualizers=("newton_gl",), required_modules=("isaaclab_contrib",), ), "scripts/demos/mpm/particle_pour.py": ScriptOverride( @@ -175,13 +175,13 @@ class SmokeResult: "scripts/demos/newton_viewer_block_and_tackle.py": ScriptOverride( args=("--max_steps", "20"), fixed_physics_backend="newton_vbd", - visualizers=("newton",), + visualizers=("newton_gl",), required_modules=("isaaclab_contrib",), ), "scripts/demos/newton_viewer_dominoes.py": ScriptOverride( args=("--max_steps", "20"), fixed_physics_backend="newton_xpbd", - visualizers=("newton",), + visualizers=("newton_gl",), ), "scripts/demos/sensors/cameras.py": ScriptOverride(args=("--num_envs", "1"), startup_timeout=600.0), "scripts/demos/sensors/multi_mesh_raycaster.py": ScriptOverride( diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index dd9847d310e0..c2164c4baea2 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -376,7 +376,7 @@ def _render_left_panel(_g=gui): imgui.text("WASD - Move camera") imgui.text("QE - Pan up/down") imgui.text("Left Click - Look around") - imgui.text("Right Click - Pick objects") + imgui.text("Right Click - Pick and drag objects") imgui.text("Middle Click - Orbit") imgui.text("Shift + Middle Click - Pan") imgui.text("Ctrl + Middle Click - Dolly")