diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3ddad1bb5f45..9a8e7938e4a5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -430,6 +430,7 @@ jobs: standalone-script-scope: "demos" standalone-script-visualizer: "none" standalone-script-runtime-group: kit + warp-cache: restore result-file: "test-standalone-demos-kit-report.xml" container-name: isaac-lab-standalone-demos-kit-test omni-github-test-type: standalone-demo @@ -460,6 +461,7 @@ jobs: standalone-script-scope: "demos" standalone-script-visualizer: "none" standalone-script-runtime-group: non-kit + warp-cache: restore result-file: "test-standalone-demos-non-kit-report.xml" container-name: isaac-lab-standalone-demos-non-kit-test omni-github-test-type: standalone-demo diff --git a/.github/workflows/wheel.yml b/.github/workflows/wheel.yml index e2237a534faa..65fa4caab2c3 100644 --- a/.github/workflows/wheel.yml +++ b/.github/workflows/wheel.yml @@ -44,6 +44,7 @@ jobs: # run only when inputs that can affect the wheel have changed. patterns=( $'^apps/\tStandalone apps packaged in the wheel' + $'^examples/\tDemos and examples packaged in the wheel' $'^pyproject\.toml$\tWheel dependency metadata and resolver overrides' $'^VERSION$\tPackage version' $'^source/\tPython packages' diff --git a/docs/source/_static/css/demo-browser.js b/docs/source/_static/css/demo-browser.js index 3e46351774e1..a7a456355eac 100644 --- a/docs/source/_static/css/demo-browser.js +++ b/docs/source/_static/css/demo-browser.js @@ -16,15 +16,15 @@ return; } - const cards = [...browser.querySelectorAll("[data-demo-path]")]; + const cards = [...browser.querySelectorAll("[data-demo-id]")]; const fields = Object.fromEntries( [...browser.querySelectorAll("[data-demo-field]")].map((field) => [field.dataset.demoField, field]) ); const commandOutput = browser.querySelector("[data-command-output]"); const copyButton = browser.querySelector("[data-copy-command]"); const copyStatus = browser.querySelector("[data-copy-status]"); - const selectedName = browser.querySelector("[data-demo-name]:not([data-demo-path])"); - const selectedDescription = browser.querySelector("[data-demo-description]:not([data-demo-path])"); + const selectedName = browser.querySelector("[data-demo-name]:not([data-demo-id])"); + const selectedDescription = browser.querySelector("[data-demo-description]:not([data-demo-id])"); let selectedCard = cards[0]; const populateSelect = (select, values, preferredValues) => { @@ -50,17 +50,20 @@ if (fields.physics.value === "ovphysx") { requiredExtras.add("ovphysx"); } + if (fields.visualizer.value === "newton_rtx") { + requiredExtras.add("ovrtx"); + } if (["rerun", "viser"].includes(fields.visualizer.value)) { requiredExtras.add(fields.visualizer.value); } - const extraOrder = ["isaacsim", "ovphysx", "tetrahedralization", "teleop", "rerun", "viser"]; + const extraOrder = ["isaacsim", "ovphysx", "ovrtx", "tetrahedralization", "teleop", "rerun", "viser"]; const extras = extraOrder.filter((extra) => requiredExtras.has(extra)); - const parts = ["uv", "run"]; + const parts = ["uvx"]; if (extras.length) { - parts.push("--extra", extras.join(",")); + parts.push("--from", `'isaaclab[${extras.join(",")}]'`); } - parts.push("python", selectedCard.dataset.demoPath); + parts.push("isaaclab", "demo", selectedCard.dataset.demoId); if (selectedCard.dataset.demoFixedPhysics !== "true") { parts.push("--physics", fields.physics.value); } @@ -90,7 +93,7 @@ } selectedName.textContent = selectedCard.dataset.demoName; selectedDescription.textContent = selectedCard.dataset.demoDescription; - populateSelect(fields.physics, splitValues(selectedCard.dataset.demoPhysics), ["isaacsim_physx", "newton_mjwarp"]); + populateSelect(fields.physics, splitValues(selectedCard.dataset.demoPhysics), ["newton_mjwarp", "isaacsim_physx"]); updateVisualizer(); }; diff --git a/docs/source/api/lab/isaaclab.app.rst b/docs/source/api/lab/isaaclab.app.rst index 06738c0ae908..8ca0909bfcd9 100644 --- a/docs/source/api/lab/isaaclab.app.rst +++ b/docs/source/api/lab/isaaclab.app.rst @@ -63,13 +63,13 @@ To set the environment variables, one can use the following command in the termi export LIVESTREAM=2 # run the python script - uv run --extra isaacsim python scripts/demos/quadrupeds.py + uv run --extra isaacsim isaaclab demo zoo --physics isaacsim_physx --viz kit Alternatively, set the environment variable inline for a single invocation: .. code-block:: bash - LIVESTREAM=2 uv run --extra isaacsim python scripts/demos/quadrupeds.py + LIVESTREAM=2 uv run --extra isaacsim isaaclab demo zoo --physics isaacsim_physx --viz kit .. tab-item:: :icon:`fa-brands fa-linux` Linux aarch64 (DGX Spark) :sync: linux-aarch64 @@ -78,13 +78,13 @@ To set the environment variables, one can use the following command in the termi export LIVESTREAM=2 # run the python script - LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1 uv run --extra isaacsim python scripts/demos/quadrupeds.py + LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1 uv run --extra isaacsim isaaclab demo zoo --physics isaacsim_physx --viz kit Alternatively, set the environment variable inline for a single invocation: .. code-block:: bash - LIVESTREAM=2 LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1 uv run --extra isaacsim python scripts/demos/quadrupeds.py + LIVESTREAM=2 LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1 uv run --extra isaacsim isaaclab demo zoo --physics isaacsim_physx --viz kit .. note:: @@ -106,14 +106,14 @@ To set the environment variables, one can use the following command in the termi .. code-block:: batch set LIVESTREAM=2 - uv run --extra isaacsim python scripts/demos/quadrupeds.py + uv run --extra isaacsim isaaclab demo zoo --physics isaacsim_physx --viz kit In PowerShell: .. code-block:: powershell $env:LIVESTREAM = "2" - uv run --extra isaacsim python scripts/demos/quadrupeds.py + uv run --extra isaacsim isaaclab demo zoo --physics isaacsim_physx --viz kit .. note:: diff --git a/docs/source/concepts/deformables.rst b/docs/source/concepts/deformables.rst index 53b16d654324..73381f5fa667 100644 --- a/docs/source/concepts/deformables.rst +++ b/docs/source/concepts/deformables.rst @@ -387,7 +387,7 @@ where ``L_parent`` and ``L_child`` are the rest lengths of the two segments it s Lab authors has no attribute for it. To target a specific axial ``E * A`` or bending ``E * I``, invert these relations to pick the -modulus; ``scripts/demos/cables.py`` does this from a target stiffness and the segment geometry. +modulus; ``examples/cables.py`` does this from a target stiffness and the segment geometry. Cable collision ^^^^^^^^^^^^^^^ @@ -571,38 +571,38 @@ Cable winding, since leaving it unset falls back to the bend value as described above. -Demos and tasks ---------------- +Examples and tasks +------------------ -Run a demo first to confirm that the spawner, solver, and visualizer all work in your environment. +Run an example first to confirm that the spawner, solver, and visualizer all work in your environment. .. list-table:: :header-rows: 1 :widths: 20 44 36 * - Kind - - Demo + - Example - Tasks * - Volume - - ``scripts/demos/deformables.py`` + - ``deformables`` - ``Isaac-Lift-Soft-Franka``, ``Isaac-Lift-Soft-Franka-Camera`` * - Surface - - ``scripts/demos/deformables.py`` + - ``deformables`` - ``Isaac-Lift-Cloth-Franka``, ``Isaac-Lift-Cloth-Franka-Camera`` * - Cable - - ``scripts/demos/cables.py`` + - ``cables`` - ``Isaac-Lift-Cable-Franka``, ``Isaac-Lift-Cable-Franka-Camera`` .. code-block:: bash # Volume and surface deformables falling onto a ground plane. - uv run --extra isaacsim --extra tetrahedralization python scripts/demos/deformables.py + uv run --extra tetrahedralization isaaclab example deformables # A pile of cables that collide and settle. Newton VBD only. - uv run --extra isaacsim python scripts/demos/cables.py + uv run isaaclab example cables # A larger cable pile, without a visualizer, stopping after a fixed number of steps. - uv run python scripts/demos/cables.py --visualizer none --num_cables 40 --num_segments 15 --max_steps 500 + uv run isaaclab example cables --visualizer none --num_cables 40 --num_segments 15 --max_steps 500 ``scripts/environments/state_machine/lift_franka_soft.py`` drives ``Isaac-Lift-Soft-Franka`` with a scripted state machine, which is a useful starting point for a deformable manipulation task. diff --git a/docs/source/concepts/sensors/camera.rst b/docs/source/concepts/sensors/camera.rst index ba45a1978224..029173705153 100644 --- a/docs/source/concepts/sensors/camera.rst +++ b/docs/source/concepts/sensors/camera.rst @@ -320,11 +320,11 @@ configuration and discovered USD attributes are fixed for the camera lifetime. separately authored RTX exposure or tonemapping settings. When ``isp_cfg`` is ``None``, the renderer leaves authored camera exposure unchanged. -Run ``scripts/demos/sensors/ppisp_camera.py`` for a complete PPISP workflow: +Run the ``ppisp-camera`` example for a complete PPISP workflow: .. code-block:: bash - uv run --extra isaacsim python scripts/demos/sensors/ppisp_camera.py \ + uv run --extra isaacsim isaaclab example ppisp-camera \ --renderer newton_renderer --max_steps 60 Performance and validation @@ -340,11 +340,11 @@ cost of the de-tiled outputs or downstream vision models. The camera follows the ``update_period`` contract; choose a period that matches the observation cadence instead of rendering at every physics step by default. -A runnable camera example is available in ``scripts/demos/sensors/cameras.py``: +A runnable camera example is available as ``camera``: .. code-block:: bash - uv run --extra isaacsim python scripts/demos/sensors/cameras.py + uv run --extra isaacsim isaaclab example camera For saving output to disk, see :doc:`/source/how-to/save_camera_output`. For renderer selection and customization, see :doc:`/source/how-to/configure_rendering`. diff --git a/docs/source/concepts/sensors/contact_sensor.rst b/docs/source/concepts/sensors/contact_sensor.rst index d05b19e2e995..31fe880bf28f 100644 --- a/docs/source/concepts/sensors/contact_sensor.rst +++ b/docs/source/concepts/sensors/contact_sensor.rst @@ -137,9 +137,8 @@ sensor contacts but does not change the reported data. :figwidth: 100% :alt: Contact sensor debug visualization -A complete runnable example is available in -``scripts/demos/sensors/contact_sensor.py``: +A complete runnable example is available as ``contact-sensor``: .. code-block:: bash - uv run --extra isaacsim python scripts/demos/sensors/contact_sensor.py + uv run isaaclab example contact-sensor diff --git a/docs/source/concepts/sensors/frame_transformer.rst b/docs/source/concepts/sensors/frame_transformer.rst index b8d597ea187c..407d92fb0d87 100644 --- a/docs/source/concepts/sensors/frame_transformer.rst +++ b/docs/source/concepts/sensors/frame_transformer.rst @@ -66,9 +66,8 @@ the offset source frame in world coordinates. Positions are in meters; quaternio :figwidth: 100% :alt: Frame transformer debug visualization -A complete runnable example is available in -``scripts/demos/sensors/frame_transformer_sensor.py``: +A complete runnable example is available as ``frame-transformer``: .. code-block:: bash - uv run --extra isaacsim python scripts/demos/sensors/frame_transformer_sensor.py + uv run --extra isaacsim isaaclab example frame-transformer diff --git a/docs/source/concepts/sensors/imu.rst b/docs/source/concepts/sensors/imu.rst index 6349ca26b72c..37390db65caa 100644 --- a/docs/source/concepts/sensors/imu.rst +++ b/docs/source/concepts/sensors/imu.rst @@ -60,8 +60,8 @@ with the control loop that consumes the measurement. :figwidth: 100% :alt: IMU acceleration debug visualization -A complete runnable example is available in ``scripts/demos/sensors/imu_sensor.py``: +A complete runnable example is available as ``imu``: .. code-block:: bash - uv run --extra isaacsim python scripts/demos/sensors/imu_sensor.py + uv run --extra isaacsim isaaclab example imu diff --git a/docs/source/concepts/sensors/pva.rst b/docs/source/concepts/sensors/pva.rst index 143a63159f42..17c2df46b61c 100644 --- a/docs/source/concepts/sensors/pva.rst +++ b/docs/source/concepts/sensors/pva.rst @@ -72,8 +72,8 @@ where Torch operations are required: Like the IMU, acceleration uses state from consecutive simulation updates. Reset the scene and sensor state together at episode boundaries. -A complete runnable example is available in ``scripts/demos/sensors/pva_sensor.py``: +A complete runnable example is available as ``pva``: .. code-block:: bash - uv run --extra isaacsim python scripts/demos/sensors/pva_sensor.py + uv run --extra isaacsim isaaclab example pva diff --git a/docs/source/concepts/sensors/ray_caster.rst b/docs/source/concepts/sensors/ray_caster.rst index da3068791602..e952c4131ef7 100644 --- a/docs/source/concepts/sensors/ray_caster.rst +++ b/docs/source/concepts/sensors/ray_caster.rst @@ -24,9 +24,9 @@ explicit and to access Newton-specific options. Using a ray caster sensor requires a **pattern** and a parent xform to be attached to. The pattern defines how the rays are cast, while the prim properties defines the orientation and position of the sensor (additional offsets can be specified for more exact placement). Isaac Lab supports a number of ray casting pattern configurations, including a generic LIDAR and grid pattern. -.. literalinclude:: ../../../../scripts/demos/sensors/raycaster_sensor.py +.. literalinclude:: ../../../../examples/sensors/raycaster_sensor.py :language: python - :lines: 40-71 + :pyobject: RaycasterSensorSceneCfg Notice that the units on the pattern config is in degrees! Also, we enable visualization here to explicitly show the pattern in the rendering, but this is not required and should be disabled for performance tuning. @@ -80,6 +80,6 @@ You can use this script to experiment with pattern configurations and build an i .. dropdown:: Code for raycaster_sensor.py :icon: code - .. literalinclude:: ../../../../scripts/demos/sensors/raycaster_sensor.py + .. literalinclude:: ../../../../examples/sensors/raycaster_sensor.py :language: python :linenos: diff --git a/docs/source/concepts/using_mpm.rst b/docs/source/concepts/using_mpm.rst index 451278521ef9..3c38a8d37b67 100644 --- a/docs/source/concepts/using_mpm.rst +++ b/docs/source/concepts/using_mpm.rst @@ -5,8 +5,8 @@ Using Implicit MPM Newton's implicit Material Point Method (MPM) solver models particle materials such as granular media. MPM support and rigid-MPM coupling are experimental. -Start with the compact ``scripts/demos/mpm/newton_mpm_granular.py`` example; -``snowball_smash.py`` adds coupling and ``teapot_fill.py`` adds cavity sampling. +Start with the compact ``mpm-granular`` example; the ``snowball-smash`` and ``teapot-fill`` demos provide polished +coupling and cavity-sampling showcases. .. _franka-pour-reset-artifact: @@ -126,13 +126,13 @@ material behavior. Run the teapot example to compare the available modes: .. code-block:: bash # Reconstructed surface (default) - uv run python scripts/demos/mpm/teapot_fill.py --device cuda:0 \ + uv run isaaclab demo teapot-fill --device cuda:0 \ --visualizer newton_gl --fluid_render_mode surface # Surface and source particles together - uv run python scripts/demos/mpm/teapot_fill.py --device cuda:0 \ + uv run isaaclab demo teapot-fill --device cuda:0 \ --visualizer newton_gl --fluid_render_mode both # Path-traced translucent surface - uv run --extra ovrtx python scripts/demos/mpm/teapot_fill.py --device cuda:0 \ + uv run --extra ovrtx isaaclab demo teapot-fill --device cuda:0 \ --visualizer newton_rtx --fluid_render_mode surface Surface rendering is available in the Newton GL and Newton RTX visualizers. @@ -163,7 +163,7 @@ dynamic topology in one reusable helper: .. dropdown:: ``FluidSurfaceRenderer`` implementation :icon: code - .. literalinclude:: ../../../scripts/demos/mpm/teapot_fill.py + .. literalinclude:: ../../../examples/demos/teapot_fill.py :language: python :pyobject: FluidSurfaceRenderer diff --git a/docs/source/concepts/visualization.rst b/docs/source/concepts/visualization.rst index 74d48868fa6a..413f581ae28a 100644 --- a/docs/source/concepts/visualization.rst +++ b/docs/source/concepts/visualization.rst @@ -208,7 +208,7 @@ Visualizer Overview -

newton_viewer_dominoes demo
Right-click dragging the first domino +

newton-dominoes example
Right-click dragging the first domino triggers the cascade across an NVIDIA-logo domino layout

diff --git a/docs/source/experimental-features/visuo_tactile_sensor.rst b/docs/source/experimental-features/visuo_tactile_sensor.rst index cb936240a4ba..e3ce8193ef9b 100644 --- a/docs/source/experimental-features/visuo_tactile_sensor.rst +++ b/docs/source/experimental-features/visuo_tactile_sensor.rst @@ -110,7 +110,7 @@ Configuration Requirements Usage Example ~~~~~~~~~~~~~ -To use the tactile sensor in a simulation environment, run the demo: +To use the tactile sensor in a simulation environment, run the example: .. tab-set:: @@ -118,13 +118,13 @@ To use the tactile sensor in a simulation environment, run the demo: .. code-block:: bash - uv run --extra isaacsim python scripts/demos/sensors/tacsl_sensor.py --use_tactile_rgb --use_tactile_ff --tactile_compliance_stiffness 100.0 --tactile_compliant_damping 1.0 --contact_object_type nut --num_envs 16 --save_viz --viz kit + uv run --extra isaacsim isaaclab example tactile-sensor --use_tactile_rgb --use_tactile_ff --tactile_compliance_stiffness 100.0 --tactile_compliant_damping 1.0 --contact_object_type nut --num_envs 16 --save_viz --viz kit .. tab-item:: isaaclab.sh / isaaclab.bat .. code-block:: bash - ./isaaclab.sh -p scripts/demos/sensors/tacsl_sensor.py --use_tactile_rgb --use_tactile_ff --tactile_compliance_stiffness 100.0 --tactile_compliant_damping 1.0 --contact_object_type nut --num_envs 16 --save_viz --viz kit + ./isaaclab.sh example tactile-sensor --use_tactile_rgb --use_tactile_ff --tactile_compliance_stiffness 100.0 --tactile_compliant_damping 1.0 --contact_object_type nut --num_envs 16 --save_viz --viz kit Available command-line options include: @@ -146,7 +146,7 @@ Available command-line options include: .. note:: Since Isaac Lab 3.0, visualizers are selected independently from the simulation backend. Use the ``--viz`` - argument to choose the visualizer backend, such as ``kit`` or ``newton``. The TacSL demo currently supports + argument to choose the visualizer backend, such as ``kit`` or ``newton``. The TacSL example currently supports only the PhysX simulation backend; running the tactile sensor simulation with the Newton backend is not supported. @@ -159,16 +159,16 @@ For a complete list of available options: .. code-block:: bash - uv run --extra isaacsim python scripts/demos/sensors/tacsl_sensor.py -h + uv run --extra isaacsim isaaclab example tactile-sensor -h .. tab-item:: isaaclab.sh / isaaclab.bat .. code-block:: bash - ./isaaclab.sh -p scripts/demos/sensors/tacsl_sensor.py -h + ./isaaclab.sh example tactile-sensor -h .. note:: - The demo examples are based on the Gelsight R1.5, which is a prototype sensor that is now discontinued. The same procedure can be adapted for other visuotactile sensors. + The examples are based on the Gelsight R1.5, which is a prototype sensor that is now discontinued. The same procedure can be adapted for other visuotactile sensors. .. figure:: ../_static/overview/sensors/tacsl_demo.jpg :align: center diff --git a/docs/source/features/draw_markers.rst b/docs/source/features/draw_markers.rst index 7632fd62b011..5182d33c0731 100644 --- a/docs/source/features/draw_markers.rst +++ b/docs/source/features/draw_markers.rst @@ -17,7 +17,7 @@ Supported on Kit, Newton GL, Rerun, and Viser; not yet on Newton RTX. See Quick Start ----------- -This guide is accompanied by ``markers.py`` in ``IsaacLab/scripts/demos``. +This guide is accompanied by the packaged ``markers`` example. .. tab-set:: @@ -25,13 +25,13 @@ This guide is accompanied by ``markers.py`` in ``IsaacLab/scripts/demos``. .. code-block:: bash - uv run --extra isaacsim python scripts/demos/markers.py + uv run --extra isaacsim isaaclab example markers .. tab-item:: isaaclab.sh / isaaclab.bat .. code-block:: bash - ./isaaclab.sh -p scripts/demos/markers.py + ./isaaclab.sh example markers Pass ``--visualizer newton_gl`` (or another supported backend) to switch visualizers; defaults to ``kit``. @@ -39,7 +39,7 @@ to ``kit``. .. figure:: ../_static/demos/markers.jpg :width: 100% - Every marker prototype from the demo script, arranged in a grid. Each column rotates in + Every marker prototype from the example script, arranged in a grid. Each column rotates in place and periodically rolls forward to the next prototype. To stop, close the window or press ``Ctrl+C``. @@ -47,9 +47,8 @@ To stop, close the window or press ``Ctrl+C``. .. dropdown:: Code for markers.py :icon: code - .. literalinclude:: ../../../scripts/demos/markers.py + .. literalinclude:: ../../../examples/markers.py :language: python - :emphasize-lines: 48-96, 106-107, 146 :linenos: @@ -69,10 +68,9 @@ Configuring markers Physics properties on a marker prototype's spawn config are stripped on creation, since markers are not simulated. -.. literalinclude:: ../../../scripts/demos/markers.py +.. literalinclude:: ../../../examples/markers.py :language: python - :lines: 50-96 - :dedent: + :pyobject: define_markers Drawing markers @@ -81,9 +79,10 @@ Drawing markers :meth:`~markers.VisualizationMarkers.visualize` sets marker poses and, optionally, which prototype each marker instance uses via ``marker_indices``. -.. literalinclude:: ../../../scripts/demos/markers.py +.. literalinclude:: ../../../examples/markers.py :language: python - :lines: 144-146 + :start-at: my_visualizer.visualize + :end-at: my_visualizer.visualize :dedent: Arguments left as ``None`` keep their previous value. Passing a different number of rows than diff --git a/docs/source/how-to/haply_teleoperation.rst b/docs/source/how-to/haply_teleoperation.rst index 421be153c47d..9a7f961d024c 100644 --- a/docs/source/how-to/haply_teleoperation.rst +++ b/docs/source/how-to/haply_teleoperation.rst @@ -149,10 +149,10 @@ You should see device data streaming from both Inverse3 and VerseGrip. .. _haply-running-demo: -Running the Demo ----------------- +Running the Example +------------------- -The Haply teleoperation demo showcases robot manipulation with force feedback using +The Haply teleoperation example showcases robot manipulation with force feedback using a Franka Panda arm. Basic Usage @@ -161,9 +161,9 @@ Basic Usage .. code:: bash # Ensure Haply SDK is running - python scripts/demos/haply_teleoperation.py --websocket_uri ws://localhost:10001 --pos_sensitivity 1.65 + isaaclab example haply-teleoperation --websocket_uri ws://localhost:10001 --pos_sensitivity 1.65 -The demo will: +The example will: 1. Connect to the Haply devices via WebSocket 2. Spawn a Franka Panda robot and a cube in simulation @@ -181,18 +181,18 @@ Controls Advanced Options ~~~~~~~~~~~~~~~~ -Customize the demo with command-line arguments: +Customize the example with command-line arguments: .. code:: bash # Use custom WebSocket URI - python scripts/demos/haply_teleoperation.py --websocket_uri ws://192.168.1.100:10001 + isaaclab example haply-teleoperation --websocket_uri ws://192.168.1.100:10001 # Adjust position sensitivity (default: 1.0) - python scripts/demos/haply_teleoperation.py --websocket_uri ws://localhost:10001 --pos_sensitivity 2.0 + isaaclab example haply-teleoperation --websocket_uri ws://localhost:10001 --pos_sensitivity 2.0 -Demo Features -~~~~~~~~~~~~~ +Example Features +~~~~~~~~~~~~~~~~ * **Workspace Mapping**: Haply workspace is mapped to robot reachable space with safety limits * **Inverse Kinematics**: Inverse Kinematics (IK) computes joint positions for desired end-effector pose @@ -219,7 +219,7 @@ Solutions: Next Steps ---------- -* **Customize the demo**: Modify the workspace mapping or add custom button behaviors +* **Customize the example**: Modify the workspace mapping or add custom button behaviors * **Implement your own controller**: Use :class:`~isaaclab.devices.HaplyDevice` in your own scripts For more information on device APIs, see :class:`~isaaclab.devices.HaplyDevice` in the API documentation. diff --git a/docs/source/how-to/multi_asset_spawning.rst b/docs/source/how-to/multi_asset_spawning.rst index d133e565bc55..6850c9c5628f 100644 --- a/docs/source/how-to/multi_asset_spawning.rst +++ b/docs/source/how-to/multi_asset_spawning.rst @@ -14,17 +14,16 @@ prim paths resolved from an expression. Multi-asset workflows cover two related This guide demonstrates both mechanisms and explains how their execution differs between PhysX and Newton. -The sample script ``multi_asset.py`` is used as a reference, located in the -``IsaacLab/scripts/demos`` directory. +The packaged ``multi-asset`` example provides the reference script, ``multi_asset.py``. .. dropdown:: Code for multi_asset.py :icon: code - .. literalinclude:: ../../../scripts/demos/multi_asset.py + .. literalinclude:: ../../../examples/multi_asset.py :language: python :linenos: -With the default PhysX configuration, this script creates multiple environments containing: +With the default Newton configuration, this script creates multiple environments containing: * a rigid object collection containing a sphere, a cube, and a cylinder * a rigid object selected from nine geometry and material variants by the clone plan @@ -43,7 +42,7 @@ want to access them as one batch. The collection exposes data with an ``(env, ob ``(env_ids, obj_ids)`` selections for commands. Compared with managing each object separately, the collection uses one batched physics view. -.. literalinclude:: ../../../scripts/demos/multi_asset.py +.. literalinclude:: ../../../examples/multi_asset.py :language: python :start-at: object_collection: RigidObjectCollectionCfg = RigidObjectCollectionCfg( :end-before: # articulation @@ -52,9 +51,9 @@ batched physics view. The :class:`~assets.RigidObjectCollectionCfg` configuration owns a dictionary of :class:`~assets.RigidObjectCfg` instances. Each dictionary key is the object's stable identifier within the collection. -The demo resets all collection members through the same API used by both physics backends: +The example resets all collection members through the same API used by both physics backends: -.. literalinclude:: ../../../scripts/demos/multi_asset.py +.. literalinclude:: ../../../examples/multi_asset.py :language: python :start-at: default_pose_w = rigid_object_collection.data.default_body_pose.torch.clone() :end-at: rigid_object_collection.write_body_com_velocity_to_sim_index(body_velocities=default_vel_w) @@ -71,7 +70,7 @@ plan and assigns one valid prototype combination to each environment. For configuration-based assets, assign :class:`~sim.spawners.wrappers.MultiAssetSpawnerCfg` to the :class:`~assets.RigidObjectCfg` spawn configuration: -.. literalinclude:: ../../../scripts/demos/multi_asset.py +.. literalinclude:: ../../../examples/multi_asset.py :language: python :start-at: object: RigidObjectCfg = RigidObjectCfg( :end-before: # object collection @@ -90,7 +89,7 @@ round-robin order. To sample combinations randomly instead, set the strategy bef For USD assets, assign :class:`~sim.spawners.wrappers.MultiUsdFileCfg` to the :class:`~assets.ArticulationCfg` spawn configuration: -.. literalinclude:: ../../../scripts/demos/multi_asset.py +.. literalinclude:: ../../../examples/multi_asset.py :language: python :start-at: robot: ArticulationCfg = ArticulationCfg( :end-before: ## @@ -112,10 +111,10 @@ environments. Do not disable physics replication merely because a scene uses a m ``replicate_physics=False`` for per-environment stage differences that cannot be represented as clone variants; that mode is not supported by the Newton backend. -The demo keeps physics replication enabled. For Newton, it also narrows the standalone object and articulation to one +The example keeps physics replication enabled. For Newton, it also narrows the standalone object and articulation to one variant because their batched Newton views currently require a uniform body layout across worlds: -.. literalinclude:: ../../../scripts/demos/multi_asset.py +.. literalinclude:: ../../../examples/multi_asset.py :language: python :start-at: scene_cfg = MultiObjectSceneCfg(num_envs=args_cli.num_envs :end-at: scene_cfg.robot.spawn.usd_path = scene_cfg.robot.spawn.usd_path[0] @@ -123,8 +122,8 @@ variant because their batched Newton views currently require a uniform body layo For more detail on prototype assignment and replication, see :doc:`cloning`. -Run the demo ------------- +Run the example +--------------- The physics backend and visualizer are selected independently. Run one of these commands from the repository root: @@ -134,20 +133,21 @@ The physics backend and visualizer are selected independently. Run one of these .. code-block:: bash - uv run --extra isaacsim python scripts/demos/multi_asset.py --num_envs 2048 + uv run --extra isaacsim isaaclab example multi-asset \ + --physics isaacsim_physx --visualizer kit --num_envs 2048 .. tab-item:: Newton MJWarp with Kit .. code-block:: bash - uv run --extra isaacsim python scripts/demos/multi_asset.py \ - --physics newton_mjwarp --num_envs 2048 + uv run --extra isaacsim isaaclab example multi-asset \ + --physics newton_mjwarp --visualizer kit --num_envs 2048 .. tab-item:: Newton MJWarp with Newton GL .. code-block:: bash - uv run python scripts/demos/multi_asset.py \ + uv run isaaclab example multi-asset \ --physics newton_mjwarp --visualizer newton_gl --num_envs 2048 The Newton commands exercise the same :class:`~assets.RigidObjectCollectionCfg` and ``(env_ids, obj_ids)`` APIs as the diff --git a/docs/source/how-to/run_articulation.rst b/docs/source/how-to/run_articulation.rst index b0034014e675..7fdd6dd53795 100644 --- a/docs/source/how-to/run_articulation.rst +++ b/docs/source/how-to/run_articulation.rst @@ -153,8 +153,7 @@ In this tutorial, we learned how to create and interact with a simple articulati of an articulation (its root and joint state) and how to apply commands to it. We also saw how to update its buffers to read the latest state from the simulation. -In addition to this tutorial, we also provide a few other scripts that spawn different robots. These are included -in the ``scripts/demos`` directory. You can run these scripts as: +The packaged Zoo demo also animates several robot families in one scene: .. tab-set:: @@ -162,17 +161,9 @@ in the ``scripts/demos`` directory. You can run these scripts as: .. code-block:: bash - # Spawn many different single-arm manipulators - uv run isaaclab -p scripts/demos/arms.py --viz kit - - # Spawn many different quadrupeds - uv run isaaclab -p scripts/demos/quadrupeds.py --viz kit + uv run --extra isaacsim isaaclab demo zoo --viz kit .. tab-item:: isaaclab.sh / isaaclab.bat .. code-block:: bash - # Spawn many different single-arm manipulators - ./isaaclab.sh -p scripts/demos/arms.py --viz kit - - # Spawn many different quadrupeds - ./isaaclab.sh -p scripts/demos/quadrupeds.py --viz kit + ./isaaclab.sh demo zoo --viz kit diff --git a/docs/source/how-to/run_deformable_object.rst b/docs/source/how-to/run_deformable_object.rst index 043bd22b48ad..e37be5174e0d 100644 --- a/docs/source/how-to/run_deformable_object.rst +++ b/docs/source/how-to/run_deformable_object.rst @@ -11,7 +11,7 @@ Interacting with a deformable object While deformable objects sometimes refer to a broader class of objects, such as cloths, fluids and soft bodies, Isaac Lab represents deformable objects as either surface or volume deformables. Unlike rigid objects, soft bodies can deform under external forces and collisions. In this tutorial, we focus on volume deformable bodies. For an example of -surface deformables (cloth), see the deformable demo at ``scripts/demos/deformables.py``. +surface deformables (cloth), run the ``deformables`` example. The deformable object API and schema define/modify functions are shared across backends, while deformable property and material configuration classes are backend-specific. PhysX simulates soft bodies using the Finite @@ -225,7 +225,8 @@ To stop the simulation, you can either close the window, or press ``Ctrl+C`` in This tutorial showed how to spawn deformable objects and wrap them in a :class:`DeformableObject` class to initialize their physics handles which allows setting and obtaining their state. We also saw how to apply kinematic commands to the -deformable object to move the mesh nodes in a controlled manner. An advanced demo of deformable objects, including surface deformables and loading USD assets and applying deformable material on them, can be found in ``scripts/demos/deformables.py``. In the next tutorial, we will see how to create +deformable object to move the mesh nodes in a controlled manner. The ``deformables`` example provides a more advanced +example, including surface deformables, loading USD assets, and applying deformable materials. In the next tutorial, we will see how to create a scene using the :class:`InteractiveScene` class. .. _PhysX documentation: https://nvidia-omniverse.github.io/PhysX/physx/5.4.1/docs/SoftBodies.html diff --git a/docs/source/how-to/run_surface_gripper.rst b/docs/source/how-to/run_surface_gripper.rst index 0eb0626eb78d..71a74fde5844 100644 --- a/docs/source/how-to/run_surface_gripper.rst +++ b/docs/source/how-to/run_surface_gripper.rst @@ -163,7 +163,7 @@ In this tutorial, we learned how to create and interact with a surface gripper. query the gripper state. We also saw how to update its buffers to read the latest state from the simulation. In addition to this tutorial, we also provide a few other scripts that spawn different robots. These are included -in the ``scripts/demos`` directory. You can run these scripts as: +through the packaged demo command. You can run it as: .. tab-set:: @@ -172,14 +172,14 @@ in the ``scripts/demos`` directory. You can run these scripts as: .. code-block:: bash # Spawn many pick-and-place robots and perform a pick-and-place task - uv run --extra isaacsim python scripts/demos/pick_and_place.py --viz kit + uv run --extra isaacsim isaaclab demo pick-and-place --viz kit .. tab-item:: isaaclab.sh / isaaclab.bat .. code-block:: bash # Spawn many pick-and-place robots and perform a pick-and-place task - ./isaaclab.sh -p scripts/demos/pick_and_place.py --viz kit + ./isaaclab.sh demo pick-and-place --viz kit Note that in practice, the users would be expected to register their :class:`assets.SurfaceGripper` instances inside a :class:`isaaclab.InteractiveScene` object, which will automatically handle the calls to the diff --git a/docs/source/migration/include/deformables.rst b/docs/source/migration/include/deformables.rst index 64b708cf8465..3caa0ab5f66e 100644 --- a/docs/source/migration/include/deformables.rst +++ b/docs/source/migration/include/deformables.rst @@ -127,7 +127,7 @@ schema. See the class reference and the `PhysX deformable schema`_ for the curre ``physx.DeformableBodyView``, and ``root_physx_view`` is deprecated in favor of ``root_view``. For runnable volume, surface, and USD-asset examples, see the -:ref:`tutorial-interact-deformable-object` tutorial and ``scripts/demos/deformables.py``. +:ref:`tutorial-interact-deformable-object` tutorial and ``examples/deformables.py``. .. _PhysX deformable schema: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/110.0/dev_guide/deformables/physx_deformable_schema.html#physxbasedeformablebodyapi diff --git a/docs/source/refs/troubleshooting.rst b/docs/source/refs/troubleshooting.rst index 16f171bcd463..8a7374863836 100644 --- a/docs/source/refs/troubleshooting.rst +++ b/docs/source/refs/troubleshooting.rst @@ -156,14 +156,14 @@ prompt when launching an Isaac Lab process: .. code:: bash - uv run --extra isaacsim python scripts/demos/bipeds.py --kit_args "--/persistent/physics/omniPvdOvdRecordingDirectory=/tmp/ --/physics/omniPvdOutputEnabled=true" + uv run --extra isaacsim isaaclab demo zoo --physics isaacsim_physx --viz kit --kit_args "--/persistent/physics/omniPvdOvdRecordingDirectory=/tmp/ --/physics/omniPvdOutputEnabled=true" .. tab-item:: isaaclab.sh / isaaclab.bat .. code:: bash - ./isaaclab.sh -p scripts/demos/bipeds.py --kit_args "--/persistent/physics/omniPvdOvdRecordingDirectory=/tmp/ --/physics/omniPvdOutputEnabled=true" + ./isaaclab.sh demo zoo --physics isaacsim_physx --viz kit --kit_args "--/persistent/physics/omniPvdOvdRecordingDirectory=/tmp/ --/physics/omniPvdOutputEnabled=true" GPU buffer capacity errors ~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/setup/demos.rst b/docs/source/setup/demos.rst index de2030b56315..ae4c7a9c1558 100644 --- a/docs/source/setup/demos.rst +++ b/docs/source/setup/demos.rst @@ -8,9 +8,24 @@ Demos ===== -Explore focused scripts that demonstrate Isaac Lab's robots, objects, sensors, and simulation features. -Choose a demo card, then select a supported physics backend and visualizer to build a ready-to-run command. -The command automatically includes the optional dependency groups required by the selection. +Demos are polished showcases of Isaac Lab capabilities. They ship in the ``isaaclab`` wheel, so you can run them +without a source checkout. Start with Zoo to see several robot families and simulation features in one scene, or run +``uvx isaaclab demo list`` to inspect the complete catalog. + +All programs live under the repository-level ``examples/`` directory: curated showcases in ``examples/demos/``, +focused programs for learning an API or tuning a feature in directories such as ``examples/mpm/`` and +``examples/sensors/``, and shared data in ``examples/assets/``. List focused programs with +``uvx isaaclab example list`` and run one with ``uvx isaaclab example ``. + +Demo and example commands show the same Isaac Lab startup screen as task playback while +their simulation initializes. Pass ``--info`` to keep startup messages visible. + +In any packaged demo or example running with ``--viz newton_gl``, the **Isaac Lab Programs** +panel shows the core showcases first, with focused examples in a separate collapsed section. +Selecting one closes the current simulation and relaunches it with ``--viz newton_gl``. +For example, run ``uvx isaaclab demo zoo --viz newton_gl`` to explore the catalog. +Programs whose required modules are unavailable, hardware-dependent teleoperation, and +Kit-only demos are not shown. Command Builder --------------- @@ -20,8 +35,8 @@ Command Builder
- - Arms + + Zoo
-The H1 locomotion and pick-and-place demos require interactive keyboard or mouse input. Haply teleoperation requires -Inverse3 and VerseGrip devices and a running Haply WebSocket service. Cables are a Newton-only asset; see -:doc:`../concepts/deformables` for details. +H1 locomotion uses a published policy. In the Newton viewer, press N to select a robot, I/J/L to walk +forward or turn, K to stop, and C to toggle the follow camera. Pick and place requires Kit input. For autonomous H1 task playback, use ``isaaclab play``. diff --git a/docs/source/setup/installation/index.rst b/docs/source/setup/installation/index.rst index e81c5b59a595..a2f64f63d13f 100644 --- a/docs/source/setup/installation/index.rst +++ b/docs/source/setup/installation/index.rst @@ -566,7 +566,7 @@ Isaac Lab Python package Use this path when Isaac Lab is a dependency of an external Python project. The released ``isaaclab`` package includes the unified ``train``, ``play``, ``zero_agent``, ``random_agent``, -``benchmark``, and ``train_multigpu`` commands. Repository demos and examples remain source-only. +``benchmark``, ``train_multigpu``, ``demo``, and ``example`` commands. Downstream projects can register their task package through the ``isaaclab.tasks`` Python package entry-point group; projects created by the template generator configure this automatically. diff --git a/scripts/demos/arl_robot_1.py b/examples/arl_robot_1.py similarity index 85% rename from scripts/demos/arl_robot_1.py rename to examples/arl_robot_1.py index d18402845c1b..2627e0509c53 100644 --- a/scripts/demos/arl_robot_1.py +++ b/examples/arl_robot_1.py @@ -8,15 +8,13 @@ .. code-block:: bash # Usage with default PhysX physics and default kit visualizer. - uv run python scripts/demos/arl_robot_1.py + uvx --from 'isaaclab[isaacsim]' isaaclab example arl-robot-1 # Usage with Newton visualizer and default PhysX physics. - uv run python scripts/demos/arl_robot_1.py --visualizer newton + uvx --from 'isaaclab[isaacsim]' isaaclab example arl-robot-1 --visualizer newton_gl """ -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - import argparse from isaaclab.app import add_launcher_args, launch_simulation @@ -26,18 +24,16 @@ conflict_handler="resolve", ) parser.add_argument("--physics", default="isaacsim_physx", choices=["isaacsim_physx"], help="Physics backend.") +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=["kit"]) args_cli = parser.parse_args() +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") import torch import isaaclab.sim as sim_utils -from isaaclab import cloner - -## -# Pre-defined configs -## from isaaclab.physics import PhysicsCfg from isaaclab_contrib.controllers.lee_position_control import LeePosController @@ -58,9 +54,6 @@ def main(): use_newton_actuators=False, ) sim = sim_utils.SimulationContext(sim_cfg) - global_paths = ("/World/DomeLight", "/World/defaultGroundPlane", "/World/Robot") - plan = cloner.make_clone_plan((), 1, 0.0, global_paths=global_paths) - sim.set_clone_plan(plan) # Create a dome light with light blue color light_cfg = sim_utils.DomeLightCfg(intensity=1000.0, color=(0.53, 0.81, 0.92)) @@ -74,7 +67,6 @@ def main(): robot_cfg = ARL_ROBOT_1_CFG.replace(prim_path="/World/Robot") robot_cfg.actuators["thrusters"].dt = sim_cfg.dt robot = robot_cfg.class_type(robot_cfg) - cloner.replicate(plan, replicate_physics=False) # Play the simulator sim.reset() @@ -100,10 +92,13 @@ def main(): pos_command[0, 2] = 1.0 # Hover at 1 meter height # Simulation loop - print("[INFO] Starting demo with Lee Position Controller. Press Ctrl+C to stop.") + print("[INFO] Starting example with Lee Position Controller. Press Ctrl+C to stop.") + step_count = 0 # Step while a visualizer window is still open (or none exist, e.g. headless); works for kit and newton. - while sim.is_headless_or_exist_active_visualizer(): + while sim.is_headless_or_exist_active_visualizer() and ( + args_cli.max_steps < 0 or step_count < args_cli.max_steps + ): # Compute wrench from position controller wrench = controller.compute(pos_command) # Shape: (1, 6) @@ -117,6 +112,7 @@ def main(): # Step simulation robot.write_data_to_sim() sim.step() + step_count += 1 # Update robot robot.update(sim_cfg.dt) diff --git a/scripts/demos/assets/nvidia_logo_domino_poses.pth b/examples/assets/nvidia_logo_domino_poses.pth similarity index 100% rename from scripts/demos/assets/nvidia_logo_domino_poses.pth rename to examples/assets/nvidia_logo_domino_poses.pth diff --git a/scripts/demos/bin_packing.py b/examples/bin_packing.py similarity index 96% rename from scripts/demos/bin_packing.py rename to examples/bin_packing.py index c03dbfc8c5f3..59d532af9613 100644 --- a/scripts/demos/bin_packing.py +++ b/examples/bin_packing.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Demonstration of heterogeneous bin-packing layouts with the cloner API. +"""Example of heterogeneous bin-packing layouts with the cloner API. The scene declares a bin layout as a clone combination on its :class:`~isaaclab.cloner.CloneCfg`, and the replication pipeline then spawns @@ -21,33 +21,34 @@ .. note:: Heterogeneous per-environment object counts require the PhysX backend, so - this demo runs on PhysX only. + this example runs on PhysX only. .. code-block:: bash # Usage with default PhysX physics and default kit visualizer. - uv run python scripts/demos/bin_packing.py + uvx --from 'isaaclab[isaacsim]' isaaclab example bin-packing """ from __future__ import annotations -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - import argparse from typing import TYPE_CHECKING from isaaclab.app import add_launcher_args, launch_simulation parser = argparse.ArgumentParser( - description="Demo of heterogeneous bin-packing layouts through the cloner API", + description="Example of heterogeneous bin-packing layouts through the cloner API", conflict_handler="resolve", ) parser.add_argument("--num_envs", type=int, default=16, help="Number of environments to spawn.") parser.add_argument("--physics", default="isaacsim_physx", choices=["isaacsim_physx"], help="Physics backend.") +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=["kit"]) args_cli = parser.parse_args() +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") import math from dataclasses import MISSING @@ -59,10 +60,6 @@ import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils - -## -# Pre-defined configs -## from isaaclab.assets import AssetBaseCfg, RigidObjectCfg from isaaclab.cloner import CloneCfg, InclusionSet, sequential from isaaclab.physics import PhysicsCfg @@ -338,8 +335,9 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> sim_dt = sim.get_physics_dt() count = 0 + step_count = 0 # Step while a visualizer window is still open (or none exist, e.g. headless); works for kit and newton. - while sim.is_headless_or_exist_active_visualizer(): + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): if count % 250 == 0: # reset counter count = 0 @@ -367,6 +365,7 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> scene.write_data_to_sim() # Perform step sim.step() + step_count += 1 # Bring out-of-bounds objects back to the bin. transforms = root_view.get_transforms() transforms_t = wp.to_torch(transforms) if isinstance(transforms, wp.array) else transforms diff --git a/scripts/demos/cables.py b/examples/cables.py similarity index 92% rename from scripts/demos/cables.py rename to examples/cables.py index 44528a0faa22..685d3266f3b8 100644 --- a/scripts/demos/cables.py +++ b/examples/cables.py @@ -7,14 +7,10 @@ .. code-block:: bash - # Usage with default Newton VBD physics and Kit visualizer. - uv run --extra isaacsim python scripts/demos/cables.py - - # Usage with explicit Newton VBD physics and Newton visualizer. - uv run python scripts/demos/cables.py --physics newton_vbd --visualizer newton + uvx isaaclab example cables # Usage without a visualizer and with a larger cable pile. - uv run python scripts/demos/cables.py --visualizer none --num_cables 40 --num_segments 15 + uvx isaaclab example cables --visualizer none --num_cables 40 --num_segments 15 """ @@ -33,7 +29,7 @@ parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") parser.add_argument("--physics", default="newton_vbd", choices=["newton_vbd"], help="Physics backend.") add_launcher_args(parser) -parser.set_defaults(visualizer=["kit"]) +parser.set_defaults(visualizer=["newton_gl"]) args_cli = parser.parse_args() if args_cli.num_cables < 1: @@ -138,7 +134,7 @@ def run_simulator(sim: sim_utils.SimulationContext, entities: dict[str, CableObj def main() -> None: - """Launch and run the cable pile demo.""" + """Launch and run the cable pile example.""" with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: physics_cfg.solver_cfg.iterations = 20 physics_cfg.num_substeps = 8 diff --git a/scripts/demos/deformables.py b/examples/deformables.py similarity index 89% rename from scripts/demos/deformables.py rename to examples/deformables.py index 64f345cded88..56b8ddda4fcb 100644 --- a/scripts/demos/deformables.py +++ b/examples/deformables.py @@ -3,27 +3,13 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""This script demonstrates how to spawn deformable prims into the scene. +"""Spawn volume and surface deformable objects. .. code-block:: bash - # Usage with default PhysX physics and default kit visualizer. - uv run --extra isaacsim --extra tetrahedralization python scripts/demos/deformables.py - - # Usage with Newton VBD backend and default kit visualizer. - uv run --extra isaacsim --extra tetrahedralization python scripts/demos/deformables.py --physics newton_vbd - - # Install the optional dependencies for the legacy launcher. - ./isaaclab.sh -i tetrahedralization - - # Usage with OvPhysX backend without a visualizer. - ./isaaclab.sh -p scripts/demos/deformables.py --physics ovphysx - + uvx --from 'isaaclab[tetrahedralization]' isaaclab example deformables """ -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - - import argparse from typing import TYPE_CHECKING @@ -36,14 +22,20 @@ ) parser.add_argument( "--physics", - default="isaacsim_physx", + default="newton_vbd", choices=["isaacsim_physx", "newton_vbd", "ovphysx"], help="Physics backend.", ) +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") add_launcher_args(parser) backend_args, _ = parser.parse_known_args() -parser.set_defaults(visualizer=None if backend_args.physics == "ovphysx" else ["kit"]) +default_visualizer = None if backend_args.physics == "ovphysx" else ["newton_gl"] +if backend_args.physics == "isaacsim_physx": + default_visualizer = ["kit"] +parser.set_defaults(visualizer=default_visualizer) args_cli = parser.parse_args() +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") if args_cli.visualizer and "newton" in args_cli.visualizer and args_cli.physics != "newton_vbd": raise ValueError( @@ -58,11 +50,7 @@ import tqdm import isaaclab.sim as sim_utils -from isaaclab import cloner -## -# Pre-defined configs -## from isaaclab.assets import DeformableObjectCfg # isort:skip from isaaclab.physics import PhysicsCfg # isort:skip from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR # isort:skip @@ -237,8 +225,9 @@ def run_simulator(sim: "sim_utils.SimulationContext", entities: dict[str, "Defor sim_time = 0.0 count = 0 + step_count = 0 # Step while a visualizer window is still open (or none exist, e.g. headless); works for kit and newton. - while sim.is_headless_or_exist_active_visualizer(): + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): # reset if count % int(3.0 / sim_dt) == 0: # reset counters @@ -253,6 +242,7 @@ def run_simulator(sim: "sim_utils.SimulationContext", entities: dict[str, "Defor print("[INFO]: Resetting deformable object state...") # perform step sim.step() + step_count += 1 # update sim-time sim_time += sim_dt count += 1 @@ -264,7 +254,7 @@ def run_simulator(sim: "sim_utils.SimulationContext", entities: dict[str, "Defor def main(): """Main function.""" with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: - # tune the CLI-selected backend for this demo + # Tune the CLI-selected backend for this example. if args_cli.physics == "newton_vbd": physics_cfg.solver_cfg.iterations = 20 physics_cfg.solver_cfg.particle_enable_self_contact = True @@ -284,12 +274,7 @@ def main(): sim.set_camera_view([4.0, 4.0, 3.0], [0.5, 0.5, 0.0]) # Design scene by adding assets to it - plan = cloner.make_clone_plan( - (), 1, 0.0, global_paths=("/World/defaultGroundPlane", "/World/light", "/World/Origin") - ) - sim.set_clone_plan(plan) scene_entities, _ = design_scene() - cloner.replicate(plan, replicate_physics=False) # Play the simulator sim.reset() # Now we are ready! diff --git a/examples/demos/h1_locomotion.py b/examples/demos/h1_locomotion.py new file mode 100644 index 000000000000..8954bf3f2b17 --- /dev/null +++ b/examples/demos/h1_locomotion.py @@ -0,0 +1,224 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Run an interactive H1 locomotion policy on rough terrain. + +.. code-block:: bash + + # Usage + uvx isaaclab demo h1-locomotion + +""" + +import argparse +import math +from importlib import metadata + +from isaaclab.app import add_launcher_args, launch_simulation + +from isaaclab_rl.entrypoints.backends import cli_args_rsl_rl as cli_args + +parser = argparse.ArgumentParser( + description="This script demonstrates an interactive demo with the H1 rough terrain environment." +) +cli_args.add_rsl_rl_args(parser) +parser.add_argument("--num_envs", type=int, default=9, help="Number of H1 robots to spawn.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") +parser.add_argument( + "--physics", default="newton_mjwarp", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." +) +add_launcher_args(parser) +parser.set_defaults(visualizer=["newton_gl"]) +args_cli = parser.parse_args() +if args_cli.num_envs < 1: + parser.error("--num_envs must be at least 1.") +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") + +import torch +from rsl_rl.runners import OnPolicyRunner +from tensordict import TensorDict + +from isaaclab.envs import ManagerBasedRLEnvCfg +from isaaclab.utils.math import quat_apply + +from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlVecEnvWrapper, handle_deprecated_rsl_rl_cfg +from isaaclab_rl.utils.pretrained_checkpoint import ( + get_pretrained_checkpoint_backend_names, + get_published_pretrained_checkpoint, +) + +from isaaclab_tasks.utils import resolve_task_config + +TASK = "Isaac-Velocity-Rough-H1" +RL_LIBRARY = "rsl_rl" +FORWARD_SPEED = 1.0 +"""Forward velocity command while a drive key is held [m/s].""" +YAW_RATE = 0.5 +"""Yaw velocity command while a turn key is held [rad/s].""" + + +class H1RoughDemo: + """Provide keyboard control for H1 robots running a locomotion policy. + + It loads a pre-trained checkpoint for the Isaac-Velocity-Rough-H1 task, trained with RSL RL + and defines a set of keyboard commands for directing motion of a selected robot. The Newton + viewer reserves WASD, the arrow keys, Q, and E for flying its camera, so the robot is driven + with the following keys in the viewer window: + + * N: select the next robot, cycling back to no selection + * I: go forward + * J: turn left + * L: turn right + * K: stop + * C: toggle a third-person camera that follows the selected robot + + Unselected robots keep following random velocity commands. + """ + + def __init__(self, env_cfg: ManagerBasedRLEnvCfg) -> None: + """Initialize the environment, policy, and keyboard controls. + + Args: + env_cfg: Resolved H1 rough-terrain environment configuration. + + Raises: + FileNotFoundError: If no published checkpoint matches the configured backends. + """ + agent_cfg: RslRlOnPolicyRunnerCfg = cli_args.parse_rsl_rl_cfg(TASK, args_cli) + agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, metadata.version("rsl-rl-lib")) + backend_names = get_pretrained_checkpoint_backend_names(env_cfg) + checkpoint = get_published_pretrained_checkpoint(RL_LIBRARY, TASK, *backend_names) + if checkpoint is None: + raise FileNotFoundError("No published checkpoint is available for the H1 locomotion demo.") + # The environment class imports USD, which must load after launch_simulation starts Kit for PhysX runs. + from isaaclab.envs import ManagerBasedRLEnv + + self.env = RslRlVecEnvWrapper(ManagerBasedRLEnv(cfg=env_cfg)) + self.device = self.env.unwrapped.device + ppo_runner = OnPolicyRunner(self.env, agent_cfg.to_dict(), log_dir=None, device=self.device) + ppo_runner.load(checkpoint) + self.policy = ppo_runner.get_inference_policy(device=self.device) + + self._viewers = [ + visualizer + for visualizer in self.env.unwrapped.sim.visualizers + if visualizer.cfg.visualizer_type in {"newton_gl", "newton_rtx"} + ] + self._key_to_control = { + "i": torch.tensor([FORWARD_SPEED, 0.0, 0.0], device=self.device), + "k": torch.tensor([0.0, 0.0, 0.0], device=self.device), + "j": torch.tensor([FORWARD_SPEED, 0.0, YAW_RATE], device=self.device), + "l": torch.tensor([FORWARD_SPEED, 0.0, -YAW_RATE], device=self.device), + } + self._keys_down: set[str] = set() + self._manual_command = torch.zeros(3, device=self.device) + self._selected_id: int | None = None + self._follow_camera = False + # Follow-camera offset in the robot base frame [m]. + self._camera_local_transform = torch.tensor([-2.5, 0.0, 0.8], device=self.device) + self._frame_robots() + + def update_controls(self) -> None: + """Poll the viewer keyboard and update the selection, command, and camera.""" + if self._key_pressed("n"): + previous_id = self._selected_id + next_id = 0 if previous_id is None else previous_id + 1 + self._selected_id = next_id if next_id < self.env.num_envs else None + if previous_id is not None: + self.env.unwrapped.command_manager.reset([previous_id]) + print(f"[INFO]: Selected robot: {self._selected_id if self._selected_id is not None else 'none'}") + if self._key_pressed("c"): + self._follow_camera = not self._follow_camera + if not self._follow_camera: + self._frame_robots() + + self._manual_command.zero_() + for key, command in self._key_to_control.items(): + if any(viewer.is_key_down(key) for viewer in self._viewers): + self._manual_command.copy_(command) + if self._follow_camera: + self._update_camera() + + def apply_commands(self) -> TensorDict: + """Apply interactive commands and return observations containing them.""" + command = self.env.unwrapped.command_manager.get_command("base_velocity") + if self._selected_id is not None: + command[self._selected_id].copy_(self._manual_command) + observations = self.env.unwrapped.observation_manager.compute() + return TensorDict(observations, batch_size=[self.env.num_envs]) + + def _key_pressed(self, key: str) -> bool: + """Return whether a key went down since the previous poll. + + Args: + key: Key name understood by the Newton viewer. + + Returns: + True on the first poll after the key is pressed, False otherwise. + """ + is_down = any(viewer.is_key_down(key) for viewer in self._viewers) + was_down = key in self._keys_down + if is_down: + self._keys_down.add(key) + else: + self._keys_down.discard(key) + return is_down and not was_down + + def _frame_robots(self) -> None: + """Point the viewer camera at the whole group of robots.""" + center = self.env.unwrapped.scene.env_origins.mean(dim=0).tolist() + scale = 2.5 * math.ceil(math.sqrt(self.env.num_envs)) + eye = (center[0] + scale, center[1] - scale, center[2] + 0.6 * scale) + self.env.unwrapped.sim.set_camera_view(eye=eye, target=(center[0], center[1], center[2] + 0.5)) + + def _update_camera(self) -> None: + """Move the viewer camera to follow the selected robot.""" + if self._selected_id is None: + return + base_pos = self.env.unwrapped.scene["robot"].data.root_pos_w.torch[self._selected_id] + base_quat = self.env.unwrapped.scene["robot"].data.root_quat_w.torch[self._selected_id, :] + camera_pos = quat_apply(base_quat, self._camera_local_transform) + base_pos + target = base_pos + torch.tensor([0.0, 0.0, 0.6], device=self.device) + self.env.unwrapped.sim.set_camera_view(eye=camera_pos.tolist(), target=target.tolist()) + + +def main() -> None: + """Run interactive H1 policy inference.""" + env_cfg, _ = resolve_task_config(TASK, "", play_mode=True, overrides=(f"physics={args_cli.physics}",)) + env_cfg.scene.num_envs = args_cli.num_envs + # Place the robots in a compact grid so they share one view instead of scattering across terrain tiles. + env_cfg.scene.terrain.use_terrain_origins = False + env_cfg.episode_length_s = 1000000 + env_cfg.curriculum = None + env_cfg.commands.base_velocity.ranges.lin_vel_x = (0.0, 1.0) + env_cfg.commands.base_velocity.heading_command = False + env_cfg.commands.base_velocity.ranges.ang_vel_z = (-1.0, 1.0) + if args_cli.device is not None: + env_cfg.sim.device = args_cli.device + + # The physics preset is already selected above; forwarding --physics would replace the + # task's tuned solver settings with backend defaults the policy was not trained on. + with launch_simulation(env_cfg, vars(args_cli) | {"physics": None}): + demo_h1 = H1RoughDemo(env_cfg) + demo_h1.env.reset() + sim = demo_h1.env.unwrapped.sim + step_count = 0 + try: + while sim.is_headless_or_exist_active_visualizer() and ( + args_cli.max_steps < 0 or step_count < args_cli.max_steps + ): + demo_h1.update_controls() + with torch.inference_mode(): + obs = demo_h1.apply_commands() + action = demo_h1.policy(obs) + demo_h1.env.step(action) + step_count += 1 + finally: + demo_h1.env.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/demos/newton_viewer_block_and_tackle.py b/examples/demos/newton_viewer_block_and_tackle.py similarity index 99% rename from scripts/demos/newton_viewer_block_and_tackle.py rename to examples/demos/newton_viewer_block_and_tackle.py index 0f5202a303f0..73fddba18628 100644 --- a/scripts/demos/newton_viewer_block_and_tackle.py +++ b/examples/demos/newton_viewer_block_and_tackle.py @@ -7,7 +7,7 @@ .. code-block:: bash - uv run python scripts/demos/newton_viewer_block_and_tackle.py + uvx isaaclab demo newton-block-and-tackle """ import argparse diff --git a/scripts/demos/pick_and_place.py b/examples/demos/pick_and_place.py similarity index 90% rename from scripts/demos/pick_and_place.py rename to examples/demos/pick_and_place.py index 919894a4c767..8c0bc6400fda 100644 --- a/scripts/demos/pick_and_place.py +++ b/examples/demos/pick_and_place.py @@ -3,36 +3,39 @@ # # SPDX-License-Identifier: BSD-3-Clause +"""Interactively pick up a cube and place it on a target.""" + from __future__ import annotations import argparse +from collections.abc import Sequence + +import torch +import warp as wp from isaaclab.app import AppLauncher -# add argparse arguments parser = argparse.ArgumentParser(description="Keyboard control for Isaac Lab Pick and Place.") parser.add_argument("--num_envs", type=int, default=32, help="Number of environments to spawn.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") parser.add_argument( "--physics", default="isaacsim_physx", choices=["isaacsim_physx"], help="Physics backend.", ) -# append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) -# demos should open Kit visualizer by default parser.set_defaults(visualizer=["kit"]) -# parse the arguments args_cli = parser.parse_args() +if args_cli.num_envs < 1: + parser.error("--num_envs must be at least 1.") +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") -# launch omniverse app app_launcher = AppLauncher(args_cli) simulation_app = app_launcher.app -from collections.abc import Sequence - -import torch -import warp as wp +# Kit modules must be imported after AppLauncher starts SimulationApp. from isaaclab_physx.assets import SurfaceGripperCfg import carb @@ -94,7 +97,7 @@ class PickAndPlaceEnvCfg(DirectRLEnvCfg): decimation = 4 episode_length_s = 240.0 action_space = 4 - observation_space = 6 + observation_space = 9 state_space = 0 # Simulation cfg. Surface grippers are currently only supported on CPU. @@ -127,7 +130,7 @@ class PickAndPlaceEnvCfg(DirectRLEnvCfg): # Target position of the cube target_x_pos_range = [-2.0, 2.0] - target_y_pos_range = [2.0, 0.5] + target_y_pos_range = [0.5, 2.0] target_z_pos = 0.2 @@ -140,7 +143,7 @@ class PickAndPlaceEnv(DirectRLEnv): cfg: PickAndPlaceEnvCfg - def __init__(self, cfg: PickAndPlaceEnvCfg, render_mode: str | None = None, **kwargs): + def __init__(self, cfg: PickAndPlaceEnvCfg, render_mode: str | None = None, **kwargs) -> None: super().__init__(cfg, render_mode, **kwargs) self.pick_and_place, self.cube, self.gripper, self.goal_pos_visualizer = [ self.scene[name] for name in ("robot", "cube", "gripper", "goal_position") @@ -168,24 +171,20 @@ def __init__(self, cfg: PickAndPlaceEnvCfg, render_mode: str | None = None, **kw # Sets up the keyboard callback and settings self.set_up_keyboard() - def set_up_keyboard(self): - """Sets up interface for keyboard input and registers the desired keys for control.""" - # Acquire keyboard interface + def set_up_keyboard(self) -> None: + """Register keyboard controls.""" self._input = carb.input.acquire_input_interface() self._keyboard = omni.appwindow.get_default_app_window().get_keyboard() self._sub_keyboard = self._input.subscribe_to_keyboard_events(self._keyboard, self._on_keyboard_event) - # Open / Close / Idle commands for gripper self._instant_key_controls = { "Q": torch.tensor([0, 0, -1]), "E": torch.tensor([0, 0, 1]), "ZEROS": torch.tensor([0, 0, 0]), } - # Move up or down self._permanent_key_controls = { "W": torch.tensor([-200.0], device=self.device), "S": torch.tensor([100.0], device=self.device), } - # Aiming manually is painful we can automate this. self._auto_aim_cube = "A" self._auto_aim_target = "D" @@ -200,10 +199,9 @@ def set_up_keyboard(self): print("Press the 'W' or 'S' keys to move all gantries UP or DOWN respectively") print("Press 'Q' or 'E' to OPEN or CLOSE all grippers respectively") - def _on_keyboard_event(self, event): - """Checks for a keyboard event and assign the corresponding command control depending on key pressed.""" + def _on_keyboard_event(self, event: carb.input.KeyboardEvent) -> bool: + """Update controls from a keyboard event.""" if event.type == carb.input.KeyboardEventType.KEY_PRESS: - # Logic on key press - apply to ALL environments if event.input.name == self._auto_aim_target: self.go_to_target[:] = True self.go_to_cube[:] = False @@ -223,6 +221,7 @@ def _on_keyboard_event(self, event): self.go_to_cube[:] = False self.go_to_target[:] = False self.instant_controls[:] = self._instant_key_controls["ZEROS"] + return True def _pre_physics_step(self, actions: torch.Tensor) -> None: # Store the actions @@ -271,7 +270,7 @@ def _apply_action(self) -> None: # Set the gripper command self.gripper.set_grippers_command(self.instant_controls[:, 2]) - def _get_observations(self) -> dict: + def _get_observations(self) -> dict[str, torch.Tensor]: # Get the observations gripper_state = wp.to_torch(self.gripper.state).clone() obs = torch.cat( @@ -289,8 +288,7 @@ def _get_observations(self) -> dict: dim=-1, ) - observations = {"policy": obs} - return observations + return {"policy": obs} def _get_rewards(self) -> torch.Tensor: return torch.zeros_like(self.reset_terminated, dtype=torch.float32) @@ -319,9 +317,9 @@ def _get_dones(self) -> tuple[torch.Tensor, torch.Tensor]: time_out = time_out | self.target_reached return self.cube_out_of_bounds, time_out - def _reset_idx(self, env_ids: Sequence[int] | None): + def _reset_idx(self, env_ids: Sequence[int] | None) -> None: if env_ids is None: - env_ids = self.pick_and_place._ALL_INDICES + env_ids = torch.arange(self.num_envs, device=self.device) # Reset the environment, this must be done first! As it releases the objects held by the grippers. # (And that's an operation that should be done before the gripper or the gripped objects are moved) super()._reset_idx(env_ids) @@ -388,29 +386,33 @@ def _reset_idx(self, env_ids: Sequence[int] | None): self.pick_and_place.write_joint_position_to_sim_index(position=joint_pos, env_ids=env_ids) self.pick_and_place.write_joint_velocity_to_sim_index(velocity=joint_vel, env_ids=env_ids) - def _set_debug_vis_impl(self, debug_vis: bool): + def _set_debug_vis_impl(self, debug_vis: bool) -> None: self.goal_pos_visualizer.set_visibility(debug_vis) - def _debug_vis_callback(self, event): - # update the markers + def _debug_vis_callback(self, event) -> None: + """Update the target marker after a simulation step.""" self.goal_pos_visualizer.visualize(self.target_pos + self.scene.env_origins) -def main(): - """Main function.""" - # create environment configuration +def main() -> None: + """Run the interactive surface-gripper demo.""" env_cfg = PickAndPlaceEnvCfg() env_cfg.scene.num_envs = args_cli.num_envs - # create environment pick_and_place = PickAndPlaceEnv(env_cfg) - obs, _ = pick_and_place.reset() - while simulation_app.is_running(): - # check for selected robots - with torch.inference_mode(): - actions = torch.zeros((pick_and_place.num_envs, 4), device=pick_and_place.device, dtype=torch.float32) - pick_and_place.step(actions) + pick_and_place.reset() + step_count = 0 + try: + while simulation_app.is_running() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): + with torch.inference_mode(): + actions = torch.zeros((pick_and_place.num_envs, 4), device=pick_and_place.device) + pick_and_place.step(actions) + step_count += 1 + finally: + pick_and_place.close() if __name__ == "__main__": - main() - simulation_app.close() + try: + main() + finally: + simulation_app.close() diff --git a/scripts/demos/mpm/snowball_smash.py b/examples/demos/snowball_smash.py similarity index 97% rename from scripts/demos/mpm/snowball_smash.py rename to examples/demos/snowball_smash.py index e908ab22ba8f..600978876128 100644 --- a/scripts/demos/mpm/snowball_smash.py +++ b/examples/demos/snowball_smash.py @@ -11,7 +11,7 @@ .. code-block:: bash - uv run python scripts/demos/mpm/snowball_smash.py --device cuda:0 --visualizer newton + uvx isaaclab demo snowball-smash --device cuda:0 --visualizer newton_gl Use ``--grid_type fixed`` to compare against the larger fixed-grid fallback. """ @@ -59,7 +59,7 @@ class SnowballSpec(NamedTuple): parser.add_argument("--rigid_substeps", type=int, default=4, help="MuJoCo-Warp substeps per coupled step.") parser.add_argument("--disable_cuda_graph", action="store_true", help="Disable CUDA graph capture for debugging.") add_launcher_args(parser) -parser.set_defaults(visualizer=["newton"]) +parser.set_defaults(visualizer=["newton_gl"]) args_cli = parser.parse_args() @@ -220,6 +220,7 @@ def create_scene_cfg(): import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg, RigidObjectCfg from isaaclab.scene import InteractiveSceneCfg + from isaaclab.sim.spawners.materials import UsdPhysicsRigidBodyMaterialCfg from isaaclab.utils import configclass particle_mass = SNOW_SPACING**3 * SNOW_DENSITY @@ -234,7 +235,7 @@ def crate_cfg(index: int) -> RigidObjectCfg: rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(rigid_body_enabled=True), mass_props=sim_utils.MassCfg(mass=args_cli.crate_mass), collision_props=sim_utils.UsdPhysicsCollisionCfg(collision_enabled=True), - physics_material=sim_utils.NewtonMaterialPropertiesCfg( + physics_material=UsdPhysicsRigidBodyMaterialCfg( static_friction=CRATE_FRICTION, dynamic_friction=CRATE_FRICTION, ), @@ -294,7 +295,7 @@ class SnowballSmashSceneCfg(InteractiveSceneCfg): kinematic_enabled=True, ), collision_props=sim_utils.UsdPhysicsCollisionCfg(collision_enabled=True), - physics_material=sim_utils.NewtonMaterialPropertiesCfg( + physics_material=UsdPhysicsRigidBodyMaterialCfg( static_friction=CRATE_FRICTION, dynamic_friction=CRATE_FRICTION, ), diff --git a/scripts/demos/mpm/teapot_fill.py b/examples/demos/teapot_fill.py similarity index 97% rename from scripts/demos/mpm/teapot_fill.py rename to examples/demos/teapot_fill.py index 4ac192676dda..2899e87a0109 100644 --- a/scripts/demos/mpm/teapot_fill.py +++ b/examples/demos/teapot_fill.py @@ -11,15 +11,15 @@ .. code-block:: bash # Fast Newton visualizer (the default): - uv run python scripts/demos/mpm/teapot_fill.py --device cuda:0 --visualizer newton_gl + uvx isaaclab demo teapot-fill --device cuda:0 --visualizer newton_gl # Display both the raw particles and reconstructed surface: - uv run python scripts/demos/mpm/teapot_fill.py --visualizer newton_gl --fluid_render_mode both + uvx isaaclab demo teapot-fill --visualizer newton_gl --fluid_render_mode both # Newton RTX path-traced visualizer: - uv run --extra ovrtx python scripts/demos/mpm/teapot_fill.py --device cuda:0 --visualizer newton_rtx + uvx --from 'isaaclab[ovrtx]' isaaclab demo teapot-fill --device cuda:0 --visualizer newton_rtx # Isaac Sim Kit visualizer (particles only): - uv run --extra isaacsim python scripts/demos/mpm/teapot_fill.py --device cuda:0 --visualizer kit + uvx --from 'isaaclab[isaacsim]' isaaclab demo teapot-fill --device cuda:0 --visualizer kit # Fuller / coarser (faster) fill: - uv run python scripts/demos/mpm/teapot_fill.py --fill_level 1.0 --fill_spacing 0.003 + uvx isaaclab demo teapot-fill --fill_level 1.0 --fill_spacing 0.003 """ from __future__ import annotations @@ -409,6 +409,7 @@ def spawn_demo_mesh( from pxr import UsdGeom from isaaclab.sim import schemas + from isaaclab.sim.spawners.materials import spawn_physics_material from isaaclab.sim.utils import bind_physics_material, bind_visual_material, create_prim, get_current_stage stage = get_current_stage() @@ -459,7 +460,7 @@ def as_fragments(value) -> list: material_path = cfg.physics_material_path if not material_path.startswith("/"): material_path = f"{geom_prim_path}/{material_path}" - cfg.physics_material.func(material_path, cfg.physics_material) + spawn_physics_material(material_path, cfg.physics_material, stage=stage) bind_physics_material(mesh_prim_path, material_path, stage=stage) return stage.GetPrimAtPath(prim_path) @@ -600,6 +601,7 @@ def create_scene_cfg(container_usd: str, island_usd: str | None, bowl_usd: str | import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg, RigidObjectCfg from isaaclab.scene import InteractiveSceneCfg + from isaaclab.sim.spawners.materials import UsdPhysicsRigidBodyMaterialCfg from isaaclab.sim.utils import clone from isaaclab.utils import configclass @@ -665,7 +667,7 @@ class TeapotFillSceneCfg(InteractiveSceneCfg): sim_utils.UsdPhysicsCollisionCfg(collision_enabled=True), NewtonCollisionCfg(contact_margin=COLLIDER_MARGIN), ], - physics_material=sim_utils.NewtonMaterialPropertiesCfg( + physics_material=UsdPhysicsRigidBodyMaterialCfg( static_friction=TABLE_FRICTION, dynamic_friction=TABLE_FRICTION, ), @@ -694,7 +696,7 @@ class TeapotFillSceneCfg(InteractiveSceneCfg): NewtonCollisionCfg(contact_margin=COLLIDER_MARGIN), ], mesh_collision_props=sim_utils.UsdPhysicsMeshCollisionCfg(mesh_approximation_name="none"), - physics_material=sim_utils.NewtonMaterialPropertiesCfg( + physics_material=UsdPhysicsRigidBodyMaterialCfg( static_friction=BOWL_FRICTION, dynamic_friction=BOWL_FRICTION, ), @@ -725,7 +727,7 @@ class TeapotFillSceneCfg(InteractiveSceneCfg): NewtonCollisionCfg(contact_margin=COLLIDER_MARGIN), ], mesh_collision_props=sim_utils.UsdPhysicsMeshCollisionCfg(mesh_approximation_name="none"), - physics_material=sim_utils.NewtonMaterialPropertiesCfg( + physics_material=UsdPhysicsRigidBodyMaterialCfg( static_friction=CONTAINER_FRICTION, dynamic_friction=CONTAINER_FRICTION, ), diff --git a/examples/demos/zoo.py b/examples/demos/zoo.py new file mode 100644 index 000000000000..7cddf33190e8 --- /dev/null +++ b/examples/demos/zoo.py @@ -0,0 +1,249 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Showcase a small robot zoo in one scene. + +.. code-block:: bash + + uvx isaaclab demo zoo +""" + +from __future__ import annotations + +import argparse +import math +from typing import TYPE_CHECKING + +from isaaclab.app import add_launcher_args, launch_simulation + +parser = argparse.ArgumentParser(description="Showcase several robot families in one scene.") +parser.add_argument( + "--physics", default="newton_mjwarp", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." +) +parser.add_argument("--num_envs", type=int, default=1, help="Number of zoo environments to spawn.") +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_gl"]) +args_cli = parser.parse_args() +if args_cli.num_envs < 1: + parser.error("--num_envs must be at least 1.") +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") + +import torch + +import isaaclab.sim as sim_utils +from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg, RigidObjectCollectionCfg +from isaaclab.physics import PhysicsCfg +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.utils import configclass + +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg # isort: skip +from isaaclab_assets.robots.anymal import ANYDRIVE_3_SIMPLE_ACTUATOR_CFG, ANYMAL_D_CFG # isort: skip +from isaaclab_assets.robots.quadcopter import CRAZYFLIE_CFG # isort: skip +from isaaclab_assets.robots.shadow_hand import ( # isort: skip + JOINT_NAMES as SHADOW_HAND_JOINT_NAMES, + SHADOW_HAND_NEWTON_CFG, + SHADOW_HAND_PHYSX_CFG, +) +from isaaclab_assets.robots.unitree import G1_CFG # isort: skip +from isaaclab_assets.robots.universal_robots import UR10e_CFG # isort: skip + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, RigidObjectCollection + from isaaclab.scene import InteractiveScene + + +_RIGID_PROPS = { + "rigid_props": sim_utils.UsdPhysicsRigidBodyCfg(), + "mass_props": sim_utils.MassCfg(mass=0.5), + "collision_props": sim_utils.UsdPhysicsCollisionCfg(), +} +_HAND_CFG = SHADOW_HAND_NEWTON_CFG if args_cli.physics == "newton_mjwarp" else SHADOW_HAND_PHYSX_CFG + + +def _prop_cfg(spawn: sim_utils.RigidObjectSpawnerCfg, position: tuple[float, float, float]) -> RigidObjectCfg: + """Create a dynamic prop configuration.""" + return RigidObjectCfg( + prim_path="", + spawn=spawn, + init_state=RigidObjectCfg.InitialStateCfg(pos=position), + ) + + +@configclass +class ZooSceneCfg(InteractiveSceneCfg): + """Configuration for the robot zoo.""" + + ground = AssetBaseCfg(prim_path="/World/Ground", spawn=sim_utils.GroundPlaneCfg()) + light = AssetBaseCfg( + prim_path="/World/Light", + spawn=sim_utils.DomeLightCfg(intensity=2500.0, color=(0.75, 0.75, 0.75)), + ) + + arm: ArticulationCfg = UR10e_CFG.replace( + prim_path="{ENV_REGEX_NS}/Arm", + init_state=UR10e_CFG.init_state.replace(pos=(-2.2, 1.4, 0.0)), + ) + biped: ArticulationCfg = G1_CFG.replace( + prim_path="{ENV_REGEX_NS}/Biped", + init_state=G1_CFG.init_state.replace(pos=(0.0, 1.5, 0.74)), + ) + quadruped: ArticulationCfg = ANYMAL_D_CFG.replace( + prim_path="{ENV_REGEX_NS}/Quadruped", + init_state=ANYMAL_D_CFG.init_state.replace(pos=(2.2, 1.4, 0.6)), + actuators={"legs": ANYDRIVE_3_SIMPLE_ACTUATOR_CFG}, + ) + hand: ArticulationCfg = _HAND_CFG.replace( + prim_path="{ENV_REGEX_NS}/Hand", + init_state=_HAND_CFG.init_state.replace(pos=(-1.4, -1.3, 0.5)), + ) + drone: ArticulationCfg = CRAZYFLIE_CFG.replace( + prim_path="{ENV_REGEX_NS}/Drone", + init_state=CRAZYFLIE_CFG.init_state.replace(pos=(1.5, -1.4, 1.3)), + ) + + props: RigidObjectCollectionCfg = RigidObjectCollectionCfg( + rigid_objects={ + "cube": _prop_cfg( + sim_utils.CuboidCfg( + size=(0.3, 0.3, 0.3), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.15, 0.55, 0.95)), + **_RIGID_PROPS, + ), + (0.0, -0.6, 2.0), + ).replace(prim_path="{ENV_REGEX_NS}/Props/Cube"), + "sphere": _prop_cfg( + sim_utils.SphereCfg( + radius=0.18, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.95, 0.35, 0.15)), + **_RIGID_PROPS, + ), + (0.4, -0.6, 2.5), + ).replace(prim_path="{ENV_REGEX_NS}/Props/Sphere"), + "cylinder": _prop_cfg( + sim_utils.CylinderCfg( + radius=0.16, + height=0.4, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.45, 0.8, 0.25)), + **_RIGID_PROPS, + ), + (-0.4, -0.6, 3.0), + ).replace(prim_path="{ENV_REGEX_NS}/Props/Cylinder"), + } + ) + + +def _reset_scene(scene: InteractiveScene) -> None: + """Restore every dynamic asset to its configured state.""" + for robot in scene.articulations.values(): + root_pose = robot.data.default_root_pose.torch.clone() + root_pose[:, :3] += scene.env_origins + robot.write_root_pose_to_sim_index(root_pose=root_pose) + robot.write_root_velocity_to_sim_index(root_velocity=robot.data.default_root_vel.torch.clone()) + robot.write_joint_position_to_sim_index(position=robot.data.default_joint_pos.torch.clone()) + robot.write_joint_velocity_to_sim_index(velocity=robot.data.default_joint_vel.torch.clone()) + + props: RigidObjectCollection = scene["props"] + body_pose = props.data.default_body_pose.torch.clone() + body_pose[..., :3] += scene.env_origins.unsqueeze(1) + props.write_body_pose_to_sim_index(body_poses=body_pose) + props.write_body_com_velocity_to_sim_index(body_velocities=props.data.default_body_vel.torch.clone()) + scene.reset() + + +def _set_joint_targets( + robot: Articulation, + joint_ids: list[int], + phase: torch.Tensor, + time: float, + amplitude: float, + frequency: float, +) -> None: + """Apply a smooth deterministic joint trajectory.""" + target = robot.data.default_joint_pos.torch.clone() + target[:, joint_ids] += amplitude * math.sin(frequency * time) * torch.cos(phase) + limits = robot.data.soft_joint_pos_limits.torch + robot.actuators.target_command.set_position_index(value=target.clamp(limits[..., 0], limits[..., 1])) + + +def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> None: + """Animate the zoo until the viewer closes or the step limit is reached.""" + arm: Articulation = scene["arm"] + biped: Articulation = scene["biped"] + quadruped: Articulation = scene["quadruped"] + hand: Articulation = scene["hand"] + drone: Articulation = scene["drone"] + prop_body_ids = drone.find_bodies("m.*_prop")[0] + drone_mass = drone.data.body_mass.torch[0].sum() + gravity = torch.tensor(sim.cfg.gravity, device=sim.device).norm() + forces = torch.zeros(drone.num_instances, len(prop_body_ids), 3, device=sim.device) + torques = torch.zeros_like(forces) + motions = [ + (arm, list(range(arm.num_joints)), 0.16, 0.8), + (biped, biped.find_joints(".*_(shoulder|elbow)_.*")[0], 0.18, 1.0), + (quadruped, list(range(quadruped.num_joints)), 0.08, 1.5), + (hand, hand.find_joints(SHADOW_HAND_JOINT_NAMES, preserve_order=True)[0], 0.3, 1.2), + ] + phases = [ + torch.linspace(0.0, 2.0 * math.pi, len(joint_ids) + 1, device=robot.device)[:-1] + for robot, joint_ids, _, _ in motions + ] + + sim_dt = sim.get_physics_dt() + sim_time = 0.0 + step_count = 0 + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): + if step_count % 800 == 0: + _reset_scene(scene) + sim_time = 0.0 + + for (robot, joint_ids, amplitude, frequency), phase in zip(motions, phases): + _set_joint_targets(robot, joint_ids, phase, sim_time, amplitude, frequency) + + forces[..., 2] = drone_mass * gravity * (1.0 + 0.03 * math.sin(1.5 * sim_time)) / len(prop_body_ids) + drone.permanent_wrench_composer.set_forces_and_torques_index( + forces=forces, + torques=torques, + body_ids=prop_body_ids, + ) + scene.write_data_to_sim() + sim.step() + scene.update(sim_dt) + sim_time += sim_dt + step_count += 1 + + +def main() -> None: + """Launch the robot zoo showcase.""" + torch.manual_seed(42) + with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: + if isinstance(physics_cfg, NewtonCfg) and isinstance(physics_cfg.solver_cfg, MJWarpSolverCfg): + physics_cfg.solver_cfg.integrator = "implicitfast" + physics_cfg.solver_cfg.njmax = 300 + physics_cfg.solver_cfg.nconmax = 200 + physics_cfg.solver_cfg.ls_iterations = 40 + physics_cfg.solver_cfg.cone = "elliptic" + physics_cfg.solver_cfg.impratio = 10.0 + physics_cfg.solver_cfg.ls_parallel = False + physics_cfg.solver_cfg.update_data_interval = 2 + physics_cfg.solver_cfg.ccd_iterations = 50 + physics_cfg.num_substeps = 2 + physics_cfg.debug_mode = False + + sim = sim_utils.SimulationContext( + sim_utils.SimulationCfg(dt=0.005, device=args_cli.device, physics=physics_cfg) + ) + camera_scale = math.ceil(math.sqrt(args_cli.num_envs)) + sim.set_camera_view(eye=(6.0 * camera_scale, -7.5 * camera_scale, 4.5 * camera_scale), target=(0.0, 0.0, 0.7)) + scene_cfg = ZooSceneCfg(num_envs=args_cli.num_envs, env_spacing=6.0, replicate_physics=True) + scene = scene_cfg.class_type(scene_cfg) + sim.reset() + print("[INFO]: Robot zoo ready.") + run_simulator(sim, scene) + + +if __name__ == "__main__": + main() diff --git a/scripts/demos/haply_teleoperation.py b/examples/haply_teleoperation.py similarity index 72% rename from scripts/demos/haply_teleoperation.py rename to examples/haply_teleoperation.py index eb88f728333b..f8f1d90493b7 100644 --- a/scripts/demos/haply_teleoperation.py +++ b/examples/haply_teleoperation.py @@ -3,56 +3,50 @@ # # SPDX-License-Identifier: BSD-3-Clause -""" -Demonstration of Haply device teleoperation with a robotic arm. +"""Teleoperate a robot arm with Haply Inverse3 and VerseGrip devices. -This script demonstrates how to use a Haply device (Inverse3 + VerseGrip) to -teleoperate a robotic arm in Isaac Lab. The Haply provides: -- Position tracking from the Inverse3 device -- Orientation and button inputs from the VerseGrip device -- Force feedback +The Inverse3 provides position tracking and force feedback. The VerseGrip provides orientation and button input. .. code-block:: bash - # Usage with default PhysX physics and default kit visualizer. - uv run python scripts/demos/haply_teleoperation.py - - # Usage with Newton visualizer and default PhysX physics. - uv run python scripts/demos/haply_teleoperation.py --visualizer newton - - # Usage with Newton (MJWarp) physics and default kit visualizer. - uv run python scripts/demos/haply_teleoperation.py --physics newton_mjwarp - - # Usage with Newton visualizer and Newton (MJWarp) physics. - uv run python scripts/demos/haply_teleoperation.py --visualizer newton --physics newton_mjwarp - - # With custom WebSocket URI - uv run python scripts/demos/haply_teleoperation.py --websocket_uri ws://localhost:10001 - - # With sensitivity adjustment - uv run python scripts/demos/haply_teleoperation.py --pos_sensitivity 2.0 + uvx --from 'isaaclab[teleop]' isaaclab example haply-teleoperation --websocket_uri ws://localhost:10001 Prerequisites: - 1. Install websockets package: pip install websockets - 2. Have Haply SDK running and accessible via WebSocket - 3. Connect Inverse3 and VerseGrip devices + Install the ``websockets`` package, start the Haply WebSocket service, and connect both devices. """ -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - import argparse -from typing import TYPE_CHECKING +import math +from typing import TYPE_CHECKING, cast +import torch + +import isaaclab.sim as sim_utils from isaaclab.app import add_launcher_args, launch_simulation +from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg +from isaaclab.controllers import DifferentialIKController, DifferentialIKControllerCfg +from isaaclab.devices import HaplyDeviceCfg +from isaaclab.physics import PhysicsCfg +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sensors import ContactSensorCfg +from isaaclab.sim.spawners.materials import UsdPhysicsRigidBodyMaterialCfg +from isaaclab.utils import configclass +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR + +from isaaclab_assets import FRANKA_PANDA_HIGH_PD_CFG + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, RigidObject + from isaaclab.devices import HaplyDevice + from isaaclab.scene import InteractiveScene + from isaaclab.sensors import ContactSensor -# add argparse arguments parser = argparse.ArgumentParser( - description="Demonstration of Haply device teleoperation with Isaac Lab.", + description="Example of Haply device teleoperation with Isaac Lab.", conflict_handler="resolve", ) -parser.add_argument("--num_envs", type=int, default=1, help="Number of environments to spawn.") parser.add_argument( - "--physics", default="isaacsim_physx", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." + "--physics", default="newton_mjwarp", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." ) parser.add_argument( "--websocket_uri", @@ -60,6 +54,9 @@ default="ws://localhost:10001", help="WebSocket URI for Haply SDK connection.", ) +parser.add_argument( + "--max_steps", type=int, default=-1, help="Stop after this many control steps; negative runs forever." +) parser.add_argument( "--pos_sensitivity", type=float, @@ -68,35 +65,11 @@ ) add_launcher_args(parser) -parser.set_defaults(visualizer=["kit"]) +parser.set_defaults(visualizer=["newton_gl"]) args_cli = parser.parse_args() +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") -import numpy as np -import torch - -import isaaclab.sim as sim_utils - -## -# Pre-defined configs -## -from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg -from isaaclab.controllers import DifferentialIKController, DifferentialIKControllerCfg -from isaaclab.devices import HaplyDeviceCfg -from isaaclab.physics import PhysicsCfg -from isaaclab.scene import InteractiveSceneCfg -from isaaclab.sensors import ContactSensorCfg -from isaaclab.utils import configclass -from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR - -from isaaclab_assets import FRANKA_PANDA_HIGH_PD_CFG # isort: skip - -if TYPE_CHECKING: - from isaaclab.assets import Articulation, RigidObject - from isaaclab.devices import HaplyDevice - from isaaclab.scene import InteractiveScene - from isaaclab.sensors import ContactSensor - -# Workspace mapping constants HAPLY_Z_OFFSET = 0.35 WORKSPACE_LIMITS = { "x": (0.1, 0.9), @@ -106,40 +79,28 @@ def apply_haply_to_robot_mapping( - haply_pos: np.ndarray | torch.Tensor, - haply_initial_pos: np.ndarray | list, - robot_initial_pos: np.ndarray | torch.Tensor, -) -> np.ndarray: + haply_pos: torch.Tensor, + haply_initial_pos: torch.Tensor, + robot_initial_pos: torch.Tensor, +) -> torch.Tensor: """Apply coordinate mapping from Haply workspace to Franka Panda end-effector. - Uses absolute position control: robot position = robot_initial_pos + haply_pos (transformed) + Applies the transformed Haply displacement to the initial robot position. Args: - haply_pos: Current Haply absolute position [x, y, z] in meters - haply_initial_pos: Haply's zero reference position [x, y, z] - robot_initial_pos: Base offset for robot end-effector + haply_pos: Current Haply absolute position [m], shape [3]. + haply_initial_pos: Haply zero-reference position [m], shape [3]. + robot_initial_pos: Initial robot end-effector position [m], shape [3]. Returns: - robot_pos: Target position for robot EE in world frame [x, y, z] - + Target end-effector position in the world frame [m], shape [3]. """ - # Convert to numpy - if isinstance(haply_pos, torch.Tensor): - haply_pos = haply_pos.cpu().numpy() - if isinstance(robot_initial_pos, torch.Tensor): - robot_initial_pos = robot_initial_pos.cpu().numpy() - haply_delta = haply_pos - haply_initial_pos - - # Coordinate system mapping: Haply (X, Y, Z) -> Robot (-Y, X, Z-offset) - robot_offset = np.array([-haply_delta[1], haply_delta[0], haply_delta[2] - HAPLY_Z_OFFSET]) + robot_offset = torch.stack((-haply_delta[1], haply_delta[0], haply_delta[2] - HAPLY_Z_OFFSET)) robot_pos = robot_initial_pos + robot_offset - - # Apply workspace limits for safety - robot_pos[0] = np.clip(robot_pos[0], WORKSPACE_LIMITS["x"][0], WORKSPACE_LIMITS["x"][1]) - robot_pos[1] = np.clip(robot_pos[1], WORKSPACE_LIMITS["y"][0], WORKSPACE_LIMITS["y"][1]) - robot_pos[2] = np.clip(robot_pos[2], WORKSPACE_LIMITS["z"][0], WORKSPACE_LIMITS["z"][1]) - + robot_pos[0].clamp_(*WORKSPACE_LIMITS["x"]) + robot_pos[1].clamp_(*WORKSPACE_LIMITS["y"]) + robot_pos[2].clamp_(*WORKSPACE_LIMITS["z"]) return robot_pos @@ -178,7 +139,7 @@ class FrankaHaplySceneCfg(InteractiveSceneCfg): rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), mass_props=sim_utils.MassCfg(mass=0.5), collision_props=sim_utils.UsdPhysicsCollisionCfg(), - physics_material=sim_utils.RigidBodyMaterialCfg(static_friction=0.5, dynamic_friction=0.5), + physics_material=UsdPhysicsRigidBodyMaterialCfg(static_friction=0.5, dynamic_friction=0.5), visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.8, 0.2), metallic=0.2), ), init_state=RigidObjectCfg.InitialStateCfg(pos=(0.60, 0.00, 1.15)), @@ -202,13 +163,14 @@ class FrankaHaplySceneCfg(InteractiveSceneCfg): def run_simulator( - sim: "sim_utils.SimulationContext", + sim: sim_utils.SimulationContext, scene: "InteractiveScene", haply_device: "HaplyDevice", -): - """Runs the simulation loop with Haply teleoperation.""" +) -> None: + """Run the simulation loop with Haply teleoperation.""" sim_dt = sim.get_physics_dt() - count = 1 + reset_count = 1 + step_count = 0 robot: Articulation = scene["robot"] cube: RigidObject = scene["cube"] @@ -233,8 +195,8 @@ def run_simulator( scene.update(sim_dt) # Initialize the position of franka - robot_initial_pos = robot.data.body_pos_w.torch[0, ee_body_idx].cpu().numpy() - haply_initial_pos = np.array([0.0, 0.0, 0.0], dtype=np.float32) + robot_initial_pos = robot.data.body_pos_w.torch[0, ee_body_idx].clone() + haply_initial_pos = torch.zeros(3, device=sim.device) ik_controller_cfg = DifferentialIKControllerCfg( command_type="position", @@ -263,18 +225,19 @@ def run_simulator( prev_button_b = False prev_button_c = False gripper_target = 0.04 + env_ids = torch.zeros(1, dtype=torch.long, device=sim.device) # Initialize the rotation of franka end-effector ee_rotation_angle = robot.data.joint_pos.torch[0, 6].item() - rotation_step = np.pi / 3 + rotation_step = math.pi / 3 print("\n[INFO] Teleoperation ready!") print(" Move handler: Control pose of the end-effector") print(" Button A: Open | Button B: Close | Button C: Rotate EE (60°)\n") - while sim.is_headless_or_exist_active_visualizer(): - if count % 10000 == 0: - count = 1 + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): + if reset_count % 10000 == 0: + reset_count = 1 root_pose = robot.data.default_root_pose.torch.clone() root_pose[:, :3] += scene.env_origins robot.write_root_pose_to_sim_index(root_pose=root_pose) @@ -330,7 +293,7 @@ def run_simulator( robot_initial_pos, ) - target_pos_tensor = torch.tensor(target_pos, dtype=torch.float32, device=sim.device).unsqueeze(0) + target_pos_tensor = target_pos.unsqueeze(0) current_joint_pos = robot.data.joint_pos.torch[:, arm_joint_indices] ee_pos_w = robot.data.body_pos_w.torch[:, ee_body_idx] @@ -356,19 +319,22 @@ def run_simulator( for _ in range(5): scene.write_data_to_sim() sim.step() - - scene.update(sim_dt) - count += 1 - - # get contact forces and apply force feedback - left_finger_forces = left_finger_sensor.data.net_normal_forces_w[0, 0] - right_finger_forces = right_finger_sensor.data.net_normal_forces_w[0, 0] + scene.update(sim_dt) + reset_count += 1 + step_count += 1 + + left_forces = left_finger_sensor.data.net_normal_forces_w + right_forces = right_finger_sensor.data.net_normal_forces_w + if left_forces is None or right_forces is None: + raise RuntimeError("Haply force feedback requires contact-force data.") + left_finger_forces = left_forces.torch[0, 0] + right_finger_forces = right_forces.torch[0, 0] total_contact_force = (left_finger_forces + right_finger_forces) * 0.5 - haply_device.push_force(forces=total_contact_force.unsqueeze(0), position=torch.tensor([0])) + haply_device.push_force(forces=total_contact_force.unsqueeze(0), position=env_ids) -def main(): - """Main function to set up and run the Haply teleoperation demo.""" +def main() -> None: + """Set up and run the Haply teleoperation example.""" with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: sim_cfg = sim_utils.SimulationCfg(device=args_cli.device, dt=1 / 200, physics=physics_cfg) sim = sim_utils.SimulationContext(sim_cfg) @@ -376,8 +342,9 @@ def main(): # set the simulation view sim.set_camera_view([1.6, 1.0, 1.70], [0.4, 0.0, 1.0]) - scene_cfg = FrankaHaplySceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0) - scene = scene_cfg.class_type(scene_cfg) + scene_cfg = FrankaHaplySceneCfg(num_envs=1, env_spacing=2.0) + scene_class = cast(type["InteractiveScene"], scene_cfg.class_type) + scene = scene_class(scene_cfg) # Create Haply device haply_cfg = HaplyDeviceCfg( diff --git a/scripts/demos/heterogeneous_scene.py b/examples/heterogeneous_scene.py similarity index 83% rename from scripts/demos/heterogeneous_scene.py rename to examples/heterogeneous_scene.py index 31bc8a133865..b556d593187f 100644 --- a/scripts/demos/heterogeneous_scene.py +++ b/examples/heterogeneous_scene.py @@ -9,35 +9,41 @@ fold the scenes together with :func:`~isaaclab.scene.add` while skipping every task's own light and floor, add one Dome light and one shared ground plane, and clone the composition so each environment hosts one task's assets. No task -environments or MDP managers are constructed; the demo owns generic PhysX +environments or MDP managers are constructed; the example owns generic PhysX simulation settings. .. code-block:: bash # Usage with the full default task selection. - ./isaaclab.sh -p scripts/demos/heterogeneous_scene.py + uvx --from 'isaaclab[isaacsim]' isaaclab example heterogeneous-scene # Usage with a smaller composition. - ./isaaclab.sh -p scripts/demos/heterogeneous_scene.py --num_task 3 --num_envs 3 + uvx --from 'isaaclab[isaacsim]' isaaclab example heterogeneous-scene --num_task 3 --num_envs 3 """ from __future__ import annotations -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - import argparse import sys +import isaaclab.sim as sim_utils from isaaclab.app import add_launcher_args, launch_simulation +from isaaclab.assets import AssetBaseCfg +from isaaclab.physics import PhysicsCfg +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.scene import add as scene_add + +from isaaclab_tasks.utils import resolve_task_config parser = argparse.ArgumentParser( - description="Demo: clone-only multi-robot multi-task scene.", + description="Example: clone-only multi-robot multi-task scene.", conflict_handler="resolve", ) parser.add_argument("--num_envs", type=int, default=64, help="Number of environments.") parser.add_argument("--env_spacing", type=float, default=2.5, help="Distance between environment origins [m].") parser.add_argument("--sim_dt", type=float, default=1.0 / 60.0, help="Physics timestep [s].") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") parser.add_argument( "--num_task", type=int, @@ -48,17 +54,19 @@ add_launcher_args(parser) parser.set_defaults(visualizer=["kit"]) args_cli, hydra_args = parser.parse_known_args() -# strip consumed args so hydra-based task-config resolution does not re-parse them +if args_cli.num_envs < 1: + parser.error("--num_envs must be at least 1.") +if args_cli.env_spacing <= 0.0: + parser.error("--env_spacing must be positive.") +if args_cli.sim_dt <= 0.0: + parser.error("--sim_dt must be positive.") +if args_cli.num_task is not None and args_cli.num_task < 2: + parser.error("--num_task must be at least 2.") +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") +# Prevent Hydra from parsing arguments already consumed by this example. sys.argv = [sys.argv[0], *hydra_args] -import isaaclab.sim as sim_utils -from isaaclab.assets import AssetBaseCfg -from isaaclab.physics import PhysicsCfg -from isaaclab.scene import InteractiveSceneCfg -from isaaclab.scene import add as scene_add - -from isaaclab_tasks.utils import resolve_task_config - # Tasks composed by default. The selection criterion is simple: every listed # scene is a PhysX task whose floor is a single flat plane at height zero, so # one shared ground plane can serve the whole composition. Registered tasks @@ -144,14 +152,17 @@ def is_global_asset(a: AssetBaseCfg) -> bool: print(f"[INFO] Composed {len(task_ids)} task scenes into {args_cli.num_envs} environments. Stepping physics.") sim_dt = sim.get_physics_dt() - # Step while a visualizer window is still open (or none exist, e.g. headless). - while True: + step_count = 0 + while sim.is_headless_or_exist_active_visualizer() and ( + args_cli.max_steps < 0 or step_count < args_cli.max_steps + ): if not sim.is_playing(): sim.step() continue scene.write_data_to_sim() sim.step() scene.update(sim_dt) + step_count += 1 if __name__ == "__main__": diff --git a/scripts/demos/markers.py b/examples/markers.py similarity index 92% rename from scripts/demos/markers.py rename to examples/markers.py index 85de26783259..0191239ffbae 100644 --- a/scripts/demos/markers.py +++ b/examples/markers.py @@ -8,35 +8,30 @@ .. code-block:: bash # Usage with default PhysX physics and default kit visualizer. - uv run python scripts/demos/markers.py + uvx --from 'isaaclab[isaacsim]' isaaclab example markers """ -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - import argparse from typing import TYPE_CHECKING from isaaclab.app import add_launcher_args, launch_simulation -# add argparse arguments parser = argparse.ArgumentParser( description="This script demonstrates different types of markers.", conflict_handler="resolve", ) parser.add_argument("--physics", default="isaacsim_physx", choices=["isaacsim_physx"], help="Physics backend.") +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=["kit"]) args_cli = parser.parse_args() +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") import torch import isaaclab.sim as sim_utils -from isaaclab import cloner - -## -# Pre-defined configs -## from isaaclab.markers.visualization_markers_cfg import VisualizationMarkersCfg from isaaclab.physics import PhysicsCfg from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR @@ -105,8 +100,6 @@ def main(): sim = sim_utils.SimulationContext(sim_cfg) # Set main camera sim.set_camera_view([0.0, 18.0, 12.0], [0.0, 3.0, 0.0]) - plan = cloner.make_clone_plan((), 1, 0.0, global_paths=("/World/Light",)) - sim.set_clone_plan(plan) # Spawn things into stage # Lights @@ -115,7 +108,6 @@ def main(): # create markers my_visualizer = define_markers() - cloner.replicate(plan, replicate_physics=False) # define a grid of positions where the markers should be placed num_markers_per_type = 5 @@ -142,8 +134,11 @@ def main(): # Yaw angle yaw = torch.zeros_like(marker_locations[:, 0]) + step_count = 0 # Step while a visualizer window is still open (or none exist, e.g. headless); works for kit and newton. - while sim.is_headless_or_exist_active_visualizer(): + while sim.is_headless_or_exist_active_visualizer() and ( + args_cli.max_steps < 0 or step_count < args_cli.max_steps + ): # rotate the markers around the z-axis for visualization marker_orientations = quat_from_angle_axis(yaw, torch.tensor([0.0, 0.0, 1.0])) # visualize @@ -153,6 +148,7 @@ def main(): marker_indices = torch.roll(marker_indices, 1) # perform step sim.step() + step_count += 1 # increment yaw yaw += 0.01 diff --git a/scripts/demos/mpm/newton_mpm_granular.py b/examples/mpm/newton_mpm_granular.py similarity index 93% rename from scripts/demos/mpm/newton_mpm_granular.py rename to examples/mpm/newton_mpm_granular.py index 844f49f24444..d94e33af9119 100644 --- a/scripts/demos/mpm/newton_mpm_granular.py +++ b/examples/mpm/newton_mpm_granular.py @@ -5,7 +5,7 @@ """Isaac Lab port of Newton's granular implicit-MPM example. -A block of granular material is dropped onto static box colliders. The demo +A block of granular material is dropped onto static box colliders. The example shows the intended Isaac Lab MPM path: configure :class:`~isaaclab_newton.physics.MPMSolverCfg`, add an :class:`~isaaclab_newton.assets.MPMObject` to an @@ -15,7 +15,7 @@ .. code-block:: bash - uv run python scripts/demos/mpm/newton_mpm_granular.py --visualizer newton_gl + uvx isaaclab example mpm-granular --visualizer newton_gl """ from __future__ import annotations @@ -25,7 +25,7 @@ from isaaclab.app import add_launcher_args, launch_simulation -parser = argparse.ArgumentParser(description="Newton implicit MPM granular demo.") +parser = argparse.ArgumentParser(description="Newton implicit MPM granular example.") parser.add_argument( "--max_steps", type=int, @@ -74,7 +74,7 @@ def create_visualizer_cfgs(): - """Create demo-specific visualizer configs for the requested backends.""" + """Create example-specific visualizer configs for the requested backends.""" if not any(v in (args_cli.visualizer or []) for v in ("newton", "newton_gl", "newton_rtx")): return [] @@ -121,6 +121,7 @@ def create_scene_cfg(): import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg from isaaclab.scene import InteractiveSceneCfg + from isaaclab.sim.spawners.materials import UsdPhysicsRigidBodyMaterialCfg from isaaclab.utils import configclass def collider_cfg(prim_path: str, center, half_extents, orientation, friction: float = 0.1) -> AssetBaseCfg: @@ -132,7 +133,7 @@ def collider_cfg(prim_path: str, center, half_extents, orientation, friction: fl sim_utils.UsdPhysicsCollisionCfg(collision_enabled=True), NewtonCollisionCfg(contact_margin=COLLIDER_MARGIN), ], - physics_material=sim_utils.NewtonMaterialPropertiesCfg( + physics_material=UsdPhysicsRigidBodyMaterialCfg( static_friction=friction, dynamic_friction=friction, ), @@ -186,7 +187,7 @@ def particle_count(scene) -> int: def keep_running(sim, count: int) -> bool: - """Return whether the demo loop should continue this frame.""" + """Return whether the example loop should continue this frame.""" if args_cli.max_steps >= 0 and count >= args_cli.max_steps: return False return sim.is_headless_or_exist_active_visualizer() @@ -205,7 +206,7 @@ def run_simulator(sim, scene) -> None: def main() -> None: - """Launch and run the Isaac Lab MPM granular demo.""" + """Launch and run the Isaac Lab MPM granular example.""" sim_cfg = create_sim_cfg() with launch_simulation(sim_cfg, args_cli): import isaaclab.sim as sim_utils @@ -216,7 +217,7 @@ def main() -> None: scene = InteractiveScene(create_scene_cfg()) sim.reset() print( - f"[INFO]: Isaac Lab Newton granular MPM demo ready. Spawned {particle_count(scene)} particles.", + f"[INFO]: Isaac Lab Newton granular MPM example ready. Spawned {particle_count(scene)} particles.", flush=True, ) run_simulator(sim, scene) diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/examples/mpm/newton_mpm_twoway_coupling.py similarity index 94% rename from scripts/demos/mpm/newton_mpm_twoway_coupling.py rename to examples/mpm/newton_mpm_twoway_coupling.py index d841b424de3f..c392949b7f41 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/examples/mpm/newton_mpm_twoway_coupling.py @@ -11,7 +11,7 @@ .. code-block:: bash - uv run python scripts/demos/mpm/newton_mpm_twoway_coupling.py + uvx isaaclab example mpm-two-way-coupling The spheres roll through three V-shaped chutes into the bath. Right-click and drag any sphere to apply an interactive force. @@ -24,13 +24,15 @@ from functools import partial from typing import TYPE_CHECKING +from isaaclab_visualizers.newton import NewtonGLVisualizerCfg, NewtonRTXVisualizerCfg + import isaaclab.sim as sim_utils from isaaclab.app import add_launcher_args, launch_simulation if TYPE_CHECKING: from pxr import Usd -parser = argparse.ArgumentParser(description="Newton rigid-sphere and MPM-sand two-way coupling demo.") +parser = argparse.ArgumentParser(description="Newton rigid-sphere and MPM-sand two-way coupling example.") 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.08, help="MPM grid voxel size [m].") parser.add_argument("--rigid_substeps", type=int, default=4, help="Rigid-solver substeps per coupled step.") @@ -90,13 +92,11 @@ def _spawn_colored_shape( def create_visualizer_cfgs(): - """Create the demo-specific Newton visualizer configuration.""" + """Create the example-specific Newton visualizer configuration.""" requested = args_cli.visualizer or [] if not {"newton", "newton_gl", "newton_rtx"}.intersection(requested): return [] - from isaaclab_visualizers.newton import NewtonGLVisualizerCfg, NewtonRTXVisualizerCfg - cfg_type = NewtonRTXVisualizerCfg if requested == ["newton_rtx"] else NewtonGLVisualizerCfg return [ cfg_type( @@ -166,6 +166,7 @@ def create_scene_cfg(): from isaaclab.assets import AssetBaseCfg, RigidObjectCfg, RigidObjectCollectionCfg from isaaclab.scene import InteractiveSceneCfg + from isaaclab.sim.spawners.materials import UsdPhysicsRigidBodyMaterialCfg from isaaclab.utils import configclass def bath_collider( @@ -185,7 +186,7 @@ def bath_collider( ), size=size, collision_props=NewtonCollisionCfg(contact_margin=COLLIDER_MARGIN), - physics_material=sim_utils.NewtonMaterialPropertiesCfg( + physics_material=UsdPhysicsRigidBodyMaterialCfg( static_friction=0.6, dynamic_friction=0.6, ), @@ -207,7 +208,7 @@ def bath_collider( rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), mass_props=sim_utils.MassCfg(mass=SPHERE_MASS), collision_props=sim_utils.UsdPhysicsCollisionCfg(), - physics_material=sim_utils.NewtonMaterialPropertiesCfg( + physics_material=UsdPhysicsRigidBodyMaterialCfg( static_friction=0.5, dynamic_friction=0.5, ), @@ -303,7 +304,7 @@ def run_simulator(sim, scene) -> None: def main() -> None: - """Launch the two-way rigid-MPM coupling demo.""" + """Launch the two-way rigid-MPM coupling example.""" sim_cfg = create_sim_cfg() with launch_simulation(sim_cfg, args_cli): from isaaclab.scene import InteractiveScene @@ -315,7 +316,7 @@ def main() -> None: 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.", + f"[INFO]: Isaac Lab Newton two-way MPM example ready. Spawned {particle_count} particles.", flush=True, ) print("[INFO]: Right-click and drag any sphere in the Newton viewer.", flush=True) diff --git a/scripts/demos/multi_asset.py b/examples/multi_asset.py similarity index 88% rename from scripts/demos/multi_asset.py rename to examples/multi_asset.py index d7ee5ab2a6c5..10f9dd3995db 100644 --- a/scripts/demos/multi_asset.py +++ b/examples/multi_asset.py @@ -3,56 +3,39 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""This script demonstrates how to spawn multiple objects in multiple environments. +"""Spawn several asset types across cloned environments. .. code-block:: bash - # Usage with default PhysX physics and default kit visualizer. - uv run --extra isaacsim python scripts/demos/multi_asset.py --num_envs 1024 - - # Usage with Newton GL visualizer and default PhysX physics. - uv run --extra isaacsim python scripts/demos/multi_asset.py --visualizer newton_gl --num_envs 1024 - - # Usage with Newton (MJWarp) physics and default kit visualizer. - uv run --extra isaacsim python scripts/demos/multi_asset.py --physics newton_mjwarp --num_envs 1024 - - # Usage with Newton GL visualizer and Newton (MJWarp) physics. - uv run python scripts/demos/multi_asset.py --visualizer newton_gl --physics newton_mjwarp --num_envs 1024 - + uvx isaaclab example multi-asset --num_envs 1024 """ from __future__ import annotations -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - import argparse from typing import TYPE_CHECKING from isaaclab.app import add_launcher_args, launch_simulation -# add argparse arguments parser = argparse.ArgumentParser( - description="Demo on spawning different objects in multiple environments.", + description="Example of spawning different objects in multiple environments.", conflict_handler="resolve", ) parser.add_argument("--num_envs", type=int, default=512, help="Number of environments to spawn.") parser.add_argument( - "--physics", default="isaacsim_physx", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." + "--physics", default="newton_mjwarp", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." ) +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") add_launcher_args(parser) -# demos should open Kit visualizer by default -parser.set_defaults(visualizer=["kit"]) -# parse the arguments +parser.set_defaults(visualizer=["newton_gl"]) args_cli = parser.parse_args() +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") from isaaclab_newton.sim.schemas import NewtonArticulationCfg from isaaclab_physx.sim.schemas import PhysxArticulationCfg, PhysxRigidBodyCfg import isaaclab.sim as sim_utils - -## -# Pre-defined configs -## from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg, RigidObjectCollectionCfg from isaaclab.physics import PhysicsCfg from isaaclab.scene import InteractiveSceneCfg @@ -193,8 +176,9 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): # Define simulation stepping sim_dt = sim.get_physics_dt() count = 0 + step_count = 0 # Step while a visualizer window is still open (or none exist, e.g. headless); works for kit and newton. - while sim.is_headless_or_exist_active_visualizer(): + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): # Reset if count % 250 == 0: # reset counter @@ -234,6 +218,7 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): scene.write_data_to_sim() # Perform step sim.step() + step_count += 1 # Increment counter count += 1 # Update buffers diff --git a/scripts/demos/newton_viewer_dominoes.py b/examples/newton_viewer_dominoes.py similarity index 92% rename from scripts/demos/newton_viewer_dominoes.py rename to examples/newton_viewer_dominoes.py index 92adf888df66..e1754586fc67 100644 --- a/scripts/demos/newton_viewer_dominoes.py +++ b/examples/newton_viewer_dominoes.py @@ -10,7 +10,7 @@ .. code-block:: bash - uv run python scripts/demos/newton_viewer_dominoes.py + uvx isaaclab example newton-dominoes """ import argparse @@ -18,7 +18,7 @@ from isaaclab.app import add_launcher_args, launch_simulation -parser = argparse.ArgumentParser(description="NVIDIA-logo domino dragging demo (XPBD).") +parser = argparse.ArgumentParser(description="NVIDIA-logo domino dragging example (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_gl"]) @@ -32,6 +32,7 @@ import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg, RigidObjectCfg, RigidObjectCollectionCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.sim.spawners.materials import UsdPhysicsRigidBodyMaterialCfg from isaaclab.utils import configclass DOMINO_SIZE = (0.12, 0.032, 0.36) @@ -39,7 +40,7 @@ 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_PATH = Path(__file__).resolve().parent / "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] @@ -59,7 +60,7 @@ def _domino_cfg(position: tuple[float, float, float], orientation: tuple[float, rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), mass_props=sim_utils.MassCfg(density=580.0), collision_props=sim_utils.UsdPhysicsCollisionCfg(), - physics_material=sim_utils.RigidBodyMaterialCfg( + physics_material=UsdPhysicsRigidBodyMaterialCfg( static_friction=1.0, dynamic_friction=1.0, restitution=0.15, @@ -79,7 +80,7 @@ def _trigger_cfg() -> RigidObjectCfg: rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), mass_props=sim_utils.MassCfg(density=20.0), collision_props=sim_utils.UsdPhysicsCollisionCfg(), - physics_material=sim_utils.RigidBodyMaterialCfg( + physics_material=UsdPhysicsRigidBodyMaterialCfg( static_friction=1.0, dynamic_friction=1.0, restitution=0.15, @@ -98,7 +99,7 @@ class DominoSceneCfg(InteractiveSceneCfg): spawn=sim_utils.CuboidCfg( size=(LOGO_FOOTPRINT[0] + 4.0, LOGO_FOOTPRINT[1] + 4.0, 0.10), collision_props=sim_utils.UsdPhysicsCollisionCfg(), - physics_material=sim_utils.RigidBodyMaterialCfg( + physics_material=UsdPhysicsRigidBodyMaterialCfg( static_friction=1.0, dynamic_friction=1.0, restitution=0.15, @@ -134,7 +135,7 @@ def run_simulator(sim: sim_utils.SimulationContext) -> None: def main() -> None: - """Launch the Newton XPBD domino dragging demo.""" + """Launch the Newton XPBD domino dragging example.""" physics_cfg = NewtonCfg( num_substeps=10, collision_decimation=1, diff --git a/scripts/demos/procedural_terrain.py b/examples/procedural_terrain.py similarity index 87% rename from scripts/demos/procedural_terrain.py rename to examples/procedural_terrain.py index 927f3a3d3d6e..bfaf7c5cae84 100644 --- a/scripts/demos/procedural_terrain.py +++ b/examples/procedural_terrain.py @@ -11,32 +11,29 @@ .. code-block:: bash # Generate terrain with height color scheme - uv run python scripts/demos/procedural_terrain.py --color_scheme height + uvx --from 'isaaclab[isaacsim]' isaaclab example procedural-terrain --color_scheme height # Generate terrain with random color scheme - uv run python scripts/demos/procedural_terrain.py --color_scheme random + uvx --from 'isaaclab[isaacsim]' isaaclab example procedural-terrain --color_scheme random # Generate terrain with no color scheme - uv run python scripts/demos/procedural_terrain.py --color_scheme none + uvx --from 'isaaclab[isaacsim]' isaaclab example procedural-terrain --color_scheme none # Generate terrain with curriculum - uv run python scripts/demos/procedural_terrain.py --use_curriculum + uvx --from 'isaaclab[isaacsim]' isaaclab example procedural-terrain --use_curriculum # Generate terrain with curriculum along with flat patches - uv run python scripts/demos/procedural_terrain.py --use_curriculum --show_flat_patches + uvx --from 'isaaclab[isaacsim]' isaaclab example procedural-terrain --use_curriculum --show_flat_patches """ from __future__ import annotations -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - import argparse from typing import TYPE_CHECKING from isaaclab.app import add_launcher_args, launch_simulation -# add argparse arguments parser = argparse.ArgumentParser( description="This script demonstrates procedural terrain generation.", conflict_handler="resolve", @@ -61,20 +58,18 @@ help="Whether to show the flat patches computed during the terrain generation.", ) parser.add_argument("--physics", default="isaacsim_physx", choices=["isaacsim_physx"], help="Physics backend.") +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=["kit"]) args_cli = parser.parse_args() +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") import random import torch import isaaclab.sim as sim_utils -from isaaclab import cloner - -## -# Pre-defined configs -## from isaaclab.markers.visualization_markers_cfg import VisualizationMarkersCfg from isaaclab.physics import PhysicsCfg from isaaclab.terrains.sub_terrain_cfg import FlatPatchSamplingCfg @@ -151,10 +146,12 @@ def design_scene() -> tuple[dict, torch.Tensor]: def run_simulator(sim: sim_utils.SimulationContext, entities: dict[str, AssetBase], origins: torch.Tensor): """Runs the simulation loop.""" + step_count = 0 # Step while a visualizer window is still open (or none exist, e.g. headless); works for kit and newton. - while sim.is_headless_or_exist_active_visualizer(): + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): # perform step sim.step() + step_count += 1 def main(): @@ -166,10 +163,7 @@ def main(): # Set main camera sim.set_camera_view(eye=[15.0, 15.0, 15.0], target=[0.0, 0.0, 0.0]) # design scene - plan = cloner.make_clone_plan((), 1, 0.0, global_paths=("/World/Light", "/World/ground")) - sim.set_clone_plan(plan) scene_entities, scene_origins = design_scene() - cloner.replicate(plan, replicate_physics=False) # Play the simulator sim.reset() # Now we are ready! diff --git a/scripts/demos/sensors/cameras.py b/examples/sensors/cameras.py similarity index 65% rename from scripts/demos/sensors/cameras.py rename to examples/sensors/cameras.py index 8bf7af71f552..8a3b48387486 100644 --- a/scripts/demos/sensors/cameras.py +++ b/examples/sensors/cameras.py @@ -3,56 +3,58 @@ # # SPDX-License-Identifier: BSD-3-Clause -""" -This script demonstrates the different camera sensors that can be attached to a robot. +"""Demonstrate camera and ray-caster camera sensors attached to a robot. .. code-block:: bash # Usage - uv run python scripts/demos/sensors/cameras.py + uvx --from 'isaaclab[isaacsim]' isaaclab example camera # Usage in headless mode - uv run python scripts/demos/sensors/cameras.py + uvx --from 'isaaclab[isaacsim]' isaaclab example camera --headless """ -"""Launch Isaac Sim Simulator first.""" - import argparse +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np +import torch from isaaclab.app import AppLauncher -# add argparse arguments parser = argparse.ArgumentParser(description="Example on using the different camera sensor implementations.") parser.add_argument("--num_envs", type=int, default=4, help="Number of environments to spawn.") parser.add_argument("--disable_fabric", action="store_true", help="Disable Fabric API and use USD instead.") +parser.add_argument("--log_interval", type=int, default=100, help="Steps between compact sensor summaries.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") +parser.add_argument("--save", action="store_true", help="Save sampled RGB and depth images.") +parser.add_argument("--save_interval", type=int, default=100, help="Steps between saved image samples.") +parser.add_argument("--output_dir", type=Path, default=Path("output/camera"), help="Directory for saved images.") parser.add_argument( "--physics", default="isaacsim_physx", choices=["isaacsim_physx"], help="Physics backend.", ) -# append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) -# demos should open Kit visualizer by default parser.set_defaults(visualizer=["kit"]) -# parse the arguments args_cli = parser.parse_args() +if args_cli.log_interval < 1: + parser.error("--log_interval must be at least 1.") +if args_cli.save_interval < 1: + parser.error("--save_interval must be at least 1.") +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") # Camera sensors require the rendering extensions in headless and viewport-free launches. args_cli.enable_cameras = True -# launch omniverse app app_launcher = AppLauncher(args_cli) simulation_app = app_launcher.app -"""Rest everything follows.""" - -import os - -import matplotlib.pyplot as plt -import numpy as np -import torch +# Simulator-dependent imports must follow AppLauncher initialization. import isaaclab.sim as sim_utils from isaaclab.assets import ArticulationCfg, AssetBaseCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg @@ -61,9 +63,6 @@ from isaaclab.terrains import TerrainImporterCfg from isaaclab.utils import configclass -## -# Pre-defined configs -## from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG # isort:skip from isaaclab_assets.robots.anymal import ANYMAL_C_CFG # isort: skip @@ -102,15 +101,6 @@ class SensorsSceneCfg(InteractiveSceneCfg): ), offset=CameraCfg.OffsetCfg(pos=(0.510, 0.0, 0.015), rot=(0.5, -0.5, 0.5, -0.5), convention="ros"), ) - tiled_camera = CameraCfg( - prim_path="{ENV_REGEX_NS}/Robot/base/front_cam", - update_period=0.1, - height=480, - width=640, - data_types=["rgb", "distance_to_image_plane"], - spawn=None, # the camera is already spawned in the scene - offset=CameraCfg.OffsetCfg(pos=(0.510, 0.0, 0.015), rot=(0.5, -0.5, 0.5, -0.5), convention="ros"), - ) raycast_camera = RayCasterCameraCfg( prim_path="{ENV_REGEX_NS}/Robot/base", mesh_prim_paths=["/World/ground"], @@ -132,19 +122,18 @@ def save_images_grid( nrow: int = 1, subtitles: list[str] | None = None, title: str | None = None, - filename: str | None = None, -): + filename: str | Path | None = None, +) -> None: """Save images in a grid with optional subtitles and title. Args: images: A list of images to be plotted. Shape of each image should be (H, W, C). cmap: Colormap to be used for plotting. Defaults to None, in which case the default colormap is used. - nrows: Number of rows in the grid. Defaults to 1. + nrow: Number of rows in the grid. Defaults to 1. subtitles: A list of subtitles for each image. Defaults to None, in which case no subtitles are shown. title: Title of the grid. Defaults to None, in which case no title is shown. filename: Path to save the figure. Defaults to None, in which case the figure is not saved. """ - # show images in a grid n_images = len(images) ncol = int(np.ceil(n_images / nrow)) @@ -154,47 +143,36 @@ def save_images_grid( else: axes = np.array([axes]) - # plot images for idx, (img, ax) in enumerate(zip(images, axes)): img = img.detach().cpu().numpy() ax.imshow(img, cmap=cmap) ax.axis("off") if subtitles: ax.set_title(subtitles[idx]) - # remove extra axes if any for ax in axes[n_images:]: fig.delaxes(ax) - # set title if title: plt.suptitle(title) - # adjust layout to fit the title plt.tight_layout() - # save the figure if filename: - os.makedirs(os.path.dirname(filename), exist_ok=True) + Path(filename).parent.mkdir(parents=True, exist_ok=True) plt.savefig(filename) - # close the figure plt.close() -def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): +def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> None: """Run the simulator.""" # Define simulation stepping sim_dt = sim.get_physics_dt() - sim_time = 0.0 count = 0 - # Create output directory to save images - output_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "output") - os.makedirs(output_dir, exist_ok=True) + if args_cli.save: + args_cli.output_dir.mkdir(parents=True, exist_ok=True) - # Simulate physics - while simulation_app.is_running(): + while simulation_app.is_running() and (args_cli.max_steps < 0 or count < args_cli.max_steps): # Reset if count % 500 == 0: - # reset counter - count = 0 # reset the scene entities # root state # we offset the root state by the origin since the states are written in simulation world frame @@ -222,75 +200,42 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): scene["robot"].set_joint_position_target_index(target=targets) # -- write data to sim scene.write_data_to_sim() - # perform step sim.step() - # update sim-time - sim_time += sim_dt count += 1 - # update buffers scene.update(sim_dt) - # print information from the sensors - print("-------------------------------") - print(scene["camera"]) - print("Received shape of rgb image: ", scene["camera"].data.output["rgb"].shape) - print("Received shape of depth image: ", scene["camera"].data.output["distance_to_image_plane"].shape) - print("-------------------------------") - print(scene["tiled_camera"]) - print("Received shape of rgb image: ", scene["tiled_camera"].data.output["rgb"].shape) - print("Received shape of depth image: ", scene["tiled_camera"].data.output["distance_to_image_plane"].shape) - print("-------------------------------") - print(scene["raycast_camera"]) - print("Received shape of depth: ", scene["raycast_camera"].data.output["distance_to_image_plane"].shape) - print("Received shape of normals: ", scene["raycast_camera"].data.output["normals"].shape) - - # save every 10th image (for visualization purposes only) - # note: saving images will slow down the simulation - if count % 10 == 0: - # compare generated RGB images across different cameras - rgb_images = [scene["camera"].data.output["rgb"][0, ..., :3], scene["tiled_camera"].data.output["rgb"][0]] + if count % args_cli.log_interval == 0: + camera_output = scene["camera"].data.output + raycast_output = scene["raycast_camera"].data.output + print( + f"[INFO] step={count} rgb={tuple(camera_output['rgb'].shape)} " + f"depth={tuple(camera_output['distance_to_image_plane'].shape)} " + f"raycast_depth={tuple(raycast_output['distance_to_image_plane'].shape)}" + ) + + if args_cli.save and count % args_cli.save_interval == 0: + rgb_images = [scene["camera"].data.output["rgb"][0, ..., :3]] save_images_grid( rgb_images, - subtitles=["Camera", "TiledCamera"], - title="RGB Image: Cam0", - filename=os.path.join(output_dir, "rgb", f"{count:04d}.jpg"), + subtitles=["Camera"], + title="RGB image", + filename=str(args_cli.output_dir / "rgb" / f"{count:06d}.jpg"), ) - - # compare generated Depth images across different cameras depth_images = [ scene["camera"].data.output["distance_to_image_plane"][0], - scene["tiled_camera"].data.output["distance_to_image_plane"][0, ..., 0], scene["raycast_camera"].data.output["distance_to_image_plane"][0], ] save_images_grid( depth_images, cmap="turbo", - subtitles=["Camera", "TiledCamera", "RaycasterCamera"], - title="Depth Image: Cam0", - filename=os.path.join(output_dir, "distance_to_camera", f"{count:04d}.jpg"), - ) - - # save all tiled RGB images - tiled_images = scene["tiled_camera"].data.output["rgb"] - save_images_grid( - tiled_images, - subtitles=[f"Cam{i}" for i in range(tiled_images.shape[0])], - title="Tiled RGB Image", - filename=os.path.join(output_dir, "tiled_rgb", f"{count:04d}.jpg"), - ) - - # save all camera RGB images - cam_images = scene["camera"].data.output["rgb"][..., :3] - save_images_grid( - cam_images, - subtitles=[f"Cam{i}" for i in range(cam_images.shape[0])], - title="Camera RGB Image", - filename=os.path.join(output_dir, "cam_rgb", f"{count:04d}.jpg"), + subtitles=["Camera", "Ray-caster camera"], + title="Depth comparison", + filename=str(args_cli.output_dir / "depth" / f"{count:06d}.jpg"), ) -def main(): - """Main function.""" +def main() -> None: + """Run the camera example.""" # Initialize the simulation context sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device, use_fabric=not args_cli.disable_fabric) sim = sim_utils.SimulationContext(sim_cfg) diff --git a/scripts/demos/sensors/contact_sensor.py b/examples/sensors/contact_sensor.py similarity index 66% rename from scripts/demos/sensors/contact_sensor.py rename to examples/sensors/contact_sensor.py index 5e80727c0b5a..456e080e868e 100644 --- a/scripts/demos/sensors/contact_sensor.py +++ b/examples/sensors/contact_sensor.py @@ -3,43 +3,41 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" +"""Inspect contact forces measured on a robot and a falling cube.""" import argparse from typing import TYPE_CHECKING, cast +import torch + from isaaclab.app import add_launcher_args, launch_simulation -# add argparse arguments parser = argparse.ArgumentParser(description="Example on using the contact sensor.") parser.add_argument("--num_envs", type=int, default=1, help="Number of environments to spawn.") +parser.add_argument("--log_interval", type=int, default=100, help="Steps between compact sensor summaries.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") parser.add_argument( "--physics", - default="isaacsim_physx", + default="newton_mjwarp", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend.", ) -# append launcher CLI args add_launcher_args(parser) -# demos should open Kit visualizer by default -parser.set_defaults(visualizer=["kit"]) -# parse the arguments +parser.set_defaults(visualizer=["newton_gl"]) args_cli = parser.parse_args() - -"""Rest everything follows.""" - -import torch +if args_cli.log_interval < 1: + parser.error("--log_interval must be at least 1.") +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg, RigidObjectCfg from isaaclab.physics import PhysicsCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.sensors import ContactSensorCfg +from isaaclab.sim.spawners.materials import UsdPhysicsRigidBodyMaterialCfg from isaaclab.utils import configclass -## -# Pre-defined configs -## from isaaclab_assets.robots.anymal import ANYMAL_C_CFG # isort: skip if TYPE_CHECKING: @@ -69,7 +67,7 @@ class ContactSensorSceneCfg(InteractiveSceneCfg): rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), mass_props=sim_utils.MassCfg(mass=100.0), collision_props=sim_utils.UsdPhysicsCollisionCfg(), - physics_material=sim_utils.RigidBodyMaterialCfg(static_friction=1.0), + physics_material=UsdPhysicsRigidBodyMaterialCfg(static_friction=1.0), visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 1.0, 0.0), metallic=0.2), ), init_state=RigidObjectCfg.InitialStateCfg(pos=(0.5, 0.5, 0.05)), @@ -102,28 +100,18 @@ class ContactSensorSceneCfg(InteractiveSceneCfg): ) -def run_simulator(sim: sim_utils.SimulationContext, scene: "InteractiveScene"): +def run_simulator(sim: sim_utils.SimulationContext, scene: "InteractiveScene") -> None: """Run the simulator.""" - # Define simulation stepping sim_dt = sim.get_physics_dt() - sim_time = 0.0 count = 0 - # Simulate physics - while sim.is_headless_or_exist_active_visualizer(): + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or count < args_cli.max_steps): if count % 500 == 0: - # reset counter - count = 0 - # reset the scene entities - # root state - # we offset the root state by the origin since the states are written in simulation world frame - # if this is not done, then the robots will be spawned at the (0, 0, 0) of the simulation world root_pose = scene["robot"].data.default_root_pose.torch.clone() root_pose[:, :3] += scene.env_origins scene["robot"].write_root_pose_to_sim_index(root_pose=root_pose) root_vel = scene["robot"].data.default_root_vel.torch.clone() scene["robot"].write_root_velocity_to_sim_index(root_velocity=root_vel) - # set joint positions with some noise joint_pos, joint_vel = ( scene["robot"].data.default_joint_pos.torch.clone(), scene["robot"].data.default_joint_vel.torch.clone(), @@ -131,62 +119,38 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: "InteractiveScene"): joint_pos += torch.rand_like(joint_pos) * 0.1 scene["robot"].write_joint_position_to_sim_index(position=joint_pos) scene["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) - # clear internal buffers scene.reset() print("[INFO]: Resetting robot state...") - # Apply default actions to the robot - # -- generate actions/commands targets = scene["robot"].data.default_joint_pos.torch - # -- apply action to the robot scene["robot"].set_joint_position_target_index(target=targets) - # -- write data to sim scene.write_data_to_sim() - # perform step sim.step() - # update sim-time - sim_time += sim_dt count += 1 - # update buffers scene.update(sim_dt) - # print information from the sensors - print("-------------------------------") - print(scene["contact_forces_LF"]) - print("Received force matrix of: ", scene["contact_forces_LF"].data.normal_force_matrix_w) - print("Received contact force of: ", scene["contact_forces_LF"].data.net_normal_forces_w) - print("-------------------------------") - print(scene["contact_forces_RF"]) - print("Received force matrix of: ", scene["contact_forces_RF"].data.normal_force_matrix_w) - print("Received contact force of: ", scene["contact_forces_RF"].data.net_normal_forces_w) - print("-------------------------------") - print(scene["contact_forces_H"]) - print("Received force matrix of: ", scene["contact_forces_H"].data.normal_force_matrix_w) - print("Received contact force of: ", scene["contact_forces_H"].data.net_normal_forces_w) - if args_cli.physics == "newton_mjwarp": - print("Received friction force of: ", scene["contact_forces_H"].data.net_friction_forces_w) - - -def main(): - """Main function.""" + if count % args_cli.log_interval == 0: + summaries = [] + for name in ("contact_forces_LF", "contact_forces_RF", "contact_forces_H"): + force = scene[name].data.net_normal_forces_w + if force is None: + raise RuntimeError(f"Contact sensor {name!r} did not produce normal forces.") + summaries.append(f"{name}={force.torch.norm(dim=-1).mean().item():.3f} N") + print(f"[INFO] step={count} mean contact force: " + ", ".join(summaries)) + +def main() -> None: + """Run the contact-sensor example.""" with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: - # Initialize the simulation context sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device, physics=physics_cfg) sim = sim_utils.SimulationContext(sim_cfg) - # Set main camera sim.set_camera_view(eye=(3.5, 3.5, 3.5), target=(0.0, 0.0, 0.0)) - # design scene scene_cfg = ContactSensorSceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0) scene_class = cast(type["InteractiveScene"], scene_cfg.class_type) scene = scene_class(scene_cfg) - # Play the simulator sim.reset() - # Now we are ready! print("[INFO]: Setup complete...") - # Run the simulator run_simulator(sim, scene) if __name__ == "__main__": - # run the main function main() diff --git a/scripts/demos/sensors/frame_transformer_sensor.py b/examples/sensors/frame_transformer_sensor.py similarity index 57% rename from scripts/demos/sensors/frame_transformer_sensor.py rename to examples/sensors/frame_transformer_sensor.py index 2c085ff61dfd..1ed74a8bc464 100644 --- a/scripts/demos/sensors/frame_transformer_sensor.py +++ b/examples/sensors/frame_transformer_sensor.py @@ -3,44 +3,44 @@ # # SPDX-License-Identifier: BSD-3-Clause +"""Track poses between a robot's base, feet, and end-effector frames.""" + import argparse +from typing import TYPE_CHECKING, cast -from isaaclab.app import AppLauncher +import torch + +import isaaclab.sim as sim_utils +from isaaclab.app import add_launcher_args, launch_simulation +from isaaclab.assets import AssetBaseCfg, RigidObjectCfg +from isaaclab.physics import PhysicsCfg +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sensors import FrameTransformerCfg +from isaaclab.sim.spawners.materials import UsdPhysicsRigidBodyMaterialCfg +from isaaclab.utils import configclass + +from isaaclab_assets.robots.anymal import ANYMAL_C_CFG + +if TYPE_CHECKING: + from isaaclab.scene import InteractiveScene -# add argparse arguments parser = argparse.ArgumentParser(description="Example on using the frame transformer sensor.") parser.add_argument("--num_envs", type=int, default=1, help="Number of environments to spawn.") +parser.add_argument("--log_interval", type=int, default=100, help="Steps between compact sensor summaries.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") parser.add_argument( "--physics", default="isaacsim_physx", choices=["isaacsim_physx"], help="Physics backend.", ) -# append AppLauncher cli args -AppLauncher.add_app_launcher_args(parser) -# demos should open Kit visualizer by default +add_launcher_args(parser) parser.set_defaults(visualizer=["kit"]) -# parse the arguments args_cli = parser.parse_args() - -# launch omniverse app -app_launcher = AppLauncher(args_cli) -simulation_app = app_launcher.app - -"""Rest everything follows.""" - -import torch - -import isaaclab.sim as sim_utils -from isaaclab.assets import AssetBaseCfg, RigidObjectCfg -from isaaclab.scene import InteractiveScene, InteractiveSceneCfg -from isaaclab.sensors import FrameTransformerCfg -from isaaclab.utils import configclass - -## -# Pre-defined configs -## -from isaaclab_assets.robots.anymal import ANYMAL_C_CFG # isort: skip +if args_cli.log_interval < 1: + parser.error("--log_interval must be at least 1.") +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") @configclass @@ -66,7 +66,7 @@ class FrameTransformerSensorSceneCfg(InteractiveSceneCfg): rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), mass_props=sim_utils.MassCfg(mass=100.0), collision_props=sim_utils.UsdPhysicsCollisionCfg(), - physics_material=sim_utils.RigidBodyMaterialCfg(static_friction=1.0), + physics_material=UsdPhysicsRigidBodyMaterialCfg(static_friction=1.0), visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 1.0, 0.0), metallic=0.2), ), init_state=RigidObjectCfg.InitialStateCfg(pos=(5, 0, 0.5)), @@ -94,28 +94,18 @@ class FrameTransformerSensorSceneCfg(InteractiveSceneCfg): ) -def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): +def run_simulator(sim: sim_utils.SimulationContext, scene: "InteractiveScene") -> None: """Run the simulator.""" - # Define simulation stepping sim_dt = sim.get_physics_dt() - sim_time = 0.0 count = 0 - # Simulate physics - while simulation_app.is_running(): + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or count < args_cli.max_steps): if count % 500 == 0: - # reset counter - count = 0 - # reset the scene entities - # root state - # we offset the root state by the origin since the states are written in simulation world frame - # if this is not done, then the robots will be spawned at the (0, 0, 0) of the simulation world root_pose = scene["robot"].data.default_root_pose.torch.clone() root_pose[:, :3] += scene.env_origins scene["robot"].write_root_pose_to_sim_index(root_pose=root_pose) root_vel = scene["robot"].data.default_root_vel.torch.clone() scene["robot"].write_root_velocity_to_sim_index(root_velocity=root_vel) - # set joint positions with some noise joint_pos, joint_vel = ( scene["robot"].data.default_joint_pos.torch.clone(), scene["robot"].data.default_joint_vel.torch.clone(), @@ -123,58 +113,34 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): joint_pos += torch.rand_like(joint_pos) * 0.1 scene["robot"].write_joint_position_to_sim_index(position=joint_pos) scene["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) - # clear internal buffers scene.reset() print("[INFO]: Resetting robot state...") - # Apply default actions to the robot - # -- generate actions/commands targets = scene["robot"].data.default_joint_pos.torch - # -- apply action to the robot scene["robot"].set_joint_position_target_index(target=targets) - # -- write data to sim scene.write_data_to_sim() - # perform step sim.step() - # update sim-time - sim_time += sim_dt count += 1 - # update buffers scene.update(sim_dt) - # print information from the sensors - print("-------------------------------") - print(scene["specific_transforms"]) - print("relative transforms:", scene["specific_transforms"].data.target_pos_source) - print("relative orientations:", scene["specific_transforms"].data.target_quat_source) - print("-------------------------------") - print(scene["cube_transform"]) - print("relative transform:", scene["cube_transform"].data.target_pos_source) - print("-------------------------------") - print(scene["robot_transforms"]) - print("relative transforms:", scene["robot_transforms"].data.target_pos_source) - - -def main(): - """Main function.""" - - # Initialize the simulation context - sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device) - sim = sim_utils.SimulationContext(sim_cfg) - # Set main camera - sim.set_camera_view(eye=[3.5, 3.5, 3.5], target=[0.0, 0.0, 0.0]) - # design scene - scene_cfg = FrameTransformerSensorSceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0) - scene = InteractiveScene(scene_cfg) - # Play the simulator - sim.reset() - # Now we are ready! - print("[INFO]: Setup complete...") - # Run the simulator - run_simulator(sim, scene) + if count % args_cli.log_interval == 0: + feet = scene["specific_transforms"].data.target_pos_source.torch[0] + cube = scene["cube_transform"].data.target_pos_source.torch[0, 0] + print(f"[INFO] step={count} feet_pos_b={feet.tolist()} cube_pos_b={cube.tolist()}") + + +def main() -> None: + """Run the frame-transformer example.""" + with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: + sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device, physics=physics_cfg) + sim = sim_utils.SimulationContext(sim_cfg) + sim.set_camera_view(eye=[3.5, 3.5, 3.5], target=[0.0, 0.0, 0.0]) + scene_cfg = FrameTransformerSensorSceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0) + scene_class = cast(type["InteractiveScene"], scene_cfg.class_type) + scene = scene_class(scene_cfg) + sim.reset() + print("[INFO]: Setup complete...") + run_simulator(sim, scene) if __name__ == "__main__": - # run the main function main() - # close sim app - simulation_app.close() diff --git a/scripts/demos/sensors/imu_sensor.py b/examples/sensors/imu_sensor.py similarity index 51% rename from scripts/demos/sensors/imu_sensor.py rename to examples/sensors/imu_sensor.py index 9a8513cb088b..8c58394f7c83 100644 --- a/scripts/demos/sensors/imu_sensor.py +++ b/examples/sensors/imu_sensor.py @@ -3,46 +3,43 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" +"""Inspect angular velocity and linear acceleration from an IMU sensor.""" import argparse +from typing import TYPE_CHECKING, cast -from isaaclab.app import AppLauncher +import torch + +import isaaclab.sim as sim_utils +from isaaclab.app import add_launcher_args, launch_simulation +from isaaclab.assets import AssetBaseCfg +from isaaclab.physics import PhysicsCfg +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sensors import ImuCfg +from isaaclab.utils import configclass + +from isaaclab_assets.robots.anymal import ANYMAL_C_CFG + +if TYPE_CHECKING: + from isaaclab.scene import InteractiveScene -# add argparse arguments parser = argparse.ArgumentParser(description="Example on using the IMU sensor.") parser.add_argument("--num_envs", type=int, default=1, help="Number of environments to spawn.") +parser.add_argument("--log_interval", type=int, default=100, help="Steps between compact sensor summaries.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") parser.add_argument( "--physics", default="isaacsim_physx", choices=["isaacsim_physx"], help="Physics backend.", ) -# append AppLauncher cli args -AppLauncher.add_app_launcher_args(parser) -# demos should open Kit visualizer by default +add_launcher_args(parser) parser.set_defaults(visualizer=["kit"]) -# parse the arguments args_cli = parser.parse_args() - -# launch omniverse app -app_launcher = AppLauncher(args_cli) -simulation_app = app_launcher.app - -"""Rest everything follows.""" - -import torch - -import isaaclab.sim as sim_utils -from isaaclab.assets import AssetBaseCfg -from isaaclab.scene import InteractiveScene, InteractiveSceneCfg -from isaaclab.sensors import ImuCfg -from isaaclab.utils import configclass - -## -# Pre-defined configs -## -from isaaclab_assets.robots.anymal import ANYMAL_C_CFG # isort: skip +if args_cli.log_interval < 1: + parser.error("--log_interval must be at least 1.") +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") @configclass @@ -60,30 +57,23 @@ class ImuSensorSceneCfg(InteractiveSceneCfg): # robot robot = ANYMAL_C_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") - imu_RF = ImuCfg(prim_path="{ENV_REGEX_NS}/Robot/LF_FOOT") + imu_LF = ImuCfg(prim_path="{ENV_REGEX_NS}/Robot/LF_FOOT") - imu_LF = ImuCfg(prim_path="{ENV_REGEX_NS}/Robot/RF_FOOT") + imu_RF = ImuCfg(prim_path="{ENV_REGEX_NS}/Robot/RF_FOOT") -def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): +def run_simulator(sim: sim_utils.SimulationContext, scene: "InteractiveScene") -> None: """Run the simulator.""" - # Define simulation stepping sim_dt = sim.get_physics_dt() - sim_time = 0.0 count = 0 - # Simulate physics - while simulation_app.is_running(): + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or count < args_cli.max_steps): if count % 500 == 0: - # reset counter - count = 0 - # reset the scene entities root_pose = scene["robot"].data.default_root_pose.torch.clone() root_pose[:, :3] += scene.env_origins scene["robot"].write_root_link_pose_to_sim_index(root_pose=root_pose) root_vel = scene["robot"].data.default_root_vel.torch.clone() scene["robot"].write_root_com_velocity_to_sim_index(root_velocity=root_vel) - # set joint positions with some noise joint_pos, joint_vel = ( scene["robot"].data.default_joint_pos.torch.clone(), scene["robot"].data.default_joint_vel.torch.clone(), @@ -91,53 +81,40 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): joint_pos += torch.rand_like(joint_pos) * 0.1 scene["robot"].write_joint_position_to_sim_index(position=joint_pos) scene["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) - # clear internal buffers scene.reset() print("[INFO]: Resetting robot state...") - # Apply default actions to the robot targets = scene["robot"].data.default_joint_pos.torch scene["robot"].set_joint_position_target_index(target=targets) scene.write_data_to_sim() - # perform step sim.step() - # update sim-time - sim_time += sim_dt count += 1 - # update buffers scene.update(sim_dt) - # print information from the sensors - print("-------------------------------") - print(scene["imu_LF"]) - print("Received angular velocity: ", scene["imu_LF"].data.ang_vel_b) - print("Received linear acceleration: ", scene["imu_LF"].data.lin_acc_b) - print("-------------------------------") - print(scene["imu_RF"]) - print("Received angular velocity: ", scene["imu_RF"].data.ang_vel_b) - print("Received linear acceleration: ", scene["imu_RF"].data.lin_acc_b) - - -def main(): - """Main function.""" - - # Initialize the simulation context - sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device) - sim = sim_utils.SimulationContext(sim_cfg) - # Set main camera - sim.set_camera_view(eye=[3.5, 3.5, 3.5], target=[0.0, 0.0, 0.0]) - # design scene - scene_cfg = ImuSensorSceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0) - scene = InteractiveScene(scene_cfg) - # Play the simulator - sim.reset() - # Now we are ready! - print("[INFO]: Setup complete...") - # Run the simulator - run_simulator(sim, scene) + if count % args_cli.log_interval == 0: + left = scene["imu_LF"].data + right = scene["imu_RF"].data + print( + f"[INFO] step={count} " + f"LF(|w|={left.ang_vel_b.torch.norm(dim=-1).mean().item():.3f} rad/s, " + f"|a|={left.lin_acc_b.torch.norm(dim=-1).mean().item():.3f} m/s^2) " + f"RF(|w|={right.ang_vel_b.torch.norm(dim=-1).mean().item():.3f} rad/s, " + f"|a|={right.lin_acc_b.torch.norm(dim=-1).mean().item():.3f} m/s^2)" + ) + + +def main() -> None: + """Run the IMU example.""" + with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: + sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device, physics=physics_cfg) + sim = sim_utils.SimulationContext(sim_cfg) + sim.set_camera_view(eye=[3.5, 3.5, 3.5], target=[0.0, 0.0, 0.0]) + scene_cfg = ImuSensorSceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0) + scene_class = cast(type["InteractiveScene"], scene_cfg.class_type) + scene = scene_class(scene_cfg) + sim.reset() + print("[INFO]: Setup complete...") + run_simulator(sim, scene) if __name__ == "__main__": - # run the main function main() - # close sim app - simulation_app.close() diff --git a/scripts/demos/sensors/multi_mesh_raycaster.py b/examples/sensors/multi_mesh_raycaster.py similarity index 93% rename from scripts/demos/sensors/multi_mesh_raycaster.py rename to examples/sensors/multi_mesh_raycaster.py index c6fbd83c32f1..a86d37173c3f 100644 --- a/scripts/demos/sensors/multi_mesh_raycaster.py +++ b/examples/sensors/multi_mesh_raycaster.py @@ -9,16 +9,16 @@ .. code-block:: bash # with allegro hand - python scripts/demos/sensors/multi_mesh_raycaster.py --num_envs 16 --asset_type allegro_hand + uvx isaaclab example multi-mesh-ray-caster --num_envs 16 --asset_type allegro_hand # with anymal-D bodies - python scripts/demos/sensors/multi_mesh_raycaster.py --num_envs 16 --asset_type anymal_d + uvx isaaclab example multi-mesh-ray-caster --num_envs 16 --asset_type anymal_d # with random multiple objects - python scripts/demos/sensors/multi_mesh_raycaster.py --num_envs 16 --asset_type objects + uvx isaaclab example multi-mesh-ray-caster --num_envs 16 --asset_type objects # with Newton (MJWarp) physics - python scripts/demos/sensors/multi_mesh_raycaster.py --physics newton_mjwarp + uvx isaaclab example multi-mesh-ray-caster --physics newton_mjwarp """ @@ -29,7 +29,6 @@ from isaaclab.app import add_launcher_args, launch_simulation -# add argparse arguments parser = argparse.ArgumentParser( description="Example on using the multi-mesh raycaster sensor.", conflict_handler="resolve", @@ -48,15 +47,17 @@ choices=["allegro_hand", "anymal_d", "objects"], ) parser.add_argument( - "--physics", default="isaacsim_physx", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." + "--physics", default="newton_mjwarp", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." ) +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") add_launcher_args(parser) -# demos should open Kit visualizer by default -parser.set_defaults(visualizer=["kit"]) +parser.set_defaults(visualizer=["newton_gl"]) args_cli = parser.parse_args() +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") if args_cli.physics == "newton_mjwarp": if not getattr(args_cli, "visualizer_explicit", False): - args_cli.visualizer = ["newton"] + args_cli.visualizer = ["newton_gl"] elif "kit" in (args_cli.visualizer or []): parser.error("the Kit visualizer is not supported with Newton physics; select newton, rerun, viser, or none") @@ -74,9 +75,6 @@ from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR -## -# Pre-defined configs -## from isaaclab_assets.robots.allegro import ALLEGRO_HAND_CFG from isaaclab_assets.robots.anymal import ANYMAL_D_CFG @@ -268,7 +266,8 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): count = 0 # Simulate physics - while sim.is_headless_or_exist_active_visualizer(): + step_count = 0 + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or step_count < args_cli.max_steps): if count % 500 == 0: # reset counter count = 0 @@ -303,6 +302,7 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): scene.write_data_to_sim() # perform step sim.step() + step_count += 1 # update sim-time sim_time += sim_dt count += 1 diff --git a/scripts/demos/sensors/multi_mesh_raycaster_camera.py b/examples/sensors/multi_mesh_raycaster_camera.py similarity index 85% rename from scripts/demos/sensors/multi_mesh_raycaster_camera.py rename to examples/sensors/multi_mesh_raycaster_camera.py index 73b80be2f42f..27c26db45c71 100644 --- a/scripts/demos/sensors/multi_mesh_raycaster_camera.py +++ b/examples/sensors/multi_mesh_raycaster_camera.py @@ -9,21 +9,26 @@ .. code-block:: bash # with allegro hand - python scripts/demos/sensors/multi_mesh_raycaster.py --num_envs 16 --asset_type allegro_hand + uvx --from 'isaaclab[isaacsim]' isaaclab example multi-mesh-ray-caster-camera \\ + --num_envs 16 --asset_type allegro_hand # with anymal-D bodies - python scripts/demos/sensors/multi_mesh_raycaster.py --num_envs 16 --asset_type anymal_d + uvx --from 'isaaclab[isaacsim]' isaaclab example multi-mesh-ray-caster-camera \\ + --num_envs 16 --asset_type anymal_d # with random multiple objects - python scripts/demos/sensors/multi_mesh_raycaster.py --num_envs 16 --asset_type objects + uvx --from 'isaaclab[isaacsim]' isaaclab example multi-mesh-ray-caster-camera \\ + --num_envs 16 --asset_type objects """ import argparse +import random + +import torch from isaaclab.app import AppLauncher -# add argparse arguments parser = argparse.ArgumentParser(description="Example on using the multi-mesh raycaster sensor.") parser.add_argument("--num_envs", type=int, default=16, help="Number of environments to spawn.") parser.add_argument( @@ -33,28 +38,27 @@ help="Asset type to use.", choices=["allegro_hand", "anymal_d", "objects"], ) +parser.add_argument("--log_interval", type=int, default=100, help="Steps between compact sensor summaries.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") parser.add_argument( "--physics", default="isaacsim_physx", choices=["isaacsim_physx"], help="Physics backend.", ) -# append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) -# demos should open Kit visualizer by default parser.set_defaults(visualizer=["kit"]) -# parse the arguments args_cli = parser.parse_args() +if args_cli.log_interval < 1: + parser.error("--log_interval must be at least 1.") +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") -# launch omniverse app app_launcher = AppLauncher(args_cli) simulation_app = app_launcher.app -"""Rest everything follows.""" - -import random -import torch +# Simulator-dependent imports must follow AppLauncher initialization. from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg from pxr import Gf, Sdf @@ -67,9 +71,6 @@ from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR -## -# Pre-defined configs -## from isaaclab_assets.robots.allegro import ALLEGRO_HAND_CFG from isaaclab_assets.robots.anymal import ANYMAL_D_CFG @@ -217,7 +218,7 @@ class RaycasterSensorSceneCfg(InteractiveSceneCfg): ray_caster = ray_caster_cfg -def randomize_shape_color(prim_path_expr: str): +def randomize_shape_color(prim_path_expr: str) -> None: """Randomize the color of the geometry.""" stage = sim_utils.get_current_stage() @@ -227,38 +228,20 @@ def randomize_shape_color(prim_path_expr: str): with Sdf.ChangeBlock(): for prim_path in prim_paths: - print("Applying prim scale to:", prim_path) - # spawn single instance prim_spec = Sdf.CreatePrimInLayer(stage.GetRootLayer(), prim_path) - - # DO YOUR OWN OTHER KIND OF RANDOMIZATION HERE! - # Note: Just need to acquire the right attribute about the property you want to set - # Here is an example on setting color randomly color_spec = prim_spec.GetAttributeAtPath(prim_path + "/geometry/material/Shader.inputs:diffuseColor") color_spec.default = Gf.Vec3f(random.random(), random.random(), random.random()) - - # randomize scale scale_spec = prim_spec.GetAttributeAtPath(prim_path + ".xformOp:scale") scale_spec.default = Gf.Vec3f(random.uniform(0.5, 1.5), random.uniform(0.5, 1.5), random.uniform(0.5, 1.5)) -def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): +def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> None: """Run the simulator.""" - # Define simulation stepping sim_dt = sim.get_physics_dt() - sim_time = 0.0 count = 0 - triggered = True - countdown = 42 - - # Simulate physics - while simulation_app.is_running(): + while simulation_app.is_running() and (args_cli.max_steps < 0 or count < args_cli.max_steps): if count % 500 == 0: - # reset counter - count = 0 - # reset the scene entities - # root state root_pose = scene["asset"].data.default_root_pose.torch.clone() root_pose[:, :3] += scene.env_origins scene["asset"].write_root_pose_to_sim_index(root_pose=root_pose) @@ -266,7 +249,6 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): scene["asset"].write_root_velocity_to_sim_index(root_velocity=root_vel) if isinstance(scene["asset"], Articulation): - # set joint positions with some noise joint_pos, joint_vel = ( scene["asset"].data.default_joint_pos.torch.clone(), scene["asset"].data.default_joint_vel.torch.clone(), @@ -274,39 +256,26 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): joint_pos += torch.rand_like(joint_pos) * 0.1 scene["asset"].write_joint_position_to_sim_index(position=joint_pos) scene["asset"].write_joint_velocity_to_sim_index(velocity=joint_vel) - # clear internal buffers scene.reset() print("[INFO]: Resetting Asset state...") if isinstance(scene["asset"], Articulation): - # -- generate actions/commands default_joint_pos = scene["asset"].data.default_joint_pos.torch targets = default_joint_pos + 5 * (torch.rand_like(default_joint_pos) - 0.5) - # -- apply action to the asset scene["asset"].set_joint_position_target_index(target=targets) - # -- write data to sim scene.write_data_to_sim() - # perform step sim.step() - # update sim-time - sim_time += sim_dt count += 1 - # update buffers scene.update(sim_dt) - if not triggered: - if countdown > 0: - countdown -= 1 - continue - - data = scene["ray_caster"].data.ray_hits_w.torch.cpu().numpy() # noqa: F841 - triggered = True - else: - continue + if count % args_cli.log_interval == 0: + hits = scene["ray_caster"].data.ray_hits_w.torch + valid = torch.isfinite(hits).all(dim=-1) + print(f"[INFO] step={count} ray hit rate={valid.float().mean().item():.1%}") -def main(): - """Main function.""" +def main() -> None: + """Run the multi-mesh ray-caster camera example.""" # Initialize the simulation context sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device) @@ -329,7 +298,7 @@ def main(): if __name__ == "__main__": - # run the main function - main() - # close sim app - simulation_app.close() + try: + main() + finally: + simulation_app.close() diff --git a/examples/sensors/newton_raycast.py b/examples/sensors/newton_raycast.py new file mode 100644 index 000000000000..35b358da8ebf --- /dev/null +++ b/examples/sensors/newton_raycast.py @@ -0,0 +1,280 @@ +# Copyright (c) 2022-2026, 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 BVH ray casting against static or moving geometry. + +The ``heightfield`` scene scans a wave terrain. The ``moving-geometry`` scene +tracks falling boxes and a kinematic bar while Newton refits its BVH. + +.. code-block:: bash + + uvx isaaclab example newton-raycast --scene moving-geometry +""" + +from __future__ import annotations + +import argparse +import math +from typing import Any + +from isaaclab.app import add_launcher_args, launch_simulation + +parser = argparse.ArgumentParser(description="Newton BVH ray-cast sensor example.") +parser.add_argument( + "--scene", + choices=("heightfield", "moving-geometry"), + default="heightfield", + help="Geometry scanned by the sensor.", +) +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_gl"]) +args_cli = parser.parse_args() + +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") + +import torch +import warp as wp +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.sensors import NewtonRaycastSensor, NewtonRaycastSensorCfg + +import isaaclab.sim as sim_utils +import isaaclab.terrains as terrain_gen +import isaaclab.utils.math as math_utils +from isaaclab.assets import RigidObject, RigidObjectCfg +from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.sensors.ray_caster.patterns import GridPatternCfg +from isaaclab.terrains import TerrainGeneratorCfg, TerrainImporterCfg +from isaaclab.utils import configclass + +WAVE_TERRAIN_CFG = TerrainGeneratorCfg( + size=(12.0, 12.0), + border_width=1.0, + num_rows=1, + num_cols=1, + use_cache=False, + sub_terrains={ + "waves": terrain_gen.HfWaveTerrainCfg(amplitude_range=(0.25, 0.25), num_waves=6), + }, +) + +BOX_DROP_POSITIONS = ((1.0, 0.6, 3.0), (-0.8, -1.0, 3.5), (0.2, -1.2, 4.0)) + + +@configclass +class HeightfieldSceneCfg(InteractiveSceneCfg): + """Wave heightfield with a floating sensor body.""" + + terrain = TerrainImporterCfg( + prim_path="/World/ground", terrain_type="generator", terrain_generator=WAVE_TERRAIN_CFG + ) + body = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/SensorBody", + spawn=sim_utils.CuboidCfg( + size=(0.4, 0.25, 0.1), + rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), + mass_props=sim_utils.MassCfg(mass=1.0), + collision_props=sim_utils.UsdPhysicsCollisionCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.9, 0.6, 0.1)), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.5)), + ) + raycast = NewtonRaycastSensorCfg( + prim_path="{ENV_REGEX_NS}/SensorBody", + pattern_cfg=GridPatternCfg(resolution=0.25, size=(1.5, 1.0)), + ray_alignment="base", + global_world_only=True, + max_distance=10.0, + debug_vis=True, + ) + + +def _falling_box_cfg(index: int) -> RigidObjectCfg: + """Create one falling box.""" + return RigidObjectCfg( + prim_path=f"{{ENV_REGEX_NS}}/Box_{index}", + spawn=sim_utils.CuboidCfg( + size=(0.5, 0.5, 0.5), + rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), + mass_props=sim_utils.MassCfg(mass=1.0), + collision_props=sim_utils.UsdPhysicsCollisionCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.4 + 0.2 * index, 0.9 - 0.3 * index)), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=BOX_DROP_POSITIONS[index]), + ) + + +@configclass +class MovingGeometrySceneCfg(InteractiveSceneCfg): + """Ground plane, falling boxes, a sweeping bar, and a hovering sensor.""" + + terrain = TerrainImporterCfg(prim_path="/World/ground", terrain_type="plane") + box_0 = _falling_box_cfg(0) + box_1 = _falling_box_cfg(1) + box_2 = _falling_box_cfg(2) + bar = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Bar", + spawn=sim_utils.CuboidCfg( + size=(3.5, 0.3, 0.3), + rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), + mass_props=sim_utils.MassCfg(mass=1.0), + collision_props=sim_utils.UsdPhysicsCollisionCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.9, 0.2, 0.5)), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 0.8)), + ) + body = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/SensorBody", + spawn=sim_utils.CuboidCfg( + size=(0.3, 0.3, 0.1), + rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), + mass_props=sim_utils.MassCfg(mass=1.0), + collision_props=sim_utils.UsdPhysicsCollisionCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.9, 0.6, 0.1)), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 2.5)), + ) + raycast = NewtonRaycastSensorCfg( + prim_path="{ENV_REGEX_NS}/SensorBody", + offset=NewtonRaycastSensorCfg.OffsetCfg(pos=(0.0, 0.0, -0.1)), + pattern_cfg=GridPatternCfg(resolution=0.25, size=(3.0, 3.0)), + ray_alignment="yaw", + max_distance=10.0, + debug_vis=True, + ) + + +def _newton_gl_viewer(sim: sim_utils.SimulationContext) -> Any | None: + """Return the active Newton GL viewer, if any.""" + from isaaclab_visualizers.newton import NewtonGLVisualizer + + return next( + ( + visualizer._viewer + for visualizer in getattr(sim, "_visualizers", []) + if isinstance(visualizer, NewtonGLVisualizer) + ), + None, + ) + + +def _draw_ray_lines(viewer: Any, sensor: NewtonRaycastSensor, miss_length: float = 3.0) -> None: + """Draw red hit rays and gray misses.""" + starts = sensor.ray_starts_w.torch.reshape(-1, 3) + directions = sensor.ray_directions_w.torch.reshape(-1, 3) + hits = sensor.data.ray_hits_w.torch.reshape(-1, 3) + misses = torch.isinf(sensor.data.ray_distances.torch.reshape(-1, 1)) + ends = torch.where(misses, starts + directions * miss_length, hits) + colors = torch.where( + misses, + torch.tensor([0.5, 0.5, 0.5], device=starts.device), + torch.tensor([1.0, 0.15, 0.1], device=starts.device), + ) + + viewer.log_lines( + "/isaaclab/raycast/rays", + wp.from_torch(starts.contiguous(), dtype=wp.vec3f), + wp.from_torch(ends.contiguous(), dtype=wp.vec3f), + wp.from_torch(colors.contiguous(), dtype=wp.vec3f), + ) + + +def _animate_heightfield(body: RigidObject, time: float, zero_velocity: torch.Tensor) -> None: + """Move the sensor body over the wave terrain.""" + angle = 0.4 * time + position = torch.tensor( + [[3.0 * math.cos(angle), 3.0 * math.sin(angle), 1.4 + 0.3 * math.sin(0.9 * time)]], + device=body.device, + ) + angles = torch.tensor( + [0.3 * math.sin(0.7 * time), 0.25 * math.sin(1.1 * time), angle + math.pi / 2.0], + device=body.device, + ) + orientation = math_utils.quat_from_euler_xyz(*(value.unsqueeze(0) for value in angles)) + body.write_root_pose_to_sim_index(root_pose=torch.cat([position, orientation], dim=-1)) + body.write_root_velocity_to_sim_index(root_velocity=zero_velocity) + + +def _animate_moving_geometry( + boxes: list[RigidObject], + bar: RigidObject, + body: RigidObject, + step: int, + sim_dt: float, + zero_velocity: torch.Tensor, + zero_angle: torch.Tensor, +) -> None: + """Drop boxes while sweeping the bar and sensor.""" + if step % 400 == 0: + for box, drop_position in zip(boxes, BOX_DROP_POSITIONS): + pose = torch.tensor([[*drop_position, 0.3, 0.3, 0.0, 0.9]], device=body.device) + pose[:, 3:] /= torch.linalg.norm(pose[:, 3:]) + box.write_root_pose_to_sim_index(root_pose=pose) + box.write_root_velocity_to_sim_index(root_velocity=zero_velocity) + box.reset() + + time = step * sim_dt + bar_orientation = math_utils.quat_from_euler_xyz(zero_angle, zero_angle, zero_angle + 0.8 * time) + bar_position = torch.tensor([[0.0, 0.0, 0.8]], device=body.device) + bar.write_root_pose_to_sim_index(root_pose=torch.cat([bar_position, bar_orientation], dim=-1)) + bar.write_root_velocity_to_sim_index(root_velocity=zero_velocity) + + body_orientation = math_utils.quat_from_euler_xyz(zero_angle, zero_angle, zero_angle - 0.3 * time) + body_position = torch.tensor( + [[0.6 * math.cos(0.5 * time), 0.6 * math.sin(0.5 * time), 2.5]], + device=body.device, + ) + body.write_root_pose_to_sim_index(root_pose=torch.cat([body_position, body_orientation], dim=-1)) + body.write_root_velocity_to_sim_index(root_velocity=zero_velocity) + + +def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene, scene_name: str, max_steps: int) -> None: + """Animate the selected scene until it closes or reaches the step limit.""" + body: RigidObject = scene["body"] + sensor: NewtonRaycastSensor = scene["raycast"] + viewer = _newton_gl_viewer(sim) + sim_dt = sim.get_physics_dt() + zero_velocity = torch.zeros(1, 6, device=sim.device) + zero_angle = torch.zeros(1, device=sim.device) + boxes: list[RigidObject] = [] + bar: RigidObject | None = None + if scene_name == "moving-geometry": + boxes = [scene[f"box_{index}"] for index in range(len(BOX_DROP_POSITIONS))] + bar = scene["bar"] + + step = 0 + while sim.is_headless_or_exist_active_visualizer() and (max_steps < 0 or step < max_steps): + if bar is None: + _animate_heightfield(body, step * sim_dt, zero_velocity) + else: + _animate_moving_geometry(boxes, bar, body, step, sim_dt, zero_velocity, zero_angle) + scene.write_data_to_sim() + sim.step() + scene.update(sim_dt) + if viewer is not None: + _draw_ray_lines(viewer, sensor) + step += 1 + + +def main() -> None: + """Launch the selected Newton ray-cast scene.""" + with launch_simulation(cfg=NewtonCfg(solver_cfg=MJWarpSolverCfg()), launcher_args=args_cli) as physics_cfg: + sim_cfg = sim_utils.SimulationCfg(dt=1 / 100, device=args_cli.device, physics=physics_cfg) + sim = sim_utils.SimulationContext(sim_cfg) + if args_cli.scene == "heightfield": + scene_cfg = HeightfieldSceneCfg(num_envs=1, env_spacing=1.0) + sim.set_camera_view(eye=[7.0, 7.0, 5.0], target=[0.0, 0.0, 0.0]) + else: + scene_cfg = MovingGeometrySceneCfg(num_envs=1, env_spacing=1.0) + sim.set_camera_view(eye=[6.0, 6.0, 4.5], target=[0.0, 0.0, 1.0]) + scene = InteractiveScene(scene_cfg) + sim.reset() + print("[INFO]: Setup complete...") + run_simulator(sim, scene, args_cli.scene, args_cli.max_steps) + + +if __name__ == "__main__": + main() diff --git a/scripts/demos/sensors/ppisp_camera.py b/examples/sensors/ppisp_camera.py similarity index 96% rename from scripts/demos/sensors/ppisp_camera.py rename to examples/sensors/ppisp_camera.py index 83c31bd03ace..3bbfa0ec112d 100644 --- a/scripts/demos/sensors/ppisp_camera.py +++ b/examples/sensors/ppisp_camera.py @@ -10,17 +10,15 @@ .. code-block:: bash # Run a finite smoke with the default Newton Warp renderer and save comparison images. - uv run python scripts/demos/sensors/ppisp_camera.py \ + uvx --from 'isaaclab[isaacsim]' isaaclab example ppisp-camera \ --input_scene /path/to/scene.usd --renderer newton_renderer --visualizer none --max_steps 60 # Run the same saved-image workflow with Isaac RTX. - uv run python scripts/demos/sensors/ppisp_camera.py \ + uvx --from 'isaaclab[isaacsim]' isaaclab example ppisp-camera \ --input_scene /path/to/scene.usd --renderer isaac_rtx --visualizer none --max_steps 60 """ -"""Launch Isaac Sim Simulator first.""" - import argparse import os from typing import Any @@ -28,7 +26,6 @@ from isaaclab.app import AppLauncher from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR -# add argparse arguments DEFAULT_INPUT_SCENE = f"{ISAAC_NUCLEUS_DIR}/Samples/Scene_ParticleField/valiant_auto.usdz" parser = argparse.ArgumentParser(description="Example of a USD-authored PPISP effect on camera RGB output.") @@ -90,13 +87,11 @@ "--output_dir", type=str, default=None, - help="Directory to write comparison images. Defaults to scripts/demos/sensors/output/ppisp_camera.", + help="Directory to write comparison images. Defaults to ./output/ppisp_camera.", ) -# append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) -# enable cameras by default; the QA workflow validates saved camera outputs and does not require a visualizer. +# Camera output is required, but the example does not need an interactive visualizer. parser.set_defaults(enable_cameras=True) -# parse the arguments args_cli = parser.parse_args() if "://" not in args_cli.input_scene: args_cli.input_scene = os.path.abspath(os.path.expanduser(args_cli.input_scene)) @@ -117,11 +112,9 @@ if args_cli.save_interval < 1: parser.error("--save_interval must be at least 1.") -# launch omniverse app app_launcher = AppLauncher(args_cli) simulation_app = app_launcher.app -"""Rest everything follows.""" import matplotlib.pyplot as plt import numpy as np @@ -139,6 +132,7 @@ from isaaclab.assets import AssetBaseCfg, RigidObjectCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import Camera, CameraCfg +from isaaclab.sim.spawners.materials import UsdPhysicsRigidBodyMaterialCfg from isaaclab.utils import configclass @@ -160,7 +154,7 @@ class PpispCameraSceneCfg(InteractiveSceneCfg): rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), mass_props=sim_utils.MassCfg(mass=0.001), collision_props=sim_utils.UsdPhysicsCollisionCfg(), - physics_material=sim_utils.RigidBodyMaterialCfg(), + physics_material=UsdPhysicsRigidBodyMaterialCfg(), visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 0.0, 0.0)), ), init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, -100.0)), @@ -322,7 +316,7 @@ def get_render_product_resolution(render_product_prim: Usd.Prim | None) -> tuple def resolve_image_shape(render_product_prim: Usd.Prim | None) -> tuple[int, int]: - """Resolve demo output ``(width, height)`` preserving source aspect when height is omitted.""" + """Resolve example output ``(width, height)`` preserving source aspect when height is omitted.""" width = args_cli.image_width height = args_cli.image_height if height is not None: @@ -445,7 +439,7 @@ def run_simulator(sim: sim_utils.SimulationContext, baseline_camera: Camera, ppi sim_dt = sim.get_physics_dt() output_dir = args_cli.output_dir if output_dir is None: - output_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "output", "ppisp_camera") + output_dir = os.path.join(os.getcwd(), "output", "ppisp_camera") os.makedirs(output_dir, exist_ok=True) if args_cli.warmup_steps > 0: diff --git a/scripts/demos/sensors/pva_sensor.py b/examples/sensors/pva_sensor.py similarity index 54% rename from scripts/demos/sensors/pva_sensor.py rename to examples/sensors/pva_sensor.py index dc415b1fd862..412e05e8b652 100644 --- a/scripts/demos/sensors/pva_sensor.py +++ b/examples/sensors/pva_sensor.py @@ -3,46 +3,43 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" +"""Inspect pose, velocity, and acceleration measurements from a PVA sensor.""" import argparse +from typing import TYPE_CHECKING, cast -from isaaclab.app import AppLauncher +import torch + +import isaaclab.sim as sim_utils +from isaaclab.app import add_launcher_args, launch_simulation +from isaaclab.assets import AssetBaseCfg +from isaaclab.physics import PhysicsCfg +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sensors import PvaCfg +from isaaclab.utils import configclass + +from isaaclab_assets.robots.anymal import ANYMAL_C_CFG + +if TYPE_CHECKING: + from isaaclab.scene import InteractiveScene -# add argparse arguments parser = argparse.ArgumentParser(description="Example on using the PVA sensor.") parser.add_argument("--num_envs", type=int, default=1, help="Number of environments to spawn.") +parser.add_argument("--log_interval", type=int, default=100, help="Steps between compact sensor summaries.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") parser.add_argument( "--physics", default="isaacsim_physx", choices=["isaacsim_physx"], help="Physics backend.", ) -# append AppLauncher cli args -AppLauncher.add_app_launcher_args(parser) -# demos should open Kit visualizer by default +add_launcher_args(parser) parser.set_defaults(visualizer=["kit"]) -# parse the arguments args_cli = parser.parse_args() - -# launch omniverse app -app_launcher = AppLauncher(args_cli) -simulation_app = app_launcher.app - -"""Rest everything follows.""" - -import torch - -import isaaclab.sim as sim_utils -from isaaclab.assets import AssetBaseCfg -from isaaclab.scene import InteractiveScene, InteractiveSceneCfg -from isaaclab.sensors import PvaCfg -from isaaclab.utils import configclass - -## -# Pre-defined configs -## -from isaaclab_assets.robots.anymal import ANYMAL_C_CFG # isort: skip +if args_cli.log_interval < 1: + parser.error("--log_interval must be at least 1.") +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") @configclass @@ -65,18 +62,14 @@ class PvaSensorSceneCfg(InteractiveSceneCfg): pva_RF = PvaCfg(prim_path="{ENV_REGEX_NS}/Robot/RF_FOOT", debug_vis=True) -def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): +def run_simulator(sim: sim_utils.SimulationContext, scene: "InteractiveScene") -> None: """Run the simulator.""" # Define simulation stepping sim_dt = sim.get_physics_dt() - sim_time = 0.0 count = 0 - # Simulate physics - while simulation_app.is_running(): + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or count < args_cli.max_steps): if count % 500 == 0: - # reset counter - count = 0 # reset the scene entities # root state # we offset the root state by the origin since the states are written in simulation world frame @@ -97,57 +90,38 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): # clear internal buffers scene.reset() print("[INFO]: Resetting robot state...") - # Apply default actions to the robot - # -- generate actions/commands targets = scene["robot"].data.default_joint_pos.torch - # -- apply action to the robot scene["robot"].set_joint_position_target_index(target=targets) - # -- write data to sim scene.write_data_to_sim() - # perform step sim.step() - # update sim-time - sim_time += sim_dt count += 1 - # update buffers scene.update(sim_dt) - # print information from the sensors - print("-------------------------------") - print(scene["pva_LF"]) - print("Received linear velocity: ", scene["pva_LF"].data.lin_vel_b) - print("Received angular velocity: ", scene["pva_LF"].data.ang_vel_b) - print("Received linear acceleration: ", scene["pva_LF"].data.lin_acc_b) - print("Received angular acceleration: ", scene["pva_LF"].data.ang_acc_b) - print("-------------------------------") - print(scene["pva_RF"]) - print("Received linear velocity: ", scene["pva_RF"].data.lin_vel_b) - print("Received angular velocity: ", scene["pva_RF"].data.ang_vel_b) - print("Received linear acceleration: ", scene["pva_RF"].data.lin_acc_b) - print("Received angular acceleration: ", scene["pva_RF"].data.ang_acc_b) - - -def main(): - """Main function.""" - - # Initialize the simulation context - sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device) - sim = sim_utils.SimulationContext(sim_cfg) - # Set main camera - sim.set_camera_view(eye=[3.5, 3.5, 3.5], target=[0.0, 0.0, 0.0]) - # design scene - scene_cfg = PvaSensorSceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0) - scene = InteractiveScene(scene_cfg) - # Play the simulator - sim.reset() - # Now we are ready! - print("[INFO]: Setup complete...") - # Run the simulator - run_simulator(sim, scene) + if count % args_cli.log_interval == 0: + left = scene["pva_LF"].data + right = scene["pva_RF"].data + print( + f"[INFO] step={count} " + f"LF(|v|={left.lin_vel_b.torch.norm(dim=-1).mean().item():.3f} m/s, " + f"|a|={left.lin_acc_b.torch.norm(dim=-1).mean().item():.3f} m/s^2) " + f"RF(|v|={right.lin_vel_b.torch.norm(dim=-1).mean().item():.3f} m/s, " + f"|a|={right.lin_acc_b.torch.norm(dim=-1).mean().item():.3f} m/s^2)" + ) + + +def main() -> None: + """Run the PVA sensor example.""" + with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: + sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device, physics=physics_cfg) + sim = sim_utils.SimulationContext(sim_cfg) + sim.set_camera_view(eye=[3.5, 3.5, 3.5], target=[0.0, 0.0, 0.0]) + scene_cfg = PvaSensorSceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0) + scene_class = cast(type["InteractiveScene"], scene_cfg.class_type) + scene = scene_class(scene_cfg) + sim.reset() + print("[INFO]: Setup complete...") + run_simulator(sim, scene) if __name__ == "__main__": - # run the main function main() - # close sim app - simulation_app.close() diff --git a/scripts/demos/sensors/raycaster_sensor.py b/examples/sensors/raycaster_sensor.py similarity index 61% rename from scripts/demos/sensors/raycaster_sensor.py rename to examples/sensors/raycaster_sensor.py index d87a8676db7a..4278d2131cdd 100644 --- a/scripts/demos/sensors/raycaster_sensor.py +++ b/examples/sensors/raycaster_sensor.py @@ -3,46 +3,44 @@ # # SPDX-License-Identifier: BSD-3-Clause +"""Cast a lidar-style ray pattern against rough terrain.""" + import argparse +from typing import TYPE_CHECKING, cast + +import torch + +import isaaclab.sim as sim_utils +from isaaclab.app import add_launcher_args, launch_simulation +from isaaclab.assets import AssetBaseCfg +from isaaclab.physics import PhysicsCfg +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sensors.ray_caster import RayCasterCfg, patterns +from isaaclab.utils import configclass +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR -from isaaclab.app import AppLauncher +from isaaclab_assets.robots.anymal import ANYMAL_C_CFG + +if TYPE_CHECKING: + from isaaclab.scene import InteractiveScene -# add argparse arguments parser = argparse.ArgumentParser(description="Example on using the raycaster sensor.") parser.add_argument("--num_envs", type=int, default=1, help="Number of environments to spawn.") +parser.add_argument("--log_interval", type=int, default=100, help="Steps between compact sensor summaries.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") parser.add_argument( "--physics", default="isaacsim_physx", choices=["isaacsim_physx"], help="Physics backend.", ) -# append AppLauncher cli args -AppLauncher.add_app_launcher_args(parser) -# demos should open Kit visualizer by default +add_launcher_args(parser) parser.set_defaults(visualizer=["kit"]) -# parse the arguments args_cli = parser.parse_args() - -# launch omniverse app -app_launcher = AppLauncher(args_cli) -simulation_app = app_launcher.app - -"""Rest everything follows.""" - -import numpy as np -import torch - -import isaaclab.sim as sim_utils -from isaaclab.assets import AssetBaseCfg -from isaaclab.scene import InteractiveScene, InteractiveSceneCfg -from isaaclab.sensors.ray_caster import RayCasterCfg, patterns -from isaaclab.utils import configclass -from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR - -## -# Pre-defined configs -## -from isaaclab_assets.robots.anymal import ANYMAL_C_CFG # isort: skip +if args_cli.log_interval < 1: + parser.error("--log_interval must be at least 1.") +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") @configclass @@ -75,25 +73,18 @@ class RaycasterSensorSceneCfg(InteractiveSceneCfg): pattern_cfg=patterns.LidarPatternCfg( channels=100, vertical_fov_range=[-90, 90], horizontal_fov_range=[-90, 90], horizontal_res=1.0 ), - debug_vis=not args_cli.headless, + debug_vis=bool(args_cli.visualizer), ) -def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): +def run_simulator(sim: sim_utils.SimulationContext, scene: "InteractiveScene") -> None: """Run the simulator.""" # Define simulation stepping sim_dt = sim.get_physics_dt() - sim_time = 0.0 count = 0 - triggered = True - countdown = 42 - - # Simulate physics - while simulation_app.is_running(): + while sim.is_headless_or_exist_active_visualizer() and (args_cli.max_steps < 0 or count < args_cli.max_steps): if count % 500 == 0: - # reset counter - count = 0 # reset the scene entities # root state # we offset the root state by the origin since the states are written in simulation world frame @@ -114,58 +105,32 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): # clear internal buffers scene.reset() print("[INFO]: Resetting robot state...") - # Apply default actions to the robot - # -- generate actions/commands targets = scene["robot"].data.default_joint_pos.torch - # -- apply action to the robot scene["robot"].set_joint_position_target_index(target=targets) - # -- write data to sim scene.write_data_to_sim() - # perform step sim.step() - # update sim-time - sim_time += sim_dt count += 1 - # update buffers scene.update(sim_dt) - # print information from the sensors - print("-------------------------------") - print(scene["ray_caster"]) - print("Ray cast hit results: ", scene["ray_caster"].data.ray_hits_w.torch) - - if not triggered: - if countdown > 0: - countdown -= 1 - continue - data = scene["ray_caster"].data.ray_hits_w.torch.cpu().numpy() - np.save("cast_data.npy", data) - triggered = True - else: - continue - - -def main(): - """Main function.""" - - # Initialize the simulation context - sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device) - sim = sim_utils.SimulationContext(sim_cfg) - # Set main camera - sim.set_camera_view(eye=[3.5, 3.5, 3.5], target=[0.0, 0.0, 0.0]) - # design scene - scene_cfg = RaycasterSensorSceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0) - scene = InteractiveScene(scene_cfg) - # Play the simulator - sim.reset() - # Now we are ready! - print("[INFO]: Setup complete...") - # Run the simulator - run_simulator(sim, scene) + if count % args_cli.log_interval == 0: + hits = scene["ray_caster"].data.ray_hits_w.torch + valid = torch.isfinite(hits).all(dim=-1) + print(f"[INFO] step={count} ray hit rate={valid.float().mean().item():.1%}") + + +def main() -> None: + """Run the ray-caster example.""" + with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: + sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device, physics=physics_cfg) + sim = sim_utils.SimulationContext(sim_cfg) + sim.set_camera_view(eye=[3.5, 3.5, 3.5], target=[0.0, 0.0, 0.0]) + scene_cfg = RaycasterSensorSceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0) + scene_class = cast(type["InteractiveScene"], scene_cfg.class_type) + scene = scene_class(scene_cfg) + sim.reset() + print("[INFO]: Setup complete...") + run_simulator(sim, scene) if __name__ == "__main__": - # run the main function main() - # close sim app - simulation_app.close() diff --git a/scripts/demos/sensors/tacsl_sensor.py b/examples/sensors/tacsl_sensor.py similarity index 88% rename from scripts/demos/sensors/tacsl_sensor.py rename to examples/sensors/tacsl_sensor.py index 4699ef54db53..10dd1c1d6ee5 100644 --- a/scripts/demos/sensors/tacsl_sensor.py +++ b/examples/sensors/tacsl_sensor.py @@ -12,14 +12,14 @@ .. code-block:: bash # Usage - python scripts/demos/sensors/tacsl_sensor.py \ + uvx --from 'isaaclab[isaacsim]' isaaclab example tactile-sensor \ --use_tactile_rgb \ --use_tactile_ff \ --tactile_compliance_stiffness 100.0 \ --num_envs 16 \ --contact_object_type nut \ --save_viz \ - --viz kit/newton + --viz kit """ @@ -27,10 +27,6 @@ import math import os -import cv2 -import numpy as np -import torch - from isaaclab.app import AppLauncher # Add argparse arguments @@ -59,6 +55,7 @@ ) parser.add_argument("--save_viz", action="store_true", help="Visualize tactile data.") parser.add_argument("--save_viz_dir", type=str, default="tactile_record", help="Directory to save tactile data.") +parser.add_argument("--max_steps", type=int, default=-1, help="Stop after this many steps; negative runs forever.") parser.add_argument("--use_tactile_rgb", action="store_true", help="Use tactile RGB sensor data collection.") parser.add_argument("--use_tactile_ff", action="store_true", help="Use tactile force field sensor data collection.") parser.add_argument("--debug_sdf_closest_pts", action="store_true", help="Visualize closest SDF points.") @@ -76,14 +73,23 @@ AppLauncher.add_app_launcher_args(parser) # Parse the arguments args_cli = parser.parse_args() +if args_cli.num_envs < 1: + parser.error("--num_envs must be at least 1.") +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") +if not args_cli.use_tactile_rgb and not args_cli.use_tactile_ff: + args_cli.use_tactile_ff = True # Launch omniverse app app_launcher = AppLauncher(args_cli) simulation_app = app_launcher.app -"""Rest everything follows.""" +import cv2 +import numpy as np +import torch from isaaclab_newton.sim.schemas import NewtonArticulationCfg +from isaaclab_physx.physics import PhysxCfg from isaaclab_physx.sim.schemas import PhysxArticulationCfg, PhysxCollisionCfg, PhysxRigidBodyCfg from isaaclab_physx.sim.spawners.materials import PhysxRigidBodyMaterialCfg @@ -118,22 +124,21 @@ class TactileSensorsSceneCfg(InteractiveSceneCfg): prim_path="{ENV_REGEX_NS}/Robot", spawn=sim_utils.UsdFileWithCompliantContactCfg( usd_path=f"{ISAACLAB_NUCLEUS_DIR}/TacSL/gelsight_r15_finger/gelsight_r15_finger.usd", - rigid_props=PhysxRigidBodyCfg( - disable_gravity=True, - max_depenetration_velocity=5.0, - ), + rigid_props={"(/.*)?": [PhysxRigidBodyCfg(disable_gravity=True, max_depenetration_velocity=5.0)]}, compliant_contact_stiffness=args_cli.tactile_compliance_stiffness, compliant_contact_damping=args_cli.tactile_compliant_damping, physics_material_prim_path="elastomer", - articulation_props=[ - PhysxArticulationCfg( - enabled_self_collisions=False, - solver_position_iteration_count=12, - solver_velocity_iteration_count=1, - ), - NewtonArticulationCfg(self_collision_enabled=False), - ], - collision_props=PhysxCollisionCfg(contact_offset=0.001, rest_offset=-0.0005), + articulation_props={ + "(/.*)?": [ + PhysxArticulationCfg( + enabled_self_collisions=False, + solver_position_iteration_count=12, + solver_velocity_iteration_count=1, + ), + NewtonArticulationCfg(self_collision_enabled=False), + ] + }, + collision_props={"(/.*)?": [PhysxCollisionCfg(contact_offset=0.001, rest_offset=-0.0005)]}, ), init_state=ArticulationCfg.InitialStateCfg( pos=(0.0, 0.0, 0.5), @@ -207,15 +212,19 @@ class NutTactileSceneCfg(TactileSensorsSceneCfg): prim_path="{ENV_REGEX_NS}/contact_object", spawn=sim_utils.UsdFileCfg( usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Factory/factory_nut_m16.usd", - rigid_props=PhysxRigidBodyCfg( - disable_gravity=True, - solver_position_iteration_count=12, - solver_velocity_iteration_count=1, - max_angular_velocity=180.0, - ), - mass_props=sim_utils.MassCfg(mass=0.1), - collision_props=PhysxCollisionCfg(contact_offset=0.005, rest_offset=0), - articulation_props=PhysxArticulationCfg(articulation_enabled=False), + rigid_props={ + "(/.*)?": [ + PhysxRigidBodyCfg( + disable_gravity=True, + solver_position_iteration_count=12, + solver_velocity_iteration_count=1, + max_angular_velocity=180.0, + ) + ] + }, + mass_props={"(/.*)?": [sim_utils.MassCfg(mass=0.1)]}, + collision_props={"(/.*)?": [PhysxCollisionCfg(contact_offset=0.005, rest_offset=0)]}, + articulation_props={"(/.*)?": [PhysxArticulationCfg(articulation_enabled=False)]}, ), init_state=RigidObjectCfg.InitialStateCfg( pos=(0.0, 0.0 + 0.06776, 0.498), @@ -305,11 +314,9 @@ def save_viz_helper( cv2.imwrite(os.path.join(tactile_rgb_image_dir, f"{count:04d}.png"), tactile_rgb_tiled) -def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): +def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> None: """Run the simulator.""" - # Define simulation stepping sim_dt = sim.get_physics_dt() - sim_time = 0.0 count = 0 # Assign different masses to contact objects in different environments @@ -332,7 +339,8 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): if "contact_object" in scene.keys(): entity_list.append("contact_object") - while simulation_app.is_running(): + total_steps = 0 + while simulation_app.is_running() and (args_cli.max_steps < 0 or total_steps < args_cli.max_steps): if count == 122: # Reset robot and contact object positions count = 0 @@ -361,8 +369,8 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): # Step simulation scene.write_data_to_sim() sim.step() - sim_time += sim_dt count += 1 + total_steps += 1 scene.update(sim_dt) # Access tactile sensor data @@ -372,10 +380,8 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): save_viz_helper(dir_path_list, count, tactile_data, num_envs, nrows, ncols) -def main(): - """Main function.""" - from isaaclab_physx.physics import PhysxCfg - +def main() -> None: + """Run the tactile-sensor example.""" # Initialize simulation # Note: We set the gpu_collision_stack_size to prevent buffer overflow in contact-rich environments. sim_cfg = sim_utils.SimulationCfg( @@ -430,7 +436,7 @@ def main(): if __name__ == "__main__": - # Run the main function - main() - # Close sim app - simulation_app.close() + try: + main() + finally: + simulation_app.close() diff --git a/scripts/demos/visual_color_randomization.py b/examples/visual_color_randomization.py similarity index 92% rename from scripts/demos/visual_color_randomization.py rename to examples/visual_color_randomization.py index 79b1f12f192e..cf66c282c795 100644 --- a/scripts/demos/visual_color_randomization.py +++ b/examples/visual_color_randomization.py @@ -10,16 +10,17 @@ feet, so the three part groups and every environment randomize independently on partial resets. The surface, glass, and solid styles exercise their numeric shader channels. Newton currently -mirrors material color only, so its demo run randomizes tint and per-shape colors while leaving the +mirrors material color only, so the example randomizes tint and per-shape colors while leaving the other optical channels to RTX renderers. .. code-block:: bash # PhysX physics and Kit visualizer. - uv run --extra isaacsim python scripts/demos/visual_color_randomization.py + uvx --from 'isaaclab[isaacsim]' isaaclab example visual-color-randomization \ + --physics isaacsim_physx --visualizer kit # Newton physics and Newton GL visualizer. - uv run python scripts/demos/visual_color_randomization.py \ + uvx isaaclab example visual-color-randomization \ --physics newton_mjwarp --visualizer newton_gl """ @@ -33,11 +34,14 @@ parser = argparse.ArgumentParser(description=__doc__, conflict_handler="resolve") parser.add_argument("--num_envs", type=int, default=512, help="Number of environments to spawn.") parser.add_argument( - "--physics", default="isaacsim_physx", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." + "--physics", default="newton_mjwarp", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." ) +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=["kit"]) +parser.set_defaults(visualizer=["newton_gl"]) args_cli = parser.parse_args() +if args_cli.max_steps == 0 or args_cli.max_steps < -1: + parser.error("--max_steps must be positive or -1.") import torch @@ -237,7 +241,7 @@ class EventCfg: @configclass class VisualMaterialEnvCfg(ManagerBasedEnvCfg): - """Manager-based environment for the visual-material demo.""" + """Manager-based environment for the visual-material example.""" scene: VisualMaterialSceneCfg = VisualMaterialSceneCfg(num_envs=512, env_spacing=1.5) actions: ActionsCfg = ActionsCfg() @@ -284,7 +288,9 @@ def main() -> None: count = 0 env.reset() print("[INFO]: Setup complete.") - while env.sim.is_headless_or_exist_active_visualizer(): + while env.sim.is_headless_or_exist_active_visualizer() and ( + args_cli.max_steps < 0 or count < args_cli.max_steps + ): if count > 0 and count % 50 == 0: num_reset = int(torch.randint(1, env.num_envs + 1, ()).item()) env_ids = torch.randperm(env.num_envs, dtype=torch.int32, device=env.device)[:num_reset] diff --git a/scripts/demos/arms.py b/scripts/demos/arms.py deleted file mode 100644 index e1279a3da785..000000000000 --- a/scripts/demos/arms.py +++ /dev/null @@ -1,256 +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 - -"""This script demonstrates different single-arm manipulators. - -.. code-block:: bash - - # Usage with default PhysX physics and default kit visualizer. - uv run python scripts/demos/arms.py - - # Usage with Newton visualizer and default PhysX physics. - uv run python scripts/demos/arms.py --visualizer newton - - # Usage with Newton (MJWarp) physics and default kit visualizer. - uv run python scripts/demos/arms.py --physics newton_mjwarp - - # Usage with Newton visualizer and Newton (MJWarp) physics. - uv run python scripts/demos/arms.py --visualizer newton --physics newton_mjwarp - -""" - -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - -import argparse -from typing import TYPE_CHECKING - -from isaaclab.app import add_launcher_args, launch_simulation - -parser = argparse.ArgumentParser( - description="This script demonstrates different single-arm manipulators.", - conflict_handler="resolve", -) -parser.add_argument( - "--physics", default="isaacsim_physx", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." -) -add_launcher_args(parser) -parser.set_defaults(visualizer=["kit"]) -args_cli = parser.parse_args() - -import numpy as np -import torch - -import isaaclab.sim as sim_utils -from isaaclab import cloner - -## -# Pre-defined configs -## -from isaaclab.physics import PhysicsCfg -from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR - -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg # isort:skip -from isaaclab_assets.robots.franka import FRANKA_PANDA_CFG # isort:skip -from isaaclab_assets.robots.kinova import KINOVA_GEN3_N7_CFG, KINOVA_JACO2_N6S300_CFG, KINOVA_JACO2_N7S300_CFG # isort:skip -from isaaclab_assets.robots.sawyer import SAWYER_CFG # isort:skip -from isaaclab_assets.robots.universal_robots import UR10_CFG # isort:skip - -if TYPE_CHECKING: - from isaaclab.assets import Articulation - - -def define_origins(num_origins: int, spacing: float) -> list[list[float]]: - """Defines the origins of the scene.""" - # create tensor based on number of environments - env_origins = torch.zeros(num_origins, 3) - # create a grid of origins - num_rows = np.floor(np.sqrt(num_origins)) - num_cols = np.ceil(num_origins / num_rows) - xx, yy = torch.meshgrid(torch.arange(num_rows), torch.arange(num_cols), indexing="xy") - env_origins[:, 0] = spacing * xx.flatten()[:num_origins] - spacing * (num_rows - 1) / 2 - env_origins[:, 1] = spacing * yy.flatten()[:num_origins] - spacing * (num_cols - 1) / 2 - env_origins[:, 2] = 0.0 - # return the origins - return env_origins.tolist() - - -def design_scene() -> tuple[dict, list[list[float]]]: - """Designs the scene.""" - # Ground-plane - cfg = sim_utils.GroundPlaneCfg() - cfg.func("/World/defaultGroundPlane", cfg) - # Lights - cfg = sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)) - cfg.func("/World/Light", cfg) - - # Create separate groups called "Origin1", "Origin2", "Origin3" - # Each group will have a mount and a robot on top of it - origins = define_origins(num_origins=6, spacing=2.0) - - # Origin 1 with Franka Panda - sim_utils.create_prim("/World/Origin1", "Xform", translation=origins[0]) - # -- Table - cfg = sim_utils.UsdFileCfg(usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Mounts/SeattleLabTable/table_instanceable.usd") - cfg.func("/World/Origin1/Table", cfg, translation=(0.55, 0.0, 1.05)) - # -- Robot - franka_arm_cfg = FRANKA_PANDA_CFG.replace(prim_path="/World/Origin1/Robot") - franka_arm_cfg.spawn.usd_path = f"{ISAAC_NUCLEUS_DIR}/Robots/FrankaRobotics/FrankaPanda/franka.usd" - franka_arm_cfg.init_state.pos = (0.0, 0.0, 1.05) - franka_panda = franka_arm_cfg.class_type(franka_arm_cfg) - - # Origin 2 with UR10 - sim_utils.create_prim("/World/Origin2", "Xform", translation=origins[1]) - # -- Table - cfg = sim_utils.UsdFileCfg( - usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Mounts/Stand/stand_instanceable.usd", scale=(2.0, 2.0, 2.0) - ) - cfg.func("/World/Origin2/Table", cfg, translation=(0.0, 0.0, 1.03)) - # -- Robot - ur10_cfg = UR10_CFG.replace(prim_path="/World/Origin2/Robot") - ur10_cfg.init_state.pos = (0.0, 0.0, 1.03) - ur10 = ur10_cfg.class_type(ur10_cfg) - - # Origin 3 with Kinova JACO2 (7-Dof) arm - sim_utils.create_prim("/World/Origin3", "Xform", translation=origins[2]) - # -- Table - cfg = sim_utils.UsdFileCfg(usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Mounts/ThorlabsTable/table_instanceable.usd") - cfg.func("/World/Origin3/Table", cfg, translation=(0.0, 0.0, 0.8)) - # -- Robot - kinova_arm_cfg = KINOVA_JACO2_N7S300_CFG.replace(prim_path="/World/Origin3/Robot") - kinova_arm_cfg.init_state.pos = (0.0, 0.0, 0.8) - kinova_j2n7s300 = kinova_arm_cfg.class_type(kinova_arm_cfg) - - # Origin 4 with Kinova JACO2 (6-Dof) arm - sim_utils.create_prim("/World/Origin4", "Xform", translation=origins[3]) - # -- Table - cfg = sim_utils.UsdFileCfg(usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Mounts/ThorlabsTable/table_instanceable.usd") - cfg.func("/World/Origin4/Table", cfg, translation=(0.0, 0.0, 0.8)) - # -- Robot - kinova_arm_cfg = KINOVA_JACO2_N6S300_CFG.replace(prim_path="/World/Origin4/Robot") - kinova_arm_cfg.init_state.pos = (0.0, 0.0, 0.8) - kinova_j2n6s300 = kinova_arm_cfg.class_type(kinova_arm_cfg) - - # Origin 5 with Sawyer - sim_utils.create_prim("/World/Origin5", "Xform", translation=origins[4]) - # -- Table - cfg = sim_utils.UsdFileCfg(usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Mounts/SeattleLabTable/table_instanceable.usd") - cfg.func("/World/Origin5/Table", cfg, translation=(0.55, 0.0, 1.05)) - # -- Robot - kinova_arm_cfg = KINOVA_GEN3_N7_CFG.replace(prim_path="/World/Origin5/Robot") - kinova_arm_cfg.init_state.pos = (0.0, 0.0, 1.05) - kinova_gen3n7 = kinova_arm_cfg.class_type(kinova_arm_cfg) - - # Origin 6 with Kinova Gen3 (7-Dof) arm - sim_utils.create_prim("/World/Origin6", "Xform", translation=origins[5]) - # -- Table - cfg = sim_utils.UsdFileCfg( - usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Mounts/Stand/stand_instanceable.usd", scale=(2.0, 2.0, 2.0) - ) - cfg.func("/World/Origin6/Table", cfg, translation=(0.0, 0.0, 1.03)) - # -- Robot - sawyer_arm_cfg = SAWYER_CFG.replace(prim_path="/World/Origin6/Robot") - sawyer_arm_cfg.init_state.pos = (0.0, 0.0, 1.03) - sawyer = sawyer_arm_cfg.class_type(sawyer_arm_cfg) - - # return the scene information - scene_entities = { - "franka_panda": franka_panda, - "ur10": ur10, - "kinova_j2n7s300": kinova_j2n7s300, - "kinova_j2n6s300": kinova_j2n6s300, - "kinova_gen3n7": kinova_gen3n7, - "sawyer": sawyer, - } - return scene_entities, origins - - -def run_simulator(sim: "sim_utils.SimulationContext", entities: dict[str, "Articulation"], origins: torch.Tensor): - """Runs the simulation loop.""" - # Define simulation stepping - sim_dt = sim.get_physics_dt() - sim_time = 0.0 - count = 0 - # Step while a visualizer window is still open (or none exist, e.g. headless); works for kit and newton. - while sim.is_headless_or_exist_active_visualizer(): - # reset - if count % 200 == 0: - # reset counters - sim_time = 0.0 - count = 0 - # reset the scene entities - for index, robot in enumerate(entities.values()): - # root state - root_pose = robot.data.default_root_pose.torch.clone() - root_pose[:, :3] += origins[index] - robot.write_root_pose_to_sim_index(root_pose=root_pose) - root_vel = robot.data.default_root_vel.torch.clone() - robot.write_root_velocity_to_sim_index(root_velocity=root_vel) - # set joint positions - joint_pos, joint_vel = ( - robot.data.default_joint_pos.torch.clone(), - robot.data.default_joint_vel.torch.clone(), - ) - robot.write_joint_position_to_sim_index(position=joint_pos) - robot.write_joint_velocity_to_sim_index(velocity=joint_vel) - # clear internal buffers - robot.reset() - print("[INFO]: Resetting robots state...") - # apply random actions to the robots - for robot in entities.values(): - # generate random joint positions - joint_pos_target = robot.data.default_joint_pos.torch + torch.randn_like(robot.data.joint_pos.torch) * 0.1 - soft_limits = robot.data.soft_joint_pos_limits.torch - joint_pos_target = joint_pos_target.clamp_(soft_limits[..., 0], soft_limits[..., 1]) - # apply action to the robot - robot.set_joint_position_target_index(target=joint_pos_target) - # write data to sim - robot.write_data_to_sim() - # perform step - sim.step() - # update sim-time - sim_time += sim_dt - count += 1 - # update buffers - for robot in entities.values(): - robot.update(sim_dt) - - -def main(): - """Main function.""" - with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: - # The default newton mjwarp solver configuration needs to be tuned for these arms. - if isinstance(physics_cfg, NewtonCfg) and isinstance(physics_cfg.solver_cfg, MJWarpSolverCfg): - physics_cfg.solver_cfg.njmax = 70 - physics_cfg.solver_cfg.nconmax = 70 - physics_cfg.solver_cfg.ls_iterations = 40 - physics_cfg.solver_cfg.cone = "elliptic" - physics_cfg.solver_cfg.impratio = 100 - physics_cfg.solver_cfg.ls_parallel = False - physics_cfg.solver_cfg.integrator = "implicitfast" - physics_cfg.num_substeps = 2 - - # Initialize the simulation context - sim_cfg = sim_utils.SimulationCfg(device=args_cli.device, physics=physics_cfg) - sim = sim_utils.SimulationContext(sim_cfg) - # Set main camera - sim.set_camera_view([3.5, 0.0, 3.2], [0.0, 0.0, 0.5]) - # design scene - global_paths = ("/World/defaultGroundPlane", "/World/Light", *(f"/World/Origin{i}" for i in range(1, 7))) - plan = cloner.make_clone_plan((), 1, 0.0, global_paths=global_paths) - sim.set_clone_plan(plan) - scene_entities, scene_origins = design_scene() - cloner.replicate(plan, replicate_physics=False) - scene_origins = torch.tensor(scene_origins, device=sim.device) - # Play the simulator - sim.reset() - # Now we are ready! - print("[INFO]: Setup complete...") - # Run the simulator - run_simulator(sim, scene_entities, scene_origins) - - -if __name__ == "__main__": - # run the main function - main() diff --git a/scripts/demos/bipeds.py b/scripts/demos/bipeds.py deleted file mode 100644 index 0b2738113b88..000000000000 --- a/scripts/demos/bipeds.py +++ /dev/null @@ -1,171 +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 - -"""This script demonstrates how to simulate bipedal robots. - -.. code-block:: bash - - # Usage with default PhysX physics and default kit visualizer. - uv run python scripts/demos/bipeds.py - - # Usage with Newton visualizer and default PhysX physics. - uv run python scripts/demos/bipeds.py --visualizer newton - - # Usage with Newton (MJWarp) physics and default kit visualizer. - uv run python scripts/demos/bipeds.py --physics newton_mjwarp - - # Usage with Newton visualizer and Newton (MJWarp) physics. - uv run python scripts/demos/bipeds.py --visualizer newton --physics newton_mjwarp - -""" - -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - -import argparse -from typing import TYPE_CHECKING - -from isaaclab.app import add_launcher_args, launch_simulation - -parser = argparse.ArgumentParser( - description="This script demonstrates how to simulate bipedal robots.", - conflict_handler="resolve", -) -parser.add_argument( - "--physics", default="isaacsim_physx", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." -) -add_launcher_args(parser) -parser.set_defaults(visualizer=["kit"]) -args_cli = parser.parse_args() - -import torch - -import isaaclab.sim as sim_utils -from isaaclab import cloner - -## -# Pre-defined configs -## -from isaaclab.physics import PhysicsCfg - -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg # isort:skip -from isaaclab_assets.robots.cassie import CASSIE_CFG # isort:skip -from isaaclab_assets.robots.unitree import G1_CFG, H1_CFG # isort:skip - -if TYPE_CHECKING: - from isaaclab.assets import Articulation - - -def design_scene(sim: "sim_utils.SimulationContext") -> tuple[list, torch.Tensor]: - """Designs the scene.""" - # Ground-plane - cfg = sim_utils.GroundPlaneCfg() - cfg.func("/World/defaultGroundPlane", cfg) - # Lights - cfg = sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)) - cfg.func("/World/Light", cfg) - - # Define origins - origins = torch.tensor( - [ - [0.0, -1.0, 0.0], - [0.0, 0.0, 0.0], - [0.0, 1.0, 0.0], - ] - ).to(device=sim.device) - - # Robots - cassie_cfg = CASSIE_CFG.replace(prim_path="/World/Cassie") - cassie = cassie_cfg.class_type(cassie_cfg) - h1_cfg = H1_CFG.replace(prim_path="/World/H1") - h1 = h1_cfg.class_type(h1_cfg) - g1_cfg = G1_CFG.replace(prim_path="/World/G1") - g1 = g1_cfg.class_type(g1_cfg) - robots = [cassie, h1, g1] - - return robots, origins - - -def run_simulator(sim: "sim_utils.SimulationContext", robots: list["Articulation"], origins: torch.Tensor): - """Runs the simulation loop.""" - # Define simulation stepping - sim_dt = sim.get_physics_dt() - sim_time = 0.0 - count = 0 - # Step while a visualizer window is still open (or none exist, e.g. headless); works for kit and newton. - while sim.is_headless_or_exist_active_visualizer(): - # reset - if count % 200 == 0: - # reset counters - sim_time = 0.0 - count = 0 - for index, robot in enumerate(robots): - # reset dof state - joint_pos, joint_vel = ( - robot.data.default_joint_pos.torch, - robot.data.default_joint_vel.torch, - ) - robot.write_joint_position_to_sim_index(position=joint_pos) - robot.write_joint_velocity_to_sim_index(velocity=joint_vel) - root_pose = robot.data.default_root_pose.torch.clone() - root_pose[:, :3] += origins[index] - robot.write_root_pose_to_sim_index(root_pose=root_pose) - root_vel = robot.data.default_root_vel.torch.clone() - robot.write_root_velocity_to_sim_index(root_velocity=root_vel) - robot.reset() - # reset command - print(">>>>>>>> Reset!") - # apply action to the robot - for robot in robots: - robot.set_joint_position_target_index(target=robot.data.default_joint_pos.torch.clone()) - robot.write_data_to_sim() - # perform step - sim.step() - # update sim-time - sim_time += sim_dt - count += 1 - # update buffers - for robot in robots: - robot.update(sim_dt) - - -def main(): - """Main function.""" - with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: - # The default newton mjwarp solver configuration needs to be tuned for these bipeds. - if isinstance(physics_cfg, NewtonCfg) and isinstance(physics_cfg.solver_cfg, MJWarpSolverCfg): - physics_cfg.solver_cfg.njmax = 70 - physics_cfg.solver_cfg.nconmax = 70 - physics_cfg.solver_cfg.ls_iterations = 40 - physics_cfg.solver_cfg.cone = "elliptic" - physics_cfg.solver_cfg.impratio = 100 - physics_cfg.solver_cfg.ls_parallel = False - physics_cfg.solver_cfg.integrator = "implicitfast" - physics_cfg.num_substeps = 2 - # Load kit helper - sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device, physics=physics_cfg) - sim = sim_utils.SimulationContext(sim_cfg) - # Set main camera - sim.set_camera_view(eye=[3.0, 0.0, 2.25], target=[0.0, 0.0, 1.0]) - - # design scene - global_paths = ("/World/defaultGroundPlane", "/World/Light", "/World/Cassie", "/World/H1", "/World/G1") - plan = cloner.make_clone_plan((), 1, 0.0, global_paths=global_paths) - sim.set_clone_plan(plan) - robots, origins = design_scene(sim) - cloner.replicate(plan, replicate_physics=False) - - # Play the simulator - sim.reset() - - # Now we are ready! - print("[INFO]: Setup complete...") - - # Run the simulator - run_simulator(sim, robots, origins) - - -if __name__ == "__main__": - # run the main function - main() diff --git a/scripts/demos/h1_locomotion.py b/scripts/demos/h1_locomotion.py deleted file mode 100644 index d7c235a15f9c..000000000000 --- a/scripts/demos/h1_locomotion.py +++ /dev/null @@ -1,240 +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 - -""" -This script demonstrates an interactive demo with the H1 rough terrain environment. - -.. code-block:: bash - - # Usage - uv run python scripts/demos/h1_locomotion.py - -""" - -"""Launch Isaac Sim Simulator first.""" - -import argparse -from importlib import metadata - -from isaaclab_rl.entrypoints.backends import cli_args_rsl_rl as cli_args # isort: skip - - -from isaaclab.app import AppLauncher - -# add argparse arguments -parser = argparse.ArgumentParser( - description="This script demonstrates an interactive demo with the H1 rough terrain environment." -) -# append RSL-RL cli arguments -cli_args.add_rsl_rl_args(parser) -parser.add_argument( - "--physics", - default="isaacsim_physx", - choices=["isaacsim_physx"], - help="Physics backend.", -) -# append AppLauncher cli args -AppLauncher.add_app_launcher_args(parser) -# demos should open Kit visualizer by default -parser.set_defaults(visualizer=["kit"]) -# parse the arguments -args_cli = parser.parse_args() - -# launch omniverse app -app_launcher = AppLauncher(args_cli) -simulation_app = app_launcher.app - -import torch -from rsl_rl.runners import OnPolicyRunner - -import carb -import omni -from omni.kit.viewport.utility import get_viewport_from_window_name -from omni.kit.viewport.utility.camera_state import ViewportCameraState -from pxr import Gf, Sdf - -from isaaclab.envs import ManagerBasedRLEnv -from isaaclab.sim.utils.stage import get_current_stage -from isaaclab.utils.math import quat_apply - -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlVecEnvWrapper, handle_deprecated_rsl_rl_cfg -from isaaclab_rl.utils.pretrained_checkpoint import ( - get_pretrained_checkpoint_backend_names, - get_published_pretrained_checkpoint, -) - -from isaaclab_tasks.utils import resolve_task_config - -TASK = "Isaac-Velocity-Rough-H1" -RL_LIBRARY = "rsl_rl" - - -class H1RoughDemo: - """This class provides an interactive demo for the H1 rough terrain environment. - It loads a pre-trained checkpoint for the Isaac-Velocity-Rough-H1 task, trained with RSL RL - and defines a set of keyboard commands for directing motion of selected robots. - - A robot can be selected from the scene through a mouse click. Once selected, the following - keyboard controls can be used to control the robot: - - * UP: go forward - * LEFT: turn left - * RIGHT: turn right - * DOWN: stop - * C: switch between third-person and perspective views - * ESC: exit current third-person view""" - - def __init__(self): - """Initializes environment config designed for the interactive model and sets up the environment, - loads pre-trained checkpoints, and registers keyboard events.""" - agent_cfg: RslRlOnPolicyRunnerCfg = cli_args.parse_rsl_rl_cfg(TASK, args_cli) - agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, metadata.version("rsl-rl-lib")) - # create envionrment - env_cfg, _ = resolve_task_config(TASK, "", play_mode=True, overrides=(f"physics={args_cli.physics}",)) - env_cfg.scene.num_envs = 25 - env_cfg.episode_length_s = 1000000 - env_cfg.curriculum = None - env_cfg.commands.base_velocity.ranges.lin_vel_x = (0.0, 1.0) - env_cfg.commands.base_velocity.ranges.heading = (-1.0, 1.0) - # load the trained jit policy - backend_names = get_pretrained_checkpoint_backend_names(env_cfg) - checkpoint = get_published_pretrained_checkpoint(RL_LIBRARY, TASK, *backend_names) - if checkpoint is None: - raise FileNotFoundError("No published checkpoint is available for the H1 locomotion demo.") - # wrap around environment for rsl-rl - self.env = RslRlVecEnvWrapper(ManagerBasedRLEnv(cfg=env_cfg)) - self.device = self.env.unwrapped.device - # load previously trained model - ppo_runner = OnPolicyRunner(self.env, agent_cfg.to_dict(), log_dir=None, device=self.device) - ppo_runner.load(checkpoint) - # obtain the trained policy for inference - self.policy = ppo_runner.get_inference_policy(device=self.device) - - self.create_camera() - self.commands = torch.zeros(env_cfg.scene.num_envs, 4, device=self.device) - self.commands[:, 0:3] = self.env.unwrapped.command_manager.get_command("base_velocity") - self.set_up_keyboard() - self._prim_selection = omni.usd.get_context().get_selection() - self._selected_id = None - self._previous_selected_id = None - self._camera_local_transform = torch.tensor([-2.5, 0.0, 0.8], device=self.device) - - def create_camera(self): - """Creates a camera to be used for third-person view.""" - stage = get_current_stage() - self.viewport = get_viewport_from_window_name("Viewport") - # Create camera - self.camera_path = "/World/Camera" - self.perspective_path = "/OmniverseKit_Persp" - camera_prim = stage.DefinePrim(self.camera_path, "Camera") - camera_prim.GetAttribute("focalLength").Set(8.5) - coi_prop = camera_prim.GetProperty("omni:kit:centerOfInterest") - if not coi_prop or not coi_prop.IsValid(): - camera_prim.CreateAttribute( - "omni:kit:centerOfInterest", Sdf.ValueTypeNames.Vector3d, True, Sdf.VariabilityUniform - ).Set(Gf.Vec3d(0, 0, -10)) - self.viewport.set_active_camera(self.perspective_path) - - def set_up_keyboard(self): - """Sets up interface for keyboard input and registers the desired keys for control.""" - self._input = carb.input.acquire_input_interface() - self._keyboard = omni.appwindow.get_default_app_window().get_keyboard() - self._sub_keyboard = self._input.subscribe_to_keyboard_events(self._keyboard, self._on_keyboard_event) - T = 1 - R = 0.5 - self._key_to_control = { - "UP": torch.tensor([T, 0.0, 0.0, 0.0], device=self.device), - "DOWN": torch.tensor([0.0, 0.0, 0.0, 0.0], device=self.device), - "LEFT": torch.tensor([T, 0.0, 0.0, -R], device=self.device), - "RIGHT": torch.tensor([T, 0.0, 0.0, R], device=self.device), - "ZEROS": torch.tensor([0.0, 0.0, 0.0, 0.0], device=self.device), - } - - def _on_keyboard_event(self, event): - """Checks for a keyboard event and assign the corresponding command control depending on key pressed.""" - if event.type == carb.input.KeyboardEventType.KEY_PRESS: - # Arrow keys map to pre-defined command vectors to control navigation of robot - if event.input.name in self._key_to_control: - if self._selected_id is not None: - self.commands[self._selected_id] = self._key_to_control[event.input.name] - # Escape key exits out of the current selected robot view - elif event.input.name == "ESCAPE": - self._prim_selection.clear_selected_prim_paths() - # C key swaps between third-person and perspective views - elif event.input.name == "C": - if self._selected_id is not None: - if self.viewport.get_active_camera() == self.camera_path: - self.viewport.set_active_camera(self.perspective_path) - else: - self.viewport.set_active_camera(self.camera_path) - # On key release, the robot stops moving - elif event.type == carb.input.KeyboardEventType.KEY_RELEASE: - if self._selected_id is not None: - self.commands[self._selected_id] = self._key_to_control["ZEROS"] - - def update_selected_object(self): - """Determines which robot is currently selected and whether it is a valid H1 robot. - For valid robots, we enter the third-person view for that robot. - When a new robot is selected, we reset the command of the previously selected - to continue random commands.""" - - self._previous_selected_id = self._selected_id - selected_prim_paths = self._prim_selection.get_selected_prim_paths() - if len(selected_prim_paths) == 0: - self._selected_id = None - self.viewport.set_active_camera(self.perspective_path) - elif len(selected_prim_paths) > 1: - print("Multiple prims are selected. Please only select one!") - else: - prim_splitted_path = selected_prim_paths[0].split("/") - # a valid robot was selected, update the camera to go into third-person view - if len(prim_splitted_path) >= 4 and prim_splitted_path[3][0:4] == "env_": - self._selected_id = int(prim_splitted_path[3][4:]) - if self._previous_selected_id != self._selected_id: - self.viewport.set_active_camera(self.camera_path) - self._update_camera() - else: - print("The selected prim was not a H1 robot") - - # Reset commands for previously selected robot if a new one is selected - if self._previous_selected_id is not None and self._previous_selected_id != self._selected_id: - self.env.unwrapped.command_manager.reset([self._previous_selected_id]) - self.commands[:, 0:3] = self.env.unwrapped.command_manager.get_command("base_velocity") - - def _update_camera(self): - """Updates the per-frame transform of the third-person view camera to follow - the selected robot's torso transform.""" - - base_pos = self.env.unwrapped.scene["robot"].data.root_pos_w.torch[ - self._selected_id, : - ] # - env.scene.env_origins - base_quat = self.env.unwrapped.scene["robot"].data.root_quat_w.torch[self._selected_id, :] - - camera_pos = quat_apply(base_quat, self._camera_local_transform) + base_pos - - camera_state = ViewportCameraState(self.camera_path, self.viewport) - eye = Gf.Vec3d(camera_pos[0].item(), camera_pos[1].item(), camera_pos[2].item()) - target = Gf.Vec3d(base_pos[0].item(), base_pos[1].item(), base_pos[2].item() + 0.6) - camera_state.set_position_world(eye, True) - camera_state.set_target_world(target, True) - - -def main(): - """Main function.""" - demo_h1 = H1RoughDemo() - obs, _ = demo_h1.env.reset() - while simulation_app.is_running(): - # check for selected robots - demo_h1.update_selected_object() - with torch.inference_mode(): - action = demo_h1.policy(obs) - obs, _, _, _ = demo_h1.env.step(action) - # overwrite command based on keyboard input - obs[:, 9:13] = demo_h1.commands - - -if __name__ == "__main__": - main() - simulation_app.close() diff --git a/scripts/demos/hands.py b/scripts/demos/hands.py deleted file mode 100644 index bc4d14198da2..000000000000 --- a/scripts/demos/hands.py +++ /dev/null @@ -1,223 +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 - -"""This script demonstrates different dexterous hands. - -.. code-block:: bash - - # Usage with default PhysX physics and default kit visualizer. - uv run python scripts/demos/hands.py - - # Usage with Newton visualizer and default PhysX physics. - uv run python scripts/demos/hands.py --visualizer newton - - # Usage with Newton (MJWarp) physics and default kit visualizer. - uv run python scripts/demos/hands.py --physics newton_mjwarp - - # Usage with Newton visualizer and Newton (MJWarp) physics. - uv run python scripts/demos/hands.py --visualizer newton --physics newton_mjwarp - -""" - -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - -import argparse -from typing import TYPE_CHECKING - -from isaaclab.app import add_launcher_args, launch_simulation - -parser = argparse.ArgumentParser( - description="This script demonstrates different dexterous hands.", - conflict_handler="resolve", -) -parser.add_argument( - "--physics", default="isaacsim_physx", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." -) -add_launcher_args(parser) -parser.set_defaults(visualizer=["kit"]) -args_cli = parser.parse_args() - -import numpy as np -import torch - -import isaaclab.sim as sim_utils -from isaaclab import cloner - -## -# Pre-defined configs -## -from isaaclab.physics import PhysicsCfg - -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg # isort:skip -from isaaclab_assets.robots.allegro import ALLEGRO_HAND_CFG # isort:skip -from isaaclab_assets.robots.shadow_hand import ( - SHADOW_HAND_NEWTON_CFG, - SHADOW_HAND_PHYSX_CFG, - TENDON_POSITION_LIMITS, -) - -if TYPE_CHECKING: - from isaaclab.assets import Articulation - - -def define_origins(num_origins: int, spacing: float) -> list[list[float]]: - """Defines the origins of the scene.""" - # create tensor based on number of environments - env_origins = torch.zeros(num_origins, 3) - # create a grid of origins - num_cols = np.floor(np.sqrt(num_origins)) - num_rows = np.ceil(num_origins / num_cols) - xx, yy = torch.meshgrid(torch.arange(num_rows), torch.arange(num_cols), indexing="xy") - env_origins[:, 0] = spacing * xx.flatten()[:num_origins] - spacing * (num_rows - 1) / 2 - env_origins[:, 1] = spacing * yy.flatten()[:num_origins] - spacing * (num_cols - 1) / 2 - env_origins[:, 2] = 0.0 - # return the origins - return env_origins.tolist() - - -def design_scene() -> tuple[dict, list[list[float]]]: - """Designs the scene.""" - # Ground-plane - cfg = sim_utils.GroundPlaneCfg() - cfg.func("/World/defaultGroundPlane", cfg) - # Lights - cfg = sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)) - cfg.func("/World/Light", cfg) - - # Create separate groups called "Origin1", "Origin2", "Origin3" - # Each group will have a mount and a robot on top of it - origins = define_origins(num_origins=2, spacing=0.5) - - # Origin 1 with Allegro Hand - sim_utils.create_prim("/World/Origin1", "Xform", translation=origins[0]) - # -- Robot - allegro_cfg = ALLEGRO_HAND_CFG.replace(prim_path="/World/Origin1/Robot") - allegro = allegro_cfg.class_type(allegro_cfg) - - # Origin 2 with Shadow Hand - sim_utils.create_prim("/World/Origin2", "Xform", translation=origins[1]) - # -- Robot - shadow_hand_cfg = SHADOW_HAND_NEWTON_CFG if args_cli.physics == "newton_mjwarp" else SHADOW_HAND_PHYSX_CFG - # Pose for this side-by-side scene; the asset's own pose is the reorientation task's. - shadow_hand_cfg = shadow_hand_cfg.replace( - prim_path="/World/Origin2/Robot", - init_state=shadow_hand_cfg.init_state.replace( - pos=(0.0, 0.2, 0.5), - rot=(0.52296271, -0.47593067, 0.47593067, 0.52296271), - ), - ) - shadow_hand = shadow_hand_cfg.class_type(shadow_hand_cfg) - - # return the scene information - scene_entities = { - "allegro": allegro, - "shadow_hand": shadow_hand, - } - return scene_entities, origins - - -def run_simulator(sim: "sim_utils.SimulationContext", entities: dict[str, "Articulation"], origins: torch.Tensor): - """Runs the simulation loop.""" - # Define simulation stepping - sim_dt = sim.get_physics_dt() - sim_time = 0.0 - count = 0 - # Start with hand open - grasp_mode = 0 - # Step while a visualizer window is still open (or none exist, e.g. headless); works for kit and newton. - while sim.is_headless_or_exist_active_visualizer(): - # reset - if count % 1000 == 0: - # reset counters - sim_time = 0.0 - count = 0 - # reset robots - for index, robot in enumerate(entities.values()): - # root state - root_pose = robot.data.default_root_pose.torch.clone() - root_pose[:, :3] += origins[index] - robot.write_root_pose_to_sim_index(root_pose=root_pose) - root_vel = robot.data.default_root_vel.torch.clone() - robot.write_root_velocity_to_sim_index(root_velocity=root_vel) - # joint state - joint_pos, joint_vel = ( - robot.data.default_joint_pos.torch.clone(), - robot.data.default_joint_vel.torch.clone(), - ) - robot.write_joint_position_to_sim_index(position=joint_pos) - robot.write_joint_velocity_to_sim_index(velocity=joint_vel) - # reset the internal state - robot.reset() - print("[INFO]: Resetting robots state...") - # toggle grasp mode - if count % 100 == 0: - grasp_mode = 1 - grasp_mode - # apply default actions to the hands robots - for robot in entities.values(): - # generate joint positions - joint_pos_target = robot.data.soft_joint_pos_limits.torch[..., grasp_mode] - # apply action to the robot - robot.set_joint_position_target_index(target=joint_pos_target) - # A tendon has no actuator on its spanned joints, so it needs its own command. - # Span comes from the asset: a fixed tendon authors no position limit of its own. - if robot.num_fixed_tendons > 0: - tendon_pos_target = torch.full( - (robot.num_instances, robot.num_fixed_tendons), - TENDON_POSITION_LIMITS[grasp_mode], - device=robot.device, - ) - robot.set_fixed_tendon_position_target_index(target=tendon_pos_target) - # write data to sim - robot.write_data_to_sim() - # perform step - sim.step() - # update sim-time - sim_time += sim_dt - count += 1 - # update buffers - for robot in entities.values(): - robot.update(sim_dt) - - -def main(): - """Main function.""" - with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: - # The default newton mjwarp solver configuration needs to be tuned for these hands. - if isinstance(physics_cfg, NewtonCfg) and isinstance(physics_cfg.solver_cfg, MJWarpSolverCfg): - # The tendon-coupled fingers diverge past their limits under the default explicit - # integrator; the reorientation task's preset uses this one for the same reason. - physics_cfg.solver_cfg.integrator = "implicitfast" - physics_cfg.solver_cfg.njmax = 200 - physics_cfg.solver_cfg.nconmax = 70 - physics_cfg.solver_cfg.impratio = 10.0 - physics_cfg.solver_cfg.cone = "elliptic" - physics_cfg.solver_cfg.update_data_interval = 2 - physics_cfg.solver_cfg.ccd_iterations = 50 - physics_cfg.num_substeps = 2 - physics_cfg.debug_mode = False - - # Initialize the simulation context - sim_cfg = sim_utils.SimulationCfg(dt=0.01, device=args_cli.device, physics=physics_cfg) - sim = sim_utils.SimulationContext(sim_cfg) - # Set main camera - sim.set_camera_view(eye=[0.0, -0.5, 1.5], target=[0.0, -0.05, 0.45]) - # design scene - global_paths = ("/World/defaultGroundPlane", "/World/Light", "/World/Origin1", "/World/Origin2") - plan = cloner.make_clone_plan((), 1, 0.0, global_paths=global_paths) - sim.set_clone_plan(plan) - scene_entities, scene_origins = design_scene() - cloner.replicate(plan, replicate_physics=False) - scene_origins = torch.tensor(scene_origins, device=sim.device) - # Play the simulator - sim.reset() - # Now we are ready! - print("[INFO]: Setup complete...") - # Run the simulator - run_simulator(sim, scene_entities, scene_origins) - - -if __name__ == "__main__": - # run the main execution - main() diff --git a/scripts/demos/quadcopter.py b/scripts/demos/quadcopter.py deleted file mode 100644 index 125cb1b51809..000000000000 --- a/scripts/demos/quadcopter.py +++ /dev/null @@ -1,135 +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 - -"""This script demonstrates how to simulate a quadcopter. - -.. code-block:: bash - - # Usage with default PhysX physics and default kit visualizer. - uv run python scripts/demos/quadcopter.py - - # Usage with Newton visualizer and default PhysX physics. - uv run python scripts/demos/quadcopter.py --visualizer newton - - # Usage with Newton (MJWarp) physics and default kit visualizer. - uv run python scripts/demos/quadcopter.py --physics newton_mjwarp - - # Usage with Newton visualizer and Newton (MJWarp) physics. - uv run python scripts/demos/quadcopter.py --visualizer newton --physics newton_mjwarp - -""" - -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - -import argparse - -from isaaclab.app import add_launcher_args, launch_simulation - -parser = argparse.ArgumentParser( - description="This script demonstrates how to simulate a quadcopter.", - conflict_handler="resolve", -) -parser.add_argument( - "--physics", default="isaacsim_physx", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." -) -add_launcher_args(parser) -parser.set_defaults(visualizer=["kit"]) -args_cli = parser.parse_args() - -import torch - -import isaaclab.sim as sim_utils -from isaaclab import cloner - -## -# Pre-defined configs -## -from isaaclab.physics import PhysicsCfg - -from isaaclab_assets import CRAZYFLIE_CFG # isort:skip - - -def main(): - """Main function.""" - with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: - # Load kit helper - sim_cfg = sim_utils.SimulationCfg(dt=0.005, device=args_cli.device, physics=physics_cfg) - sim = sim_utils.SimulationContext(sim_cfg) - # Set main camera - sim.set_camera_view(eye=[0.25, -0.25, 0.7], target=[0.0, 0.0, 0.5]) - global_paths = ("/World/defaultGroundPlane", "/World/Light", "/World/Crazyflie") - plan = cloner.make_clone_plan((), 1, 0.0, global_paths=global_paths) - sim.set_clone_plan(plan) - - # Spawn things into stage - # Ground-plane - cfg = sim_utils.GroundPlaneCfg() - cfg.func("/World/defaultGroundPlane", cfg) - # Lights - cfg = sim_utils.DistantLightCfg(intensity=3000.0, color=(0.75, 0.75, 0.75)) - cfg.func("/World/Light", cfg) - - # Robots - robot_cfg = CRAZYFLIE_CFG.replace(prim_path="/World/Crazyflie") - - # create handles for the robots - robot = robot_cfg.class_type(robot_cfg) - cloner.replicate(plan, replicate_physics=False) - - # Play the simulator - sim.reset() - - # Fetch relevant parameters to make the quadcopter hover in place - prop_body_ids = robot.find_bodies("m.*_prop")[0] - robot_mass = robot.data.body_mass.torch[0].sum() - gravity = torch.tensor(sim.cfg.gravity, device=sim.device).norm() - - # Now we are ready! - print("[INFO]: Setup complete...") - - # Define simulation stepping - sim_dt = sim.get_physics_dt() - sim_time = 0.0 - count = 0 - # Step while a visualizer window is still open (or none exist, e.g. headless); works for kit and newton. - while sim.is_headless_or_exist_active_visualizer(): - # reset - if count % 2000 == 0: - # reset counters - sim_time = 0.0 - count = 0 - # reset dof state - joint_pos, joint_vel = robot.data.default_joint_pos.torch, robot.data.default_joint_vel.torch - robot.write_joint_position_to_sim_index(position=joint_pos) - robot.write_joint_velocity_to_sim_index(velocity=joint_vel) - default_root_pose = robot.data.default_root_pose.torch - robot.write_root_pose_to_sim_index(root_pose=default_root_pose) - default_root_vel = robot.data.default_root_vel.torch - robot.write_root_velocity_to_sim_index(root_velocity=default_root_vel) - robot.reset() - # reset command - print(">>>>>>>> Reset!") - # apply action to the robot (make the robot float in place) - forces = torch.zeros(robot.num_instances, 4, 3, device=sim.device) - torques = torch.zeros_like(forces) - forces[..., 2] = robot_mass * gravity / 4.0 - robot.permanent_wrench_composer.set_forces_and_torques_index( - forces=forces, - torques=torques, - body_ids=prop_body_ids, - ) - robot.write_data_to_sim() - # perform step - sim.step() - # update sim-time - sim_time += sim_dt - count += 1 - # update buffers - robot.update(sim_dt) - - -if __name__ == "__main__": - # run the main function - main() diff --git a/scripts/demos/quadrupeds.py b/scripts/demos/quadrupeds.py deleted file mode 100644 index 973e02f95037..000000000000 --- a/scripts/demos/quadrupeds.py +++ /dev/null @@ -1,207 +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 - -"""This script demonstrates different legged robots. - -.. code-block:: bash - - # Usage with default PhysX physics and default kit visualizer. - uv run python scripts/demos/quadrupeds.py - - # Usage with Newton visualizer and default PhysX physics. - uv run python scripts/demos/quadrupeds.py --visualizer newton - - # Usage with Newton (MJWarp) physics and default kit visualizer. - uv run python scripts/demos/quadrupeds.py --physics newton_mjwarp - - # Usage with Newton visualizer and Newton (MJWarp) physics. - uv run python scripts/demos/quadrupeds.py --visualizer newton --physics newton_mjwarp - -""" - -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - -import argparse -from typing import TYPE_CHECKING - -from isaaclab.app import add_launcher_args, launch_simulation - -parser = argparse.ArgumentParser( - description="This script demonstrates different legged robots.", - conflict_handler="resolve", -) -parser.add_argument( - "--physics", default="isaacsim_physx", choices=["isaacsim_physx", "newton_mjwarp"], help="Physics backend." -) -add_launcher_args(parser) -parser.set_defaults(visualizer=["kit"]) -args_cli = parser.parse_args() - -import numpy as np -import torch - -import isaaclab.sim as sim_utils -from isaaclab import cloner - -## -# Pre-defined configs -## -from isaaclab.physics import PhysicsCfg - -from isaaclab_assets.robots.anymal import ANYMAL_B_CFG, ANYMAL_C_CFG, ANYMAL_D_CFG # isort:skip -from isaaclab_assets.robots.spot import SPOT_CFG # isort:skip -from isaaclab_assets.robots.unitree import UNITREE_A1_CFG, UNITREE_GO1_CFG, UNITREE_GO2_CFG # isort:skip - -if TYPE_CHECKING: - from isaaclab.assets import Articulation - - -def define_origins(num_origins: int, spacing: float) -> torch.Tensor: - """Defines the origins of the scene.""" - # create tensor based on number of environments - env_origins = torch.zeros(num_origins, 3) - # create a grid of origins - num_cols = np.floor(np.sqrt(num_origins)) - num_rows = np.ceil(num_origins / num_cols) - xx, yy = torch.meshgrid(torch.arange(num_rows), torch.arange(num_cols), indexing="xy") - env_origins[:, 0] = spacing * xx.flatten()[:num_origins] - spacing * (num_rows - 1) / 2 - env_origins[:, 1] = spacing * yy.flatten()[:num_origins] - spacing * (num_cols - 1) / 2 - env_origins[:, 2] = 0.0 - # return the origins - return env_origins - - -def design_scene() -> tuple[dict, torch.Tensor]: - """Designs the scene.""" - # Ground-plane - cfg = sim_utils.GroundPlaneCfg() - cfg.func("/World/defaultGroundPlane", cfg) - # Lights - cfg = sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)) - cfg.func("/World/Light", cfg) - - # Create separate groups called "Origin1", "Origin2", "Origin3" - # Each group will have a mount and a robot on top of it - origins = define_origins(num_origins=7, spacing=1.25) - - # Origin 1 with Anymal B - sim_utils.create_prim("/World/Origin1", "Xform", translation=origins[0]) - # -- Robot - anymal_b_cfg = ANYMAL_B_CFG.replace(prim_path="/World/Origin1/Robot") - anymal_b = anymal_b_cfg.class_type(anymal_b_cfg) - - # Origin 2 with Anymal C - sim_utils.create_prim("/World/Origin2", "Xform", translation=origins[1]) - # -- Robot - anymal_c_cfg = ANYMAL_C_CFG.replace(prim_path="/World/Origin2/Robot") - anymal_c = anymal_c_cfg.class_type(anymal_c_cfg) - - # Origin 3 with Anymal D - sim_utils.create_prim("/World/Origin3", "Xform", translation=origins[2]) - # -- Robot - anymal_d_cfg = ANYMAL_D_CFG.replace(prim_path="/World/Origin3/Robot") - anymal_d = anymal_d_cfg.class_type(anymal_d_cfg) - - # Origin 4 with Unitree A1 - sim_utils.create_prim("/World/Origin4", "Xform", translation=origins[3]) - # -- Robot - unitree_a1_cfg = UNITREE_A1_CFG.replace(prim_path="/World/Origin4/Robot") - unitree_a1 = unitree_a1_cfg.class_type(unitree_a1_cfg) - - # Origin 5 with Unitree Go1 - sim_utils.create_prim("/World/Origin5", "Xform", translation=origins[4]) - # -- Robot - unitree_go1_cfg = UNITREE_GO1_CFG.replace(prim_path="/World/Origin5/Robot") - unitree_go1 = unitree_go1_cfg.class_type(unitree_go1_cfg) - - # Origin 6 with Unitree Go2 - sim_utils.create_prim("/World/Origin6", "Xform", translation=origins[5]) - # -- Robot - unitree_go2_cfg = UNITREE_GO2_CFG.replace(prim_path="/World/Origin6/Robot") - unitree_go2 = unitree_go2_cfg.class_type(unitree_go2_cfg) - - # Origin 7 with Boston Dynamics Spot - sim_utils.create_prim("/World/Origin7", "Xform", translation=origins[6]) - # -- Robot - spot_cfg = SPOT_CFG.replace(prim_path="/World/Origin7/Robot") - spot = spot_cfg.class_type(spot_cfg) - - # return the scene information - scene_entities = { - "anymal_b": anymal_b, - "anymal_c": anymal_c, - "anymal_d": anymal_d, - "unitree_a1": unitree_a1, - "unitree_go1": unitree_go1, - "unitree_go2": unitree_go2, - "spot": spot, - } - return scene_entities, origins - - -def run_simulator(sim: "sim_utils.SimulationContext", entities: dict[str, "Articulation"], origins: torch.Tensor): - """Runs the simulation loop.""" - # Define simulation stepping - sim_dt = sim.get_physics_dt() - count = 0 - # Step while a visualizer window is still open (or none exist, e.g. headless); works for kit and newton. - while sim.is_headless_or_exist_active_visualizer(): - # Reset robots every 200 steps. - if count % 200 == 0: - # reset counters - count = 0 - # reset robots - for index, robot in enumerate(entities.values()): - # root state - root_pose = robot.data.default_root_pose.torch.clone() - root_pose[:, :3] += origins[index] - robot.write_root_pose_to_sim_index(root_pose=root_pose) - root_vel = robot.data.default_root_vel.torch.clone() - robot.write_root_velocity_to_sim_index(root_velocity=root_vel) - # joint state - joint_pos = robot.data.default_joint_pos.torch.clone() - robot.write_joint_position_to_sim_index(position=joint_pos) - joint_vel = robot.data.default_joint_vel.torch.clone() - robot.write_joint_velocity_to_sim_index(velocity=joint_vel) - # reset the internal state - robot.reset() - print("[INFO]: Reset robots' state...") - # Apply default actions to the quadrupedal robots. - for robot in entities.values(): - # generate random joint positions - joint_pos_target = robot.data.default_joint_pos.torch + torch.randn_like(robot.data.joint_pos.torch) * 0.1 - # apply action to the robot - robot.set_joint_position_target_index(target=joint_pos_target) - # write data to sim - robot.write_data_to_sim() - # perform step - sim.step() - # update counter - count += 1 - # update buffers - for robot in entities.values(): - robot.update(sim_dt) - - -def main(): - """Main function.""" - with launch_simulation(cfg=PhysicsCfg(), launcher_args=args_cli) as physics_cfg: - dt = 1 / 200 - sim_cfg: sim_utils.SimulationCfg = sim_utils.SimulationCfg(dt=dt, device=args_cli.device, physics=physics_cfg) - sim = sim_utils.SimulationContext(sim_cfg) - sim.set_camera_view(eye=[2.5, 2.5, 2.5], target=[0.0, 0.0, 0.0]) - global_paths = ("/World/defaultGroundPlane", "/World/Light", *(f"/World/Origin{i}" for i in range(1, 8))) - plan = cloner.make_clone_plan((), 1, 0.0, global_paths=global_paths) - sim.set_clone_plan(plan) - scene_entities, scene_origins = design_scene() - cloner.replicate(plan, replicate_physics=False) - scene_origins = scene_origins.to(sim.device) - sim.reset() - print("[INFO]: Setup complete...") - run_simulator(sim, scene_entities, scene_origins) - - -if __name__ == "__main__": - main() diff --git a/scripts/demos/sensors/newton_raycast_heightfield.py b/scripts/demos/sensors/newton_raycast_heightfield.py deleted file mode 100644 index 553f844f82a6..000000000000 --- a/scripts/demos/sensors/newton_raycast_heightfield.py +++ /dev/null @@ -1,157 +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 - -"""Demo: Newton BVH ray-cast sensor scanning a heightfield terrain. - -A grid ray-cast sensor rides a body that circles, bobs, and tumbles above a -wave heightfield. Rays are drawn live in the Newton viewer — red where they -hit the terrain (with sphere markers at the hit points), gray where they miss. - -.. code-block:: bash - - ./isaaclab.sh -p scripts/demos/sensors/newton_raycast_heightfield.py - -""" - -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - -import argparse - -from isaaclab.app import add_launcher_args, launch_simulation - -parser = argparse.ArgumentParser( - description="Newton BVH ray-cast sensor scanning a heightfield.", - conflict_handler="resolve", -) -add_launcher_args(parser) -parser.set_defaults(visualizer=["newton"]) -args_cli = parser.parse_args() - -import math - -import numpy as np -import torch -import warp as wp -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg -from isaaclab_newton.sensors import NewtonRaycastSensor, NewtonRaycastSensorCfg - -import isaaclab.sim as sim_utils -import isaaclab.terrains as terrain_gen -import isaaclab.utils.math as math_utils -from isaaclab.assets import RigidObject, RigidObjectCfg -from isaaclab.scene import InteractiveScene, InteractiveSceneCfg -from isaaclab.sensors.ray_caster.patterns import GridPatternCfg -from isaaclab.terrains import TerrainGeneratorCfg, TerrainImporterCfg -from isaaclab.utils import configclass - -WAVE_TERRAIN_CFG = TerrainGeneratorCfg( - size=(12.0, 12.0), - border_width=1.0, - num_rows=1, - num_cols=1, - use_cache=False, - sub_terrains={ - "waves": terrain_gen.HfWaveTerrainCfg(amplitude_range=(0.25, 0.25), num_waves=6), - }, -) - - -@configclass -class HeightfieldSceneCfg(InteractiveSceneCfg): - """Wave heightfield with a floating sensor body.""" - - terrain = TerrainImporterCfg( - prim_path="/World/ground", terrain_type="generator", terrain_generator=WAVE_TERRAIN_CFG - ) - - body = RigidObjectCfg( - prim_path="{ENV_REGEX_NS}/SensorBody", - spawn=sim_utils.CuboidCfg( - size=(0.4, 0.25, 0.1), - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), - mass_props=sim_utils.MassCfg(mass=1.0), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.9, 0.6, 0.1)), - ), - init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.5)), - ) - - raycast = NewtonRaycastSensorCfg( - prim_path="{ENV_REGEX_NS}/SensorBody", - pattern_cfg=GridPatternCfg(resolution=0.25, size=(1.5, 1.0)), - ray_alignment="base", - global_world_only=True, - max_distance=10.0, - debug_vis=True, - ) - - -def _newton_gl_viewer(sim: sim_utils.SimulationContext): - """Return the Newton GL viewer when the newton visualizer is active.""" - from isaaclab_visualizers.newton import NewtonVisualizer - - for viz in getattr(sim, "_visualizers", []): - if isinstance(viz, NewtonVisualizer): - return viz._viewer - return None - - -def log_ray_lines(viewer, sensor: NewtonRaycastSensor, miss_length: float = 3.0): - """Draw the sensor rays in the viewer: red to the hit point, gray for misses.""" - starts = sensor.ray_starts_w.torch.reshape(-1, 3) - directions = sensor.ray_directions_w.torch.reshape(-1, 3) - hits = sensor.data.ray_hits_w.torch.reshape(-1, 3) - miss = torch.isinf(sensor.data.ray_distances.torch.reshape(-1, 1)) - ends = torch.where(miss, starts + directions * miss_length, hits) - colors = torch.where( - miss, - torch.tensor([0.5, 0.5, 0.5], device=starts.device), - torch.tensor([1.0, 0.15, 0.1], device=starts.device), - ) - to_wp = lambda t: wp.array(t.cpu().numpy().astype(np.float32), dtype=wp.vec3) # noqa: E731 - viewer.log_lines("/isaaclab/raycast/rays", to_wp(starts), to_wp(ends), to_wp(colors)) - - -def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): - """Fly the sensor body over the terrain and visualize the rays.""" - body: RigidObject = scene["body"] - sensor: NewtonRaycastSensor = scene["raycast"] - viewer = _newton_gl_viewer(sim) - - sim_dt = sim.get_physics_dt() - zero_vel = torch.zeros(1, 6, device=sim.device) - t = 0.0 - while sim.is_headless_or_exist_active_visualizer(): - # Circle above the terrain while bobbing, pitching, rolling, and yawing. - angle = 0.4 * t - pos = torch.tensor( - [[3.0 * math.cos(angle), 3.0 * math.sin(angle), 1.4 + 0.3 * math.sin(0.9 * t)]], device=sim.device - ) - angles = torch.tensor([0.3 * math.sin(0.7 * t), 0.25 * math.sin(1.1 * t), angle + math.pi / 2.0]) - quat = math_utils.quat_from_euler_xyz(*(a.unsqueeze(0) for a in angles.to(sim.device))) - body.write_root_pose_to_sim_index(root_pose=torch.cat([pos, quat], dim=-1)) - body.write_root_velocity_to_sim_index(root_velocity=zero_vel) - scene.write_data_to_sim() - - sim.step() - scene.update(sim_dt) - if viewer is not None: - log_ray_lines(viewer, sensor) - t += sim_dt - - -def main(): - """Main function.""" - with launch_simulation(cfg=NewtonCfg(solver_cfg=MJWarpSolverCfg()), launcher_args=args_cli) as physics_cfg: - sim_cfg = sim_utils.SimulationCfg(dt=1 / 100, device=args_cli.device, physics=physics_cfg) - sim = sim_utils.SimulationContext(sim_cfg) - sim.set_camera_view(eye=[7.0, 7.0, 5.0], target=[0.0, 0.0, 0.0]) - scene = InteractiveScene(HeightfieldSceneCfg(num_envs=1, env_spacing=1.0)) - sim.reset() - print("[INFO]: Setup complete...") - run_simulator(sim, scene) - - -if __name__ == "__main__": - main() diff --git a/scripts/demos/sensors/newton_raycast_moving_geometry.py b/scripts/demos/sensors/newton_raycast_moving_geometry.py deleted file mode 100644 index 7940c1200b6d..000000000000 --- a/scripts/demos/sensors/newton_raycast_moving_geometry.py +++ /dev/null @@ -1,194 +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 - -"""Demo: Newton BVH ray-cast sensor over moving geometry (live BVH refit). - -A slowly spinning grid ray-cast sensor hovers above a scene where boxes keep -falling and a long bar sweeps around kinematically. Every hit tracks the moving -bodies because the scene BVH is refit each step inside the shared CUDA graph. -Rays are drawn live in the Newton viewer — red where they hit, gray where they -miss. - -.. code-block:: bash - - ./isaaclab.sh -p scripts/demos/sensors/newton_raycast_moving_geometry.py - -""" - -"""Parse CLI first so we can decide whether to launch Isaac Sim Kit.""" - -import argparse - -from isaaclab.app import add_launcher_args, launch_simulation - -parser = argparse.ArgumentParser( - description="Newton BVH ray-cast sensor over moving geometry.", - conflict_handler="resolve", -) -add_launcher_args(parser) -parser.set_defaults(visualizer=["newton"]) -args_cli = parser.parse_args() - -import math - -import numpy as np -import torch -import warp as wp -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg -from isaaclab_newton.sensors import NewtonRaycastSensor, NewtonRaycastSensorCfg - -import isaaclab.sim as sim_utils -import isaaclab.utils.math as math_utils -from isaaclab.assets import RigidObject, RigidObjectCfg -from isaaclab.scene import InteractiveScene, InteractiveSceneCfg -from isaaclab.sensors.ray_caster.patterns import GridPatternCfg -from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass - -BOX_DROP_POSITIONS = [(1.0, 0.6, 3.0), (-0.8, -1.0, 3.5), (0.2, -1.2, 4.0)] - - -def _falling_box_cfg(index: int) -> RigidObjectCfg: - return RigidObjectCfg( - prim_path=f"{{ENV_REGEX_NS}}/Box_{index}", - spawn=sim_utils.CuboidCfg( - size=(0.5, 0.5, 0.5), - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), - mass_props=sim_utils.MassCfg(mass=1.0), - collision_props=sim_utils.UsdPhysicsCollisionCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.4 + 0.2 * index, 0.9 - 0.3 * index)), - ), - init_state=RigidObjectCfg.InitialStateCfg(pos=BOX_DROP_POSITIONS[index]), - ) - - -@configclass -class MovingGeometrySceneCfg(InteractiveSceneCfg): - """Ground plane, falling boxes, a sweeping bar, and a hovering sensor.""" - - terrain = TerrainImporterCfg(prim_path="/World/ground", terrain_type="plane") - - box_0 = _falling_box_cfg(0) - box_1 = _falling_box_cfg(1) - box_2 = _falling_box_cfg(2) - - bar = RigidObjectCfg( - prim_path="{ENV_REGEX_NS}/Bar", - spawn=sim_utils.CuboidCfg( - size=(3.5, 0.3, 0.3), - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), - mass_props=sim_utils.MassCfg(mass=1.0), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.9, 0.2, 0.5)), - ), - init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 0.8)), - ) - - body = RigidObjectCfg( - prim_path="{ENV_REGEX_NS}/SensorBody", - spawn=sim_utils.CuboidCfg( - size=(0.3, 0.3, 0.1), - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), - mass_props=sim_utils.MassCfg(mass=1.0), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.9, 0.6, 0.1)), - ), - init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 2.5)), - ) - - raycast = NewtonRaycastSensorCfg( - prim_path="{ENV_REGEX_NS}/SensorBody", - # Start the rays below the carrier body so they do not hit it. - offset=NewtonRaycastSensorCfg.OffsetCfg(pos=(0.0, 0.0, -0.1)), - pattern_cfg=GridPatternCfg(resolution=0.25, size=(3.0, 3.0)), - ray_alignment="yaw", - max_distance=10.0, - debug_vis=True, - ) - - -def _newton_gl_viewer(sim: sim_utils.SimulationContext): - """Return the Newton GL viewer when the newton visualizer is active.""" - from isaaclab_visualizers.newton import NewtonVisualizer - - for viz in getattr(sim, "_visualizers", []): - if isinstance(viz, NewtonVisualizer): - return viz._viewer - return None - - -def log_ray_lines(viewer, sensor: NewtonRaycastSensor, miss_length: float = 3.0): - """Draw the sensor rays in the viewer: red to the hit point, gray for misses.""" - starts = sensor.ray_starts_w.torch.reshape(-1, 3) - directions = sensor.ray_directions_w.torch.reshape(-1, 3) - hits = sensor.data.ray_hits_w.torch.reshape(-1, 3) - miss = torch.isinf(sensor.data.ray_distances.torch.reshape(-1, 1)) - ends = torch.where(miss, starts + directions * miss_length, hits) - colors = torch.where( - miss, - torch.tensor([0.5, 0.5, 0.5], device=starts.device), - torch.tensor([1.0, 0.15, 0.1], device=starts.device), - ) - to_wp = lambda t: wp.array(t.cpu().numpy().astype(np.float32), dtype=wp.vec3) # noqa: E731 - viewer.log_lines("/isaaclab/raycast/rays", to_wp(starts), to_wp(ends), to_wp(colors)) - - -def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): - """Spin the sensor, sweep the bar, and keep dropping boxes through the rays.""" - boxes: list[RigidObject] = [scene[f"box_{i}"] for i in range(len(BOX_DROP_POSITIONS))] - bar: RigidObject = scene["bar"] - body: RigidObject = scene["body"] - sensor: NewtonRaycastSensor = scene["raycast"] - viewer = _newton_gl_viewer(sim) - - sim_dt = sim.get_physics_dt() - zero_vel = torch.zeros(1, 6, device=sim.device) - zero_angle = torch.zeros(1, device=sim.device) - count = 0 - while sim.is_headless_or_exist_active_visualizer(): - # Re-drop the boxes periodically so geometry keeps moving through the rays. - if count % 400 == 0: - for box, drop_pos in zip(boxes, BOX_DROP_POSITIONS): - pose = torch.tensor([[*drop_pos, 0.3, 0.3, 0.0, 0.9]], device=sim.device) - pose[:, 3:] /= torch.linalg.norm(pose[:, 3:]) - box.write_root_pose_to_sim_index(root_pose=pose) - box.write_root_velocity_to_sim_index(root_velocity=zero_vel) - box.reset() - - t = count * sim_dt - # Kinematically sweep the bar and spin the sensor body about z. - bar_quat = math_utils.quat_from_euler_xyz(zero_angle, zero_angle, zero_angle + 0.8 * t) - bar_pos = torch.tensor([[0.0, 0.0, 0.8]], device=sim.device) - bar.write_root_pose_to_sim_index(root_pose=torch.cat([bar_pos, bar_quat], dim=-1)) - bar.write_root_velocity_to_sim_index(root_velocity=zero_vel) - - body_quat = math_utils.quat_from_euler_xyz(zero_angle, zero_angle, zero_angle - 0.3 * t) - body_pos = torch.tensor( - [[0.6 * math.cos(0.5 * t), 0.6 * math.sin(0.5 * t), 2.5]], - device=sim.device, - ) - body.write_root_pose_to_sim_index(root_pose=torch.cat([body_pos, body_quat], dim=-1)) - body.write_root_velocity_to_sim_index(root_velocity=zero_vel) - scene.write_data_to_sim() - - sim.step() - count += 1 - scene.update(sim_dt) - if viewer is not None: - log_ray_lines(viewer, sensor) - - -def main(): - """Main function.""" - with launch_simulation(cfg=NewtonCfg(solver_cfg=MJWarpSolverCfg()), launcher_args=args_cli) as physics_cfg: - sim_cfg = sim_utils.SimulationCfg(dt=1 / 100, device=args_cli.device, physics=physics_cfg) - sim = sim_utils.SimulationContext(sim_cfg) - sim.set_camera_view(eye=[6.0, 6.0, 4.5], target=[0.0, 0.0, 1.0]) - scene = InteractiveScene(MovingGeometrySceneCfg(num_envs=1, env_spacing=1.0)) - sim.reset() - print("[INFO]: Setup complete...") - run_simulator(sim, scene) - - -if __name__ == "__main__": - main() diff --git a/scripts/demos/sensors/ppisp_camera_ovrtx.py b/scripts/demos/sensors/ppisp_camera_ovrtx.py deleted file mode 100644 index 597aed18eeda..000000000000 --- a/scripts/demos/sensors/ppisp_camera_ovrtx.py +++ /dev/null @@ -1,557 +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 - -""" -This script demonstrates USD-authored PPISP on a Gaussian scene through the kit-less OVRTX renderer. - -.. code-block:: bash - - OVRTX_rtx_rtpt_gaussian_skipTonemapping_enabled=0 \ - ./env_isaaclab/bin/python scripts/demos/sensors/ppisp_camera_ovrtx.py \ - --input_scene /path/to/scene.usdz \ - --max_steps 60 \ - --num_envs 8 - -OVRTX must run kit-less: launch this script with ``uv run python``. -""" - -import argparse -import os -from typing import Any - -import matplotlib.pyplot as plt -import numpy as np -import torch -from isaaclab_newton.physics.mjwarp_manager_cfg import MJWarpSolverCfg -from isaaclab_newton.physics.newton_manager_cfg import NewtonCfg -from isaaclab_ppisp._demo_utils import ( - find_ppisp_camera_bindings, - format_available_ppisp_cameras, - order_ppisp_bindings_by_camera, -) -from isaaclab_ppisp.cfg import PpispCfg, ppisp_cfg_from_usd_camera - -from pxr import Usd, UsdGeom - -import isaaclab.sim as sim_utils -from isaaclab.assets import AssetBaseCfg, RigidObjectCfg -from isaaclab.scene import InteractiveScene, InteractiveSceneCfg -from isaaclab.sensors import Camera, CameraCfg -from isaaclab.utils import configclass -from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, retrieve_file_path - -DEFAULT_INPUT_SCENE = f"{ISAAC_NUCLEUS_DIR}/Samples/Scene_ParticleField/valiant_auto.usdz" - -parser = argparse.ArgumentParser(description="Kit-less OVRTX demo for USD-authored PPISP camera output.") -parser.add_argument( - "--input_scene", - type=str, - default=DEFAULT_INPUT_SCENE, - help="USD or USDZ scene containing the Gaussian PPISP setup.", -) -parser.add_argument( - "--camera_prim_path", - type=str, - default=None, - help="Optional camera prim path override. Omit to auto-select the first camera with PPISP attributes.", -) -parser.add_argument( - "--camera_time_code", - type=float, - default=0.0, - help="USD time code used to bake the selected camera pose into duplicated env cameras.", -) -parser.add_argument( - "--num_envs", - type=int, - default=1, - help="Number of duplicated input-scene envs to render in the tiled camera batch.", -) -parser.add_argument("--env_spacing", type=float, default=20.0, help="Spacing between duplicated input-scene envs.") -parser.add_argument("--image_width", type=int, default=320, help="Output image width.") -parser.add_argument( - "--image_height", - type=int, - default=None, - help="Output image height. Defaults to preserving the selected USD RenderProduct aspect ratio.", -) -parser.add_argument("--disable_fabric", action="store_true", help="Disable Fabric API and use USD instead.") -parser.add_argument("--device", type=str, default="cuda:0", help="Torch/Warp device to render into.") -parser.add_argument( - "--viz", - type=str, - choices=["none"], - default="none", - help="Accepted for CLI parity with the Kit demo. OVRTX runs kit-less, so only 'none' is supported.", -) -parser.add_argument( - "--warmup_steps", - type=int, - default=32, - help="Simulation/render steps to run before saving images.", -) -parser.add_argument("--max_steps", type=int, default=120, help="Maximum simulation steps before exiting.") -parser.add_argument("--save_interval", type=int, default=20, help="Interval, in steps, for saving comparison images.") -parser.add_argument( - "--ppisp_responsivity", - type=float, - default=None, - help="Override the USD-authored PPISP responsivity. If omitted, the scene-authored value is used.", -) -parser.add_argument( - "--output_dir", - type=str, - default=None, - help="Directory to write comparison images. Defaults to scripts/demos/sensors/output/ppisp_camera_ovrtx.", -) -parser.add_argument("--ovrtx_log_level", type=str, default="verbose", help="OVRTX carb log level.") -parser.add_argument("--ovrtx_log_file", type=str, default="/tmp/ovrtx_renderer.log", help="OVRTX log file path.") - -args_cli = parser.parse_args() -if "://" not in args_cli.input_scene: - args_cli.input_scene = os.path.abspath(os.path.expanduser(args_cli.input_scene)) - if not os.path.exists(args_cli.input_scene): - parser.error(f"--input_scene does not exist: {args_cli.input_scene}") -if args_cli.num_envs < 1: - parser.error("--num_envs must be at least 1.") -if args_cli.image_width < 1: - parser.error("--image_width must be at least 1.") -if args_cli.image_height is not None and args_cli.image_height < 1: - parser.error("--image_height must be at least 1.") -if args_cli.warmup_steps < 0: - parser.error("--warmup_steps must be non-negative.") -if args_cli.max_steps < 1: - parser.error("--max_steps must be at least 1.") -if args_cli.save_interval < 1: - parser.error("--save_interval must be at least 1.") - - -@configclass -class PpispCameraOvrtxSceneCfg(InteractiveSceneCfg): - """Minimal scene cfg that references the input USD under each env.""" - - env_spacing: float = 20.0 - - input_scene = AssetBaseCfg( - prim_path="{ENV_REGEX_NS}/Scene", - spawn=sim_utils.UsdFileCfg(usd_path=""), - ) - - anchor = RigidObjectCfg( - prim_path="{ENV_REGEX_NS}/Anchor", - spawn=sim_utils.CuboidCfg( - size=(0.01, 0.01, 0.01), - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), - mass_props=sim_utils.MassCfg(mass=0.001), - collision_props=sim_utils.UsdPhysicsCollisionCfg(), - physics_material=sim_utils.RigidBodyMaterialCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 0.0, 0.0)), - ), - init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, -100.0)), - ) - - -def make_renderer_cfg() -> Any: - """Create the OVRTX camera renderer cfg.""" - try: - from isaaclab_ov.renderers import OVRTXRendererCfg - except ModuleNotFoundError as exc: - raise ModuleNotFoundError( - "ppisp_camera_ovrtx.py requires the optional OVRTX renderer stack. " - "Run it from an environment with isaaclab_ov and ovrtx installed." - ) from exc - - return OVRTXRendererCfg( - log_level=args_cli.ovrtx_log_level, - log_file_path=args_cli.ovrtx_log_file, - ) - - -def make_sim_cfg() -> sim_utils.SimulationCfg: - """Create the kit-less Newton simulation cfg required by OVRTX.""" - return sim_utils.SimulationCfg( - dt=0.005, - device=args_cli.device, - physics=NewtonCfg(solver_cfg=MJWarpSolverCfg(), num_substeps=1), - use_fabric=not args_cli.disable_fabric, - ) - - -def resolve_source_camera_binding(source_stage: Usd.Stage) -> tuple[str, Usd.Prim | None, Usd.Prim]: - """Resolve the source camera and PPISP camera binding from CLI or source stage metadata.""" - ppisp_bindings = order_ppisp_bindings_by_camera(source_stage, find_ppisp_camera_bindings(source_stage)) - if not ppisp_bindings: - raise RuntimeError("No cameras with PPISP camera attributes found in input scene.") - - if args_cli.camera_prim_path is not None: - camera_prim_path = args_cli.camera_prim_path - if not camera_prim_path.startswith("/"): - camera_prim_path = f"/{camera_prim_path}" - else: - camera_prim_path = ppisp_bindings[0][0] - print(f"[INFO] Auto-selected camera prim: {camera_prim_path}", flush=True) - - camera_prim = source_stage.GetPrimAtPath(camera_prim_path) - if not camera_prim or not camera_prim.IsValid(): - available = format_available_ppisp_cameras(ppisp_bindings) - raise RuntimeError( - f"Camera prim not found: {camera_prim_path}\n" - "Omit --camera_prim_path to auto-select a camera with PPISP attributes, or use one of:\n" - f" {available}" - ) - if camera_prim.GetTypeName() != "Camera": - available = format_available_ppisp_cameras(ppisp_bindings) - raise RuntimeError( - f"Prim is not a Camera: {camera_prim_path} ({camera_prim.GetTypeName()})\n" - "Omit --camera_prim_path to auto-select a camera with PPISP attributes, or use one of:\n" - f" {available}" - ) - - for binding in ppisp_bindings: - if binding[0] == camera_prim_path: - return binding - - available = format_available_ppisp_cameras(ppisp_bindings) - raise RuntimeError( - f"Selected camera has no PPISP camera attributes: {camera_prim_path}\n" - "Omit --camera_prim_path to auto-select a camera with PPISP attributes, or use one of:\n" - f" {available}" - ) - - -def source_camera_path_to_default_rel_path(source_stage: Usd.Stage, source_camera_prim_path: str) -> str: - """Return the source camera path relative to the source defaultPrim.""" - default_prim = source_stage.GetDefaultPrim() - if not default_prim: - raise RuntimeError("Input scene must have a defaultPrim so it can be referenced under each env.") - - default_prim_path = default_prim.GetPath().pathString - default_prefix = f"{default_prim_path}/" - if not source_camera_prim_path.startswith(default_prefix): - raise RuntimeError( - f"Camera path {source_camera_prim_path} is not under source defaultPrim {default_prim_path}." - ) - return source_camera_prim_path[len(default_prefix) :] - - -def source_camera_path_to_env_regex(source_stage: Usd.Stage, source_camera_prim_path: str) -> str: - """Map a source camera path to the duplicated-env camera regex.""" - camera_rel_path = source_camera_path_to_default_rel_path(source_stage, source_camera_prim_path) - return f"/World/envs/env_.*/Scene/{camera_rel_path}" - - -def bake_source_camera_pose_to_envs(source_stage: Usd.Stage, source_camera_prim_path: str) -> None: - """Bake the selected USD camera pose at ``camera_time_code`` into duplicated env camera prims.""" - default_prim = source_stage.GetDefaultPrim() - if not default_prim: - raise RuntimeError("Input scene must have a defaultPrim so it can be referenced under each env.") - - source_camera_prim = source_stage.GetPrimAtPath(source_camera_prim_path) - if not source_camera_prim or not source_camera_prim.IsValid(): - raise RuntimeError(f"Camera prim not found: {source_camera_prim_path}") - - time_code = Usd.TimeCode(args_cli.camera_time_code) - source_cache = UsdGeom.XformCache(time_code) - source_default_world = source_cache.GetLocalToWorldTransform(default_prim) - source_camera_world = source_cache.GetLocalToWorldTransform(source_camera_prim) - source_camera_in_default = source_camera_world * source_default_world.GetInverse() - - stage = sim_utils.get_current_stage() - target_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) - camera_rel_path = source_camera_path_to_default_rel_path(source_stage, source_camera_prim_path) - scene_prims = sim_utils.find_matching_prims("/World/envs/env_.*/Scene", stage) - if not scene_prims: - raise RuntimeError("No duplicated scene prims found under /World/envs.") - - authored_count = 0 - for scene_prim in scene_prims: - scene_path = scene_prim.GetPath().pathString - target_camera_path = f"{scene_path}/{camera_rel_path}" - target_camera_prim = stage.GetPrimAtPath(target_camera_path) - if not target_camera_prim or not target_camera_prim.IsValid(): - raise RuntimeError(f"Duplicated camera prim not found: {target_camera_path}") - - target_scene_world = target_cache.GetLocalToWorldTransform(scene_prim) - target_parent_world = target_cache.GetLocalToWorldTransform(target_camera_prim.GetParent()) - target_camera_world = source_camera_in_default * target_scene_world - target_camera_local = target_camera_world * target_parent_world.GetInverse() - target_camera_local.Orthonormalize() - - xformable = UsdGeom.Xformable(target_camera_prim) - xformable.ClearXformOpOrder() - xform_op = xformable.AddTransformOp(UsdGeom.XformOp.PrecisionDouble, "ppispCameraPose") - xform_op.Set(target_camera_local, Usd.TimeCode.Default()) - xformable.SetXformOpOrder([xform_op]) - authored_count += 1 - - print( - f"[INFO] Baked camera pose at USD time {args_cli.camera_time_code:g} into {authored_count} env camera(s).", - flush=True, - ) - - -def get_render_product_resolution(render_product_prim: Usd.Prim | None) -> tuple[int, int] | None: - """Return ``(width, height)`` from a RenderProduct ``resolution`` attribute.""" - if render_product_prim is None: - return None - resolution_attr = render_product_prim.GetAttribute("resolution") - if not resolution_attr: - return None - resolution = resolution_attr.Get() - if resolution is None or len(resolution) != 2: - return None - return int(resolution[0]), int(resolution[1]) - - -def resolve_image_shape(render_product_prim: Usd.Prim | None) -> tuple[int, int]: - """Resolve demo output ``(width, height)`` preserving source aspect when height is omitted.""" - width = args_cli.image_width - height = args_cli.image_height - if height is not None: - return width, height - - source_resolution = get_render_product_resolution(render_product_prim) - if source_resolution is None: - return width, width - - source_width, source_height = source_resolution - height = max(1, round(width * source_height / source_width)) - return width, height - - -def make_ppisp_cfg(camera_prim: Usd.Prim, num_ppisp_bindings: int) -> PpispCfg: - """Parse the selected source PPISP camera into an explicit cfg for duplicated envs.""" - ppisp_cfg = ppisp_cfg_from_usd_camera(camera_prim) - ppisp_cfg.camera_prim_path = None - if args_cli.ppisp_responsivity is None: - print(f"[INFO] Using USD-authored PPISP values from {num_ppisp_bindings} PPISP camera(s).", flush=True) - else: - ppisp_cfg.inputs["responsivity"] = float(args_cli.ppisp_responsivity) - print( - f"[INFO] Applied PPISP responsivity={args_cli.ppisp_responsivity:g} to duplicated env PPISP cfg.", - flush=True, - ) - return ppisp_cfg - - -def create_duplicated_env_scene() -> InteractiveScene: - """Create a production-style duplicated-env scene for tiled camera rendering.""" - scene_cfg = PpispCameraOvrtxSceneCfg(num_envs=args_cli.num_envs, env_spacing=args_cli.env_spacing) - scene_cfg.input_scene.spawn = sim_utils.UsdFileCfg(usd_path=args_cli.input_scene) - scene = InteractiveScene(scene_cfg) - print(f"[INFO] Referenced input scene into {args_cli.num_envs} env(s).", flush=True) - return scene - - -def make_matched_camera_prims_visible(stage: Usd.Stage, camera_prim_path: str) -> None: - """Make duplicated camera prims visible for OVRTX render product discovery.""" - for prim in sim_utils.find_matching_prims(camera_prim_path, stage): - UsdGeom.Imageable(prim).MakeVisible() - - -def make_camera( - camera_prim_path: str, - *, - ppisp_cfg: PpispCfg | None, - width: int, - height: int, -) -> Camera: - """Create a baseline or PPISP camera sensor for the duplicated-env camera batch.""" - return Camera( - CameraCfg( - prim_path=camera_prim_path, - update_period=0.0, - height=height, - width=width, - data_types=["rgb"], - spawn=None, - isp_cfg=ppisp_cfg, - renderer_cfg=make_renderer_cfg(), - ) - ) - - -def save_images_grid( - images: list[torch.Tensor], - nrow: int = 1, - subtitles: list[str] | None = None, - title: str | None = None, - filename: str | None = None, -) -> None: - """Save images in a grid with optional subtitles and title.""" - n_images = len(images) - ncol = int(np.ceil(n_images / nrow)) - - fig, axes = plt.subplots(nrow, ncol, figsize=(ncol * 3, nrow * 3)) - if isinstance(axes, np.ndarray): - axes = axes.flatten() - else: - axes = np.array([axes]) - - for idx, (img, ax) in enumerate(zip(images, axes)): - ax.imshow(img.detach().cpu().clamp(0.0, 1.0).numpy()) - ax.axis("off") - if subtitles: - ax.set_title(subtitles[idx]) - for ax in axes[n_images:]: - fig.delaxes(ax) - if title: - plt.suptitle(title) - plt.tight_layout() - if filename: - os.makedirs(os.path.dirname(filename), exist_ok=True) - plt.savefig(filename) - plt.close() - - -def make_tiled_image(images: torch.Tensor) -> torch.Tensor: - """Stack a camera batch vertically into one image.""" - return torch.cat([image for image in images], dim=0) - - -def save_tensor_image(image: torch.Tensor, filename: str) -> None: - """Save a tensor image in [0, 1] without axes, titles, or layout scaling.""" - os.makedirs(os.path.dirname(filename), exist_ok=True) - plt.imsave(filename, image.detach().cpu().clamp(0.0, 1.0).numpy()) - - -def corner_center_ratio(rgb: torch.Tensor) -> float: - """Return the average corner brightness divided by center brightness for one RGB image.""" - h, w = rgb.shape[:2] - patch = max(4, min(h, w) // 8) - cy, cx = h // 2 - patch // 2, w // 2 - patch // 2 - center = rgb[cy : cy + patch, cx : cx + patch, :3].float().mean() - corners = torch.stack( - [ - rgb[:patch, :patch, :3].float().mean(), - rgb[:patch, -patch:, :3].float().mean(), - rgb[-patch:, :patch, :3].float().mean(), - rgb[-patch:, -patch:, :3].float().mean(), - ] - ).mean() - return (corners / center.clamp_min(1.0)).item() - - -def run_simulator(sim: sim_utils.SimulationContext, baseline_camera: Camera, ppisp_camera: Camera) -> None: - """Run the simulator and periodically save baseline-vs-PPISP images.""" - sim_dt = sim.get_physics_dt() - output_dir = args_cli.output_dir - if output_dir is None: - output_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "output", "ppisp_camera_ovrtx") - os.makedirs(output_dir, exist_ok=True) - - if args_cli.warmup_steps > 0: - print(f"[INFO] Running {args_cli.warmup_steps} warmup step(s) before saving images.", flush=True) - for _ in range(args_cli.warmup_steps): - sim.step() - baseline_camera.update(sim_dt, force_recompute=True) - ppisp_camera.update(sim_dt, force_recompute=True) - - reported_shape = False - for count in range(1, args_cli.max_steps + 1): - sim.step() - baseline_camera.update(sim_dt, force_recompute=True) - ppisp_camera.update(sim_dt, force_recompute=True) - - if count % args_cli.save_interval == 0: - baseline = baseline_camera.data.output["rgb"][..., :3] - ppisp = ppisp_camera.data.output["rgb"][..., :3] - diff = (ppisp.float() - baseline.float()).abs() / 255.0 - if not reported_shape: - print(f"[INFO] camera batch rgb shape={tuple(ppisp.shape)}", flush=True) - reported_shape = True - mean_abs_delta = diff.mean().item() * 255.0 - ratios = [corner_center_ratio(ppisp[i]) for i in range(ppisp.shape[0])] - ratio = sum(ratios) / len(ratios) - per_env_delta = diff.mean(dim=(1, 2, 3)) * 255.0 - per_env_ppisp_mean = ppisp.float().mean(dim=(1, 2, 3)) - print( - f"[INFO] step={count} mean_abs_delta={mean_abs_delta:.2f} mean_ppisp_corner_center_ratio={ratio:.3f}", - flush=True, - ) - print( - "[INFO] per-env mean_abs_delta=" - + ", ".join(f"{value:.2f}" for value in per_env_delta.detach().cpu().tolist()), - flush=True, - ) - print( - "[INFO] per-env ppisp_mean=" - + ", ".join(f"{value:.2f}" for value in per_env_ppisp_mean.detach().cpu().tolist()), - flush=True, - ) - images = [] - subtitles = [] - for env_id in range(ppisp.shape[0]): - images.extend( - [ - baseline[env_id].float() / 255.0, - ppisp[env_id].float() / 255.0, - diff[env_id], - ] - ) - subtitles.extend([f"env {env_id} baseline", f"env {env_id} PPISP", f"env {env_id} diff"]) - save_images_grid( - images, - nrow=ppisp.shape[0], - subtitles=subtitles, - title="USD-authored PPISP on duplicated Gaussian scene envs through OVRTX", - filename=os.path.join(output_dir, f"ppisp_camera_ovrtx_{count:04d}.png"), - ) - save_tensor_image( - make_tiled_image(baseline.float() / 255.0), - os.path.join(output_dir, f"ppisp_camera_ovrtx_{count:04d}_baseline_tiled.png"), - ) - save_tensor_image( - make_tiled_image(ppisp.float() / 255.0), - os.path.join(output_dir, f"ppisp_camera_ovrtx_{count:04d}_ppisp_tiled.png"), - ) - save_tensor_image( - make_tiled_image(diff), - os.path.join(output_dir, f"ppisp_camera_ovrtx_{count:04d}_diff_tiled.png"), - ) - - -def main() -> None: - """Main function.""" - args_cli.input_scene = retrieve_file_path(args_cli.input_scene) - source_stage = Usd.Stage.Open(args_cli.input_scene) - if source_stage is None: - raise RuntimeError(f"Failed to open input scene: {args_cli.input_scene}") - source_camera_prim_path, render_product_prim, ppisp_camera_prim = resolve_source_camera_binding(source_stage) - ppisp_cfg = make_ppisp_cfg(ppisp_camera_prim, len(find_ppisp_camera_bindings(source_stage))) - camera_prim_path = source_camera_path_to_env_regex(source_stage, source_camera_prim_path) - width, height = resolve_image_shape(render_product_prim) - - sim_utils.create_new_stage() - sim_cfg = make_sim_cfg() - sim = sim_utils.SimulationContext(sim_cfg) - - scene = create_duplicated_env_scene() - bake_source_camera_pose_to_envs(source_stage, source_camera_prim_path) - make_matched_camera_prims_visible(sim_utils.get_current_stage(), camera_prim_path) - ppisp_camera = make_camera( - camera_prim_path, - ppisp_cfg=ppisp_cfg, - width=width, - height=height, - ) - baseline_camera = make_camera(camera_prim_path, ppisp_cfg=None, width=width, height=height) - print(f"[INFO] Duplicated-env camera regex: {camera_prim_path}", flush=True) - print(f"[INFO] Rendering {width}x{height} from source camera {source_camera_prim_path}.", flush=True) - - try: - sim.reset() - print("[INFO]: Setup complete. Saving comparison images during simulation.", flush=True) - run_simulator(sim, baseline_camera, ppisp_camera) - finally: - del ppisp_camera - del baseline_camera - del scene - sim.stop() - sim.clear_instance() - - -if __name__ == "__main__": - main() diff --git a/source/isaaclab/changelog.d/package-demos.major.rst b/source/isaaclab/changelog.d/package-demos.major.rst new file mode 100644 index 000000000000..a9e07cc2a199 --- /dev/null +++ b/source/isaaclab/changelog.d/package-demos.major.rst @@ -0,0 +1,42 @@ +Added +^^^^^ + +* Added curated showcases through ``isaaclab demo `` and focused standalone programs through + ``isaaclab example ``. Both catalogs are included in the released wheel and can be run with ``uvx``. +* Added the ``zoo`` demo, which animates several robot families and rigid objects in one deterministic scene. +* Added a Newton GL program selector that puts curated demos ahead of focused examples. +* Reused the Isaac Lab terminal startup screen for packaged demos and examples. +* Added ``--max_steps`` to the ``arl-robot-1``, ``bin-packing``, ``deformables``, ``markers``, ``multi-asset``, + ``procedural-terrain``, ``multi-mesh-ray-caster``, and ``visual-color-randomization`` examples so every packaged + program can stop after a fixed number of steps. +* Added :meth:`~isaaclab.sim.SimulationContext.add_reset_callback` and + :meth:`~isaaclab.sim.SimulationContext.remove_reset_callback` for callbacks that must be registered before a + script creates its simulation context. + +Changed +^^^^^^^ + +* **Breaking:** Moved maintained programs from ``scripts/demos`` into the repository-level ``examples`` directory, + with showcases in ``examples/demos`` and focused programs in topic directories such as ``examples/mpm``. Use + ``isaaclab demo `` or ``isaaclab example `` instead of direct script paths. +* **Breaking:** Reclassified the following technical programs as examples without changing their public names: + ``arl-robot-1``, ``bin-packing``, ``cables``, ``deformables``, ``haply-teleoperation``, ``heterogeneous-scene``, + ``markers``, ``multi-asset``, ``newton-dominoes``, ``procedural-terrain``, ``visual-color-randomization``, + ``mpm-granular``, ``mpm-two-way-coupling``, ``camera``, ``contact-sensor``, ``frame-transformer``, ``imu``, + ``multi-mesh-ray-caster``, ``multi-mesh-ray-caster-camera``, ``ppisp-camera``, ``pva``, ``ray-caster``, and + ``tactile-sensor``. Replace direct ``scripts/demos`` invocations with ``isaaclab example ``. +* Changed ``isaaclab demo h1-locomotion`` to run on Newton with the Newton GL viewer by default. Robots are driven + with I/J/K/L, selected with N, and followed with C. Pass ``--physics isaacsim_physx`` for PhysX. +* Changed ``isaaclab demo`` and ``isaaclab example`` to compile Warp kernels without backward passes, since + packaged programs only run inference. +* **Breaking:** Consolidated ``scripts/demos/sensors/newton_raycast_heightfield.py`` and + ``scripts/demos/sensors/newton_raycast_moving_geometry.py`` as + ``isaaclab example newton-raycast --scene {heightfield,moving-geometry}``. + +Removed +^^^^^^^ + +* **Breaking:** Replaced the separate ``scripts/demos/arms.py``, ``bipeds.py``, ``hands.py``, ``quadcopter.py``, and + ``quadrupeds.py`` galleries with ``isaaclab demo zoo``. +* **Breaking:** Removed the temporary ``ppisp_camera_ovrtx.py`` QA script. Use + ``isaaclab example ppisp-camera --renderer newton_renderer`` instead. diff --git a/source/isaaclab/docs/README.md b/source/isaaclab/docs/README.md index f5ded99b0477..e481d3ae7274 100644 --- a/source/isaaclab/docs/README.md +++ b/source/isaaclab/docs/README.md @@ -7,5 +7,4 @@ features such as augmenting simulators with non-ideal actuator models, managing settings, integrate different sensors, as well as provide interfaces to features that are currently not available in Isaac Sim but are available from the physics side (such as deformable bodies). -We recommend the users to try out the demo scripts present in `scripts/demos` that display how different parts -of the framework can be integrated together. +Run `isaaclab demo list` for polished showcases or `isaaclab example list` for focused standalone programs. diff --git a/source/isaaclab/isaaclab/cli/__init__.py b/source/isaaclab/isaaclab/cli/__init__.py index dcb6ea9e9bf6..2a2eeda4690d 100644 --- a/source/isaaclab/isaaclab/cli/__init__.py +++ b/source/isaaclab/isaaclab/cli/__init__.py @@ -111,6 +111,28 @@ def list_envs(args: list[str] | None = None) -> None: command_list_envs(args) +def demo(args: list[str] | None = None) -> None: + """List or run a packaged Isaac Lab demo. + + Args: + args: Command-line arguments. Uses ``sys.argv`` when omitted. + """ + from isaaclab.programs import DEMOS, run_program_cli + + run_program_cli("demo", DEMOS, args) + + +def example(args: list[str] | None = None) -> None: + """List or run a packaged Isaac Lab example. + + Args: + args: Command-line arguments. Uses ``sys.argv`` when omitted. + """ + from isaaclab.programs import EXAMPLES, run_program_cli + + run_program_cli("example", EXAMPLES, args) + + def teleop(args: list[str] | None = None) -> None: """Run a live teleoperation, demonstration recording, or demonstration replay workflow. @@ -165,6 +187,12 @@ def cli() -> None: if len(sys.argv) > 1 and sys.argv[1] == "list_envs": list_envs(sys.argv[2:]) return + if len(sys.argv) > 1 and sys.argv[1] == "demo": + demo(sys.argv[2:]) + return + if len(sys.argv) > 1 and sys.argv[1] == "example": + example(sys.argv[2:]) + return if len(sys.argv) > 1 and sys.argv[1] in subcommands: _load_external_tasks() subcommands[sys.argv[1]](sys.argv[2:]) @@ -184,6 +212,8 @@ def cli() -> None: " benchmark Run a runtime, startup, training, or play benchmark\n" " (append _multigpu to a workflow to run it across GPUs)\n" " microbenchmark Run a component micro-benchmark\n" + " demo List or run packaged demonstrations\n" + " example List or run packaged standalone examples\n" " leapp Export or deploy a policy with LEAPP\n" " list_envs List registered environments and presets\n" " train Train an RL policy\n" diff --git a/source/isaaclab/isaaclab/program_browser.py b/source/isaaclab/isaaclab/program_browser.py new file mode 100644 index 000000000000..9c6ade44f849 --- /dev/null +++ b/source/isaaclab/isaaclab/program_browser.py @@ -0,0 +1,76 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Newton GL selector for packaged demos and examples.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .programs import ProgramSpec + from .sim import SimulationContext + from .visualizers import BaseVisualizer + + +class ProgramBrowser: + """List packaged programs in Newton GL windows and remember the one the user picks. + + Picking a program closes the window, which ends the running program; the launcher then reads + :attr:`selected` to start the next one. + """ + + def __init__(self, catalogs: dict[str, tuple[ProgramSpec, ...]]) -> None: + """Initialize the browser. + + Args: + catalogs: Programs keyed by the CLI command that runs them, e.g. ``"demo"``. Programs + that cannot run with Newton GL or are missing optional modules are left out. + """ + self.selected: tuple[str, ProgramSpec] | None = None + """CLI command and program picked by the user, or None.""" + self._catalogs = { + command: tuple( + program for program in catalog if program.newton_gl_args is not None and not program.missing_modules() + ) + for command, catalog in catalogs.items() + } + self._attached: set[int] = set() + + def attach(self, sim: SimulationContext) -> None: + """Add the program list to every Newton GL window of a simulation. + + Args: + sim: Simulation whose visualizers receive the list. Windows that already show it are skipped. + """ + for visualizer in sim.visualizers: + if visualizer.cfg.visualizer_type == "newton_gl" and id(visualizer) not in self._attached: + self._attached.add(id(visualizer)) + visualizer.register_ui_callback(self._render_callback(visualizer), position="panel") + + def _render_callback(self, visualizer: BaseVisualizer) -> Callable[[Any], None]: + """Return the ImGui callback that draws the program list in one window.""" + + def render(imgui: Any) -> None: + imgui.set_next_item_open(True, imgui.Cond_.appearing) + if not imgui.collapsing_header("Isaac Lab Programs"): + return + for command, catalog in self._catalogs.items(): + if command == "demo": + imgui.set_next_item_open(True, imgui.Cond_.appearing) + if imgui.tree_node(command.title() + "s"): + for program in catalog: + clicked, _ = imgui.selectable( + f"{program.name.replace('-', ' ').title()}##{command}:{program.name}", False + ) + if clicked and self.selected is None: + self.selected = command, program + visualizer.request_close() + if imgui.is_item_hovered(): + imgui.set_tooltip(program.summary) + imgui.tree_pop() + + return render diff --git a/source/isaaclab/isaaclab/programs.py b/source/isaaclab/isaaclab/programs.py new file mode 100644 index 000000000000..4804f8f10ee2 --- /dev/null +++ b/source/isaaclab/isaaclab/programs.py @@ -0,0 +1,353 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Catalog and launcher for programs distributed with Isaac Lab.""" + +from __future__ import annotations + +import argparse +import os +import sys +from dataclasses import dataclass +from importlib.util import find_spec +from pathlib import Path +from types import ModuleType + +from .paths import ISAACLAB_ROOT + + +@dataclass(frozen=True) +class ProgramSpec: + """Metadata for one packaged program.""" + + name: str + relative_path: str + summary: str + extras: tuple[str, ...] = () + required_modules: tuple[str, ...] = () + newton_gl_args: tuple[str, ...] | None = () + + def uvx_command(self, command: str) -> str: + """Return the command for running the program from a released package.""" + if not self.extras: + return f"uvx isaaclab {command} {self.name}" + extras = ",".join(self.extras) + return f"uvx --from 'isaaclab[{extras}]' isaaclab {command} {self.name}" + + def missing_modules(self) -> tuple[str, ...]: + """Return required Python modules that are unavailable.""" + return tuple(module for module in self.required_modules if find_spec(module) is None) + + @property + def path(self) -> Path: + """Return the program path in a checkout or installed wheel.""" + directory, relative_path = self.relative_path.split("/", 1) + return _program_root(directory) / relative_path + + +def _program_root(directory: str) -> Path: + """Return the root containing executable programs.""" + installed_root = Path(__file__).resolve().parent / directory + if installed_root.is_dir(): + return installed_root + return ISAACLAB_ROOT / directory + + +def _run_script(path: Path) -> None: + """Run a script as ``__main__`` without replacing ``sys.argv[0]``.""" + module = ModuleType("__main__") + module.__file__ = str(path) + module.__package__ = None + module.__spec__ = None + original_main = sys.modules.get("__main__") + try: + sys.modules["__main__"] = module + exec(compile(path.read_bytes(), str(path), "exec"), module.__dict__) + finally: + if original_main is None: + sys.modules.pop("__main__", None) + else: + sys.modules["__main__"] = original_main + + +_RESET_CALLBACK_NAME = "isaaclab.programs" +"""Name of the simulation reset callback installed while a program runs.""" + +_ISAACSIM = {"extras": ("isaacsim",), "required_modules": ("isaacsim",)} +_TETRAHEDRALIZATION = {"extras": ("tetrahedralization",), "required_modules": ("pytetwild",)} +_TELEOP = {"extras": ("teleop",), "required_modules": ("isaaclab_teleop", "websockets")} + + +DEMOS = ( + ProgramSpec("zoo", "examples/demos/zoo.py", "Explore Isaac Lab robots and simulation features."), + ProgramSpec("h1-locomotion", "examples/demos/h1_locomotion.py", "Control a trained H1 locomotion policy."), + ProgramSpec( + "pick-and-place", + "examples/demos/pick_and_place.py", + "Interactively pick and place a cube.", + **_ISAACSIM, + newton_gl_args=None, + ), + ProgramSpec( + "newton-block-and-tackle", + "examples/demos/newton_viewer_block_and_tackle.py", + "Interact with a Newton VBD block-and-tackle scene.", + ), + ProgramSpec( + "snowball-smash", + "examples/demos/snowball_smash.py", + "Smash rigid crates with MPM snowballs.", + ), + ProgramSpec("teapot-fill", "examples/demos/teapot_fill.py", "Fill and pour a teapot with MPM fluid."), +) + + +EXAMPLES = ( + ProgramSpec( + "bin-packing", + "examples/bin_packing.py", + "Clone heterogeneous randomized bin layouts.", + **_ISAACSIM, + ), + ProgramSpec("cables", "examples/cables.py", "Simulate colliding cables with Newton VBD."), + ProgramSpec( + "deformables", + "examples/deformables.py", + "Compare deformable objects across backends.", + **_TETRAHEDRALIZATION, + newton_gl_args=("--physics", "newton_vbd"), + ), + ProgramSpec( + "heterogeneous-scene", + "examples/heterogeneous_scene.py", + "Compose heterogeneous task scenes.", + **_ISAACSIM, + ), + ProgramSpec("markers", "examples/markers.py", "Render reusable visualization markers.", **_ISAACSIM), + ProgramSpec("multi-asset", "examples/multi_asset.py", "Spawn different assets across cloned environments."), + ProgramSpec("newton-dominoes", "examples/newton_viewer_dominoes.py", "Interact with Newton XPBD dominoes."), + ProgramSpec( + "procedural-terrain", + "examples/procedural_terrain.py", + "Generate procedural terrain meshes.", + **_ISAACSIM, + ), + ProgramSpec( + "visual-color-randomization", + "examples/visual_color_randomization.py", + "Randomize visual materials on cloned assets.", + ), + ProgramSpec( + "mpm-granular", + "examples/mpm/newton_mpm_granular.py", + "Drop granular MPM material on obstacles.", + ), + ProgramSpec( + "mpm-two-way-coupling", + "examples/mpm/newton_mpm_twoway_coupling.py", + "Couple MPM sand with rigid bodies.", + ), + ProgramSpec( + "camera", + "examples/sensors/cameras.py", + "Capture data from several camera configurations.", + **_ISAACSIM, + ), + ProgramSpec( + "contact-sensor", + "examples/sensors/contact_sensor.py", + "Inspect robot contact measurements.", + ), + ProgramSpec( + "frame-transformer", + "examples/sensors/frame_transformer_sensor.py", + "Track transforms between robot frames.", + **_ISAACSIM, + ), + ProgramSpec( + "imu", + "examples/sensors/imu_sensor.py", + "Inspect inertial measurements.", + **_ISAACSIM, + ), + ProgramSpec( + "multi-mesh-ray-caster", + "examples/sensors/multi_mesh_raycaster.py", + "Cast rays against several dynamic meshes.", + ), + ProgramSpec( + "multi-mesh-ray-caster-camera", + "examples/sensors/multi_mesh_raycaster_camera.py", + "Render depth and normals with a multi-mesh ray caster.", + **_ISAACSIM, + ), + ProgramSpec( + "newton-raycast", + "examples/sensors/newton_raycast.py", + "Raycast against static or moving Newton geometry.", + ), + ProgramSpec( + "pva", + "examples/sensors/pva_sensor.py", + "Inspect pose, velocity, and acceleration data.", + **_ISAACSIM, + ), + ProgramSpec( + "ray-caster", + "examples/sensors/raycaster_sensor.py", + "Inspect a lidar-style ray caster.", + **_ISAACSIM, + ), + ProgramSpec( + "arl-robot-1", + "examples/arl_robot_1.py", + "Fly ARL Robot 1 with its position controller.", + **_ISAACSIM, + ), + ProgramSpec( + "haply-teleoperation", + "examples/haply_teleoperation.py", + "Teleoperate a Franka with Haply hardware.", + **_TELEOP, + newton_gl_args=None, + ), + ProgramSpec( + "ppisp-camera", + "examples/sensors/ppisp_camera.py", + "Compare PPISP camera renderers.", + **_ISAACSIM, + newton_gl_args=None, + ), + ProgramSpec( + "tactile-sensor", + "examples/sensors/tacsl_sensor.py", + "Inspect camera and force-field tactile data.", + **_ISAACSIM, + ), +) + + +def run_program(command: str, program: ProgramSpec, args: list[str] | None = None) -> None: + """Run a packaged program as its ``__main__`` module. + + Args: + command: CLI command that owns the program. + program: Program to run. + args: Arguments forwarded to the program. + + Raises: + FileNotFoundError: If the program script is unavailable. + ModuleNotFoundError: If an optional dependency is unavailable. + """ + missing_modules = program.missing_modules() + if missing_modules: + missing = ", ".join(missing_modules) + raise ModuleNotFoundError( + f"{command} {program.name!r} requires missing module(s): {missing}. Run: {program.uvx_command(command)}" + ) + path = program.path + if not path.is_file(): + raise FileNotFoundError(f"{command} {program.name!r} is not installed at {path}") + + import warp as wp + + # Programs only run inference. Warp fixes this option when each kernel module is imported, so set it + # before anything below imports one; it also matches the kernel cache keys produced by the test suites. + wp.config.enable_backward = False + + from .program_browser import ProgramBrowser + + browser = ProgramBrowser({"demo": DEMOS, "example": EXAMPLES}) + original_argv = sys.argv + try: + forwarded_args = list(args or []) + sys.argv = [f"isaaclab {command} {program.name}", *forwarded_args] + if any(arg in ("-h", "--help") for arg in forwarded_args): + _run_script(path) + else: + from .app.loading_screen import LoadingScreen + from .sim import SimulationContext + + verbose = any(arg in ("--info", "--verbose") for arg in forwarded_args) + with LoadingScreen(1, enabled=False if verbose else None) as screen: + screen.summary( + f"Isaac Lab · {command}", + {"Program": program.name, "Description": program.summary}, + ) + screen.stage("Launching simulation") + + def on_reset(sim: SimulationContext) -> None: + # Hand the console to the program once its simulation is ready. + screen.close() + browser.attach(sim) + + SimulationContext.add_reset_callback(_RESET_CALLBACK_NAME, on_reset) + try: + _run_script(path) + finally: + SimulationContext.remove_reset_callback(_RESET_CALLBACK_NAME) + screen.close() + finally: + sys.argv = original_argv + if browser.selected is not None: + next_command, next_program = browser.selected + sys.stdout.flush() + sys.stderr.flush() + os.execv( + sys.executable, + [ + sys.executable, + "-m", + "isaaclab", + next_command, + next_program.name, + *(next_program.newton_gl_args or ()), + "--viz", + "newton_gl", + ], + ) + + +def run_program_cli(command: str, catalog: tuple[ProgramSpec, ...], args: list[str] | None = None) -> None: + """List or run programs from a catalog. + + Args: + command: Singular CLI command name. + catalog: Programs available through the command. + args: Command-line arguments. Uses ``sys.argv`` when omitted. + """ + parser = argparse.ArgumentParser( + description=f"Run a packaged Isaac Lab {command}.", + prog=f"{Path(sys.argv[0]).name} {command}", + ) + parser.add_argument("name", nargs="?", help=f"{command.title()} name, or 'list' to show the catalog.") + if args is None: + args = sys.argv[1:] + if args and args[0] in ("-h", "--help"): + parser.parse_args(args) + parsed_args = parser.parse_args(args[:1]) + + if parsed_args.name in (None, "list"): + if len(args) > 1: + parser.error("the list command does not accept additional arguments") + name_width = max(len(program.name) for program in catalog) + for program in catalog: + install_hint = f" [{program.uvx_command(command)}]" if program.extras else "" + print(f"{program.name:<{name_width}} {program.summary}{install_hint}") + return + + programs_by_name = {program.name: program for program in catalog} + try: + program = programs_by_name[parsed_args.name] + except KeyError: + parser.error(f"unknown {command} {parsed_args.name!r}; run '{parser.prog} list' to see available {command}s") + + missing_modules = program.missing_modules() + if missing_modules: + missing = ", ".join(missing_modules) + parser.error( + f"{command} {program.name!r} requires missing module(s): {missing}. Run: {program.uvx_command(command)}" + ) + run_program(command, program, args[1:]) diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 1f294f61b65a..d1727753c2d6 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -11,7 +11,7 @@ from collections.abc import Callable, Iterator from contextlib import contextmanager from dataclasses import fields -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar import torch import warp as wp @@ -102,12 +102,35 @@ class SimulationContext: # SINGLETON PATTERN _instance: SimulationContext | None = None + _reset_callbacks: ClassVar[dict[str, Callable[[SimulationContext], None]]] = {} @classmethod def instance(cls) -> SimulationContext | None: """Get the singleton instance, or None if not created.""" return cls._instance + @classmethod + def add_reset_callback(cls, name: str, fn: Callable[[SimulationContext], None]) -> None: + """Register a callback to fire after every :meth:`reset` of any simulation context. + + Unlike :meth:`add_render_callback`, the callback is registered on the class, so a launcher + can install it before the script it runs creates its simulation context. + + Args: + name: Unique identifier. Silently replaces any existing callback with the same name. + fn: Callable invoked with the reset simulation context once its visualizers are ready. + """ + cls._reset_callbacks[name] = fn + + @classmethod + def remove_reset_callback(cls, name: str) -> None: + """Unregister a previously registered reset callback. + + Args: + name: Identifier passed to :meth:`add_reset_callback`. No-op if not found. + """ + cls._reset_callbacks.pop(name, None) + def __init__(self, cfg: SimulationCfg | None = None): """Initialize the simulation context. @@ -766,6 +789,8 @@ def reset(self, soft: bool = False) -> None: self.physics_manager.play() self._is_playing = True self._is_stopped = False + for callback in tuple(self._reset_callbacks.values()): + callback(self) def step(self, render: bool = True) -> None: """Step physics and optionally render. diff --git a/source/isaaclab/test/app/standalone_script_cases.py b/source/isaaclab/test/app/standalone_script_cases.py index 753cd68398f2..097e19f8d190 100644 --- a/source/isaaclab/test/app/standalone_script_cases.py +++ b/source/isaaclab/test/app/standalone_script_cases.py @@ -22,8 +22,12 @@ from dataclasses import dataclass, field from pathlib import Path +from isaaclab.programs import DEMOS, EXAMPLES + ROOT = Path(__file__).resolve().parents[4] -SCRIPT_ROOTS = (ROOT / "scripts" / "demos", ROOT / "scripts" / "tutorials") +EXAMPLE_ROOT = ROOT / "examples" +DEMO_ROOT = EXAMPLE_ROOT / "demos" +SCRIPT_ROOTS = (EXAMPLE_ROOT, ROOT / "scripts" / "tutorials") # ``scripts/tools`` is not a root because most of its scripts are not simulator launches. The asset # converters are: they build a SimulationContext to preview the converted asset. EXTRA_SCRIPTS = ( @@ -34,7 +38,14 @@ DEFAULT_READINESS_PATTERN = r"Setup complete" MAX_OUTPUT_BYTES = 4 * 1024 * 1024 DEFAULT_BATCHED_NUM_ENVS = 2 +DEFAULT_MAX_STEPS = 10 +"""Steps a script with a ``--max_steps`` option runs before it must exit cleanly.""" + +PROGRAMS_BY_PATH = { + **{ROOT / program.relative_path: ("demo", program.name) for program in DEMOS}, + **{ROOT / program.relative_path: ("example", program.name) for program in EXAMPLES}, +} _FATAL_PATTERNS = ( "Traceback (most recent call last):", "Segmentation fault", @@ -75,6 +86,16 @@ class ScriptSpec: visualizer_option: str required_modules: tuple[str, ...] + @property + def program(self) -> tuple[str, str] | None: + """Return the CLI command and public name for a packaged program.""" + return PROGRAMS_BY_PATH.get(self.path) + + @property + def finite(self) -> bool: + """Return whether the script can stop itself after a bounded number of steps.""" + return "--max_steps" in self.options + @property def relative_path(self) -> str: """Return the repository-relative POSIX path.""" @@ -121,9 +142,23 @@ def skip_reason(self) -> str | None: def command(self) -> list[str]: """Build the repository launcher command for this case.""" - command = [str(ROOT / "isaaclab.sh"), "-p", self.spec.relative_path, *self.spec.args] + if self.spec.program is None: + command = [str(ROOT / "isaaclab.sh"), "-p", self.spec.relative_path, *self.spec.args] + else: + program_command, program_name = self.spec.program + command = [ + str(ROOT / "isaaclab.sh"), + "-p", + "-m", + "isaaclab", + program_command, + program_name, + *self.spec.args, + ] if "--num_envs" in self.spec.options and "--num_envs" not in self.spec.args: command.extend(("--num_envs", str(DEFAULT_BATCHED_NUM_ENVS))) + if self.spec.finite and "--max_steps" not in self.spec.args: + command.extend(("--max_steps", str(DEFAULT_MAX_STEPS))) if self.physics_option is not None: command.extend((self.physics_option, self.physics_backend)) if self.renderer_option is not None: @@ -148,20 +183,11 @@ class SmokeResult: _NEWTON_MJCF = str(Path(importlib.util.find_spec("newton").origin).parent / "examples" / "assets" / "nv_ant.xml") OVERRIDES = { - "scripts/demos/arl_robot_1.py": ScriptOverride(readiness_pattern=r"Starting demo with Lee Position Controller"), - "scripts/demos/arms.py": ScriptOverride(startup_timeout=420.0), - "scripts/demos/h1_locomotion.py": ScriptOverride( - skip_reason="downloads a published policy and requires interactive viewport input", - visualizers=("kit",), - ), - "scripts/demos/haply_teleoperation.py": ScriptOverride( + "examples/haply_teleoperation.py": ScriptOverride( skip_reason="requires a physical Haply device and its WebSocket service" ), - "scripts/demos/heterogeneous_scene.py": ScriptOverride( - args=("--num_task", "2"), - readiness_pattern=r"Composed \d+ task scenes into \d+ environments", - ), - "scripts/demos/deformables.py": ScriptOverride( + "examples/heterogeneous_scene.py": ScriptOverride(args=("--num_task", "2")), + "examples/deformables.py": ScriptOverride( case_skip_reasons={ ( "isaacsim_physx", @@ -177,67 +203,43 @@ class SmokeResult: ("isaacsim_physx", "default", "viser"): "Viser cannot import PhysX deformable attributes", } ), - "scripts/demos/mpm/newton_mpm_granular.py": ScriptOverride( - args=("--max_steps", "20"), - readiness_pattern=r"Newton granular MPM demo ready", - fixed_physics_backend="newton_mpm", - ), - "scripts/demos/mpm/newton_mpm_twoway_coupling.py": ScriptOverride( + "examples/mpm/newton_mpm_granular.py": ScriptOverride(fixed_physics_backend="newton_mpm"), + "examples/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_gl",), required_modules=("isaaclab_contrib",), ), - "scripts/demos/mpm/snowball_smash.py": ScriptOverride( - args=("--max_steps", "20"), - readiness_pattern=r"Newton snowball-smash demo ready", - fixed_physics_backend="newton_mpm", - ), - "scripts/demos/mpm/teapot_fill.py": ScriptOverride( - args=("--max_steps", "20"), - readiness_pattern=r"Newton teapot-fill MPM demo ready", - fixed_physics_backend="newton_mpm", - ), - "scripts/demos/multi_asset.py": ScriptOverride(args=("--num_envs", "4")), - "scripts/demos/newton_viewer_block_and_tackle.py": ScriptOverride( - args=("--max_steps", "20"), + "examples/demos/snowball_smash.py": ScriptOverride(fixed_physics_backend="newton_mpm"), + "examples/demos/teapot_fill.py": ScriptOverride(fixed_physics_backend="newton_mpm"), + "examples/multi_asset.py": ScriptOverride(args=("--num_envs", "4")), + "examples/demos/newton_viewer_block_and_tackle.py": ScriptOverride( fixed_physics_backend="newton_vbd", visualizers=("newton_gl",), required_modules=("isaaclab_contrib",), ), - "scripts/demos/newton_viewer_dominoes.py": ScriptOverride( - args=("--max_steps", "20"), + "examples/newton_viewer_dominoes.py": ScriptOverride( fixed_physics_backend="newton_xpbd", visualizers=("newton_gl",), ), - "scripts/demos/sensors/cameras.py": ScriptOverride(args=("--num_envs", "1"), startup_timeout=900.0), - "scripts/demos/sensors/multi_mesh_raycaster.py": ScriptOverride( + "examples/sensors/cameras.py": ScriptOverride(args=("--num_envs", "1"), startup_timeout=900.0), + "examples/sensors/multi_mesh_raycaster.py": ScriptOverride( args=("--flat_ground",), startup_timeout=600.0, case_skip_reasons={ ("newton_mjwarp", "default", "kit"): "Kit viewport fails with the Newton multi-mesh raycaster" }, ), - "scripts/demos/sensors/newton_raycast_heightfield.py": ScriptOverride( - fixed_physics_backend="newton_mjwarp", visualizers=("none", "newton_gl", "rerun", "viser") - ), - "scripts/demos/sensors/newton_raycast_moving_geometry.py": ScriptOverride( - fixed_physics_backend="newton_mjwarp", visualizers=("none", "newton_gl", "rerun", "viser") - ), - "scripts/demos/pick_and_place.py": ScriptOverride( - readiness_pattern=r"Gym action space|Press the 'A' key", visualizers=("kit",) + "examples/sensors/newton_raycast.py": ScriptOverride( + fixed_physics_backend="newton_mjwarp", + visualizers=("none", "newton_gl", "rerun", "viser"), ), - "scripts/demos/sensors/ppisp_camera.py": ScriptOverride( + "examples/demos/pick_and_place.py": ScriptOverride(visualizers=("kit",)), + "examples/sensors/ppisp_camera.py": ScriptOverride( args=("--max_steps", "3", "--warmup_steps", "1", "--image_width", "64", "--image_height", "64"), startup_timeout=600.0, visualizers=("none",), ), - "scripts/demos/sensors/ppisp_camera_ovrtx.py": ScriptOverride( - args=("--max_steps", "3", "--warmup_steps", "1"), - visualizers=("none",), - required_modules=("ovrtx",), - ), # Readiness fires once conversion succeeds, so the preview runs inside the soak. "scripts/tools/convert_urdf.py": ScriptOverride( args=( @@ -279,7 +281,7 @@ class SmokeResult: def discover_specs() -> list[ScriptSpec]: - """Discover the executable demo, tutorial, and tool scripts and their literal CLI choices.""" + """Discover executable packaged programs, tutorials, and tools and their literal CLI choices.""" specs = [] for group in (*(sorted(root.rglob("*.py")) for root in SCRIPT_ROOTS), EXTRA_SCRIPTS): for path in group: @@ -338,11 +340,12 @@ def build_cases(specs: list[ScriptSpec]) -> list[LaunchCase]: def select_script_scope(specs: list[ScriptSpec], scope: str) -> list[ScriptSpec]: - """Select scripts within a repository-relative demo or tutorial directory. + """Select packaged programs or scripts within a tutorial directory. Args: specs: Discovered standalone script specifications. - scope: Directory below ``scripts``, or ``"all"`` for every script. + scope: Program category, directory below ``examples`` or ``scripts``, or ``"all"`` for every script. + The ``"demos"`` scope selects both packaged catalogs for compatibility with the existing CI job. Returns: Specifications selected by the requested scope. @@ -352,7 +355,16 @@ def select_script_scope(specs: list[ScriptSpec], scope: str) -> list[ScriptSpec] """ if scope == "all": return specs - selected_specs = [spec for spec in specs if f"scripts/{scope}/" in spec.relative_path] + if scope == "demos": + selected_specs = [spec for spec in specs if spec.path in PROGRAMS_BY_PATH] + elif scope.startswith("examples/"): + relative_root = ROOT / scope + selected_specs = [ + spec for spec in specs if spec.path in PROGRAMS_BY_PATH and spec.path.is_relative_to(relative_root) + ] + else: + relative_root = f"scripts/{scope}" + selected_specs = [spec for spec in specs if spec.relative_path.startswith(f"{relative_root}/")] if not selected_specs: raise ValueError(f"standalone script scope selected no scripts: {scope!r}") return selected_specs @@ -381,7 +393,8 @@ def backend_is_available(backend: str) -> bool: if backend in {"physx", "isaacsim_physx"}: package = "isaaclab_physx" elif backend == "ovphysx": - package = "isaaclab_ov" + # The Isaac Lab wrapper is installed without its OVPhysX runtime unless the ov extra is requested. + return importlib.util.find_spec("isaaclab_ov") is not None and importlib.util.find_spec("ovphysx") is not None else: package = "isaaclab_newton" if backend.startswith("newton") else f"isaaclab_{backend}" return importlib.util.find_spec(package) is not None @@ -414,7 +427,7 @@ def gui_is_available() -> bool: def run_until_ready( command: list[str], - readiness_pattern: str, + readiness_pattern: str | None, *, startup_timeout: float = 180.0, soak_time: float = 5.0, @@ -423,8 +436,10 @@ def run_until_ready( ) -> SmokeResult: """Run a script until it exits or remains healthy after becoming ready. - Infinite demos are terminated as a process group after the readiness marker - has been observed and the soak interval has elapsed. + Infinite programs are terminated as a process group after the readiness marker + has been observed and the soak interval has elapsed. Without a readiness pattern + the script must exit on its own before ``startup_timeout``, and fatal output is + monitored through its shutdown. """ start_time = time.monotonic() process = subprocess.Popen( @@ -468,7 +483,7 @@ def read_available_output(timeout: float) -> bool: while True: read_available_output(timeout=0.1) decoded = output.decode(errors="replace") - if ready_at is None and re.search(readiness_pattern, decoded): + if ready_at is None and readiness_pattern and re.search(readiness_pattern, decoded): ready_at = time.monotonic() returncode = process.poll() @@ -515,7 +530,7 @@ def read_available_output(timeout: float) -> bool: record_output(remainder or b"") decoded = output.decode(errors="replace") - if ready_at is None and re.search(readiness_pattern, decoded): + if ready_at is None and readiness_pattern and re.search(readiness_pattern, decoded): ready_at = time.monotonic() return SmokeResult( @@ -529,9 +544,18 @@ def read_available_output(timeout: float) -> bool: def assert_smoke_passed(result: SmokeResult, case: LaunchCase) -> None: - """Assert that a supervised script reached readiness without a fatal error.""" + """Assert that a supervised script ran without a fatal error. + + Finite scripts must exit cleanly after their bounded steps; others must reach readiness and survive the soak. + """ tail = result.output[-30000:] assert not result.fatal_patterns, f"{case.id} emitted fatal output {result.fatal_patterns}:\n{tail}" + if case.spec.finite: + assert result.returncode == 0, ( + f"{case.id} did not exit cleanly after its steps " + f"(exit {result.returncode} in {result.elapsed:.1f}s):\n{tail}" + ) + return assert result.ready, f"{case.id} did not reach {case.spec.readiness_pattern!r} in {result.elapsed:.1f}s:\n{tail}" assert result.stopped_after_soak or result.returncode == 0, ( f"{case.id} exited with {result.returncode} before completing the soak:\n{tail}" diff --git a/source/isaaclab/test/app/test_standalone_scripts.py b/source/isaaclab/test/app/test_standalone_scripts.py index c56825ac2152..7711de8a8861 100644 --- a/source/isaaclab/test/app/test_standalone_scripts.py +++ b/source/isaaclab/test/app/test_standalone_scripts.py @@ -3,28 +3,26 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Robustness smoke tests for standalone demo and tutorial scripts. +"""Robustness smoke tests for packaged programs and standalone tutorial scripts. Set ``ISAACLAB_RUN_STANDALONE_SCRIPT_TESTS=1`` to enable the simulator launch -matrix. GUI cases additionally require ``DISPLAY`` or ``WAYLAND_DISPLAY``. +matrix. Scripts with a ``--max_steps`` option run a few steps and must exit +cleanly; other scripts must print their readiness marker and survive a soak. +GUI cases additionally require ``DISPLAY`` or ``WAYLAND_DISPLAY``. ``ISAACLAB_STANDALONE_SOAK_TIME`` and ``ISAACLAB_STANDALONE_STARTUP_TIMEOUT`` -may be used to tune the default five-second soak and five-minute startup limit. +may be used to tune the default five-second soak and five-minute time limit. Set ``ISAACLAB_STANDALONE_VISUALIZER`` to run one visualizer slice of the matrix. Set ``ISAACLAB_STANDALONE_SCRIPT_RUNTIME_GROUP`` to ``kit`` or ``non-kit`` to run the corresponding backend-runtime group. -``ISAACLAB_STANDALONE_SCREENSHOT_DELAY`` controls when visual evidence is captured. +``ISAACLAB_STANDALONE_SCREENSHOT_DIR`` captures a screenshot of soaked visual +launches; ``ISAACLAB_STANDALONE_SCREENSHOT_DELAY`` controls when. """ import ast -import fcntl import os import re -import struct -import subprocess import sys -from dataclasses import replace from pathlib import Path -from unittest import mock import pytest import standalone_script_cases as script_cases @@ -57,7 +55,12 @@ MULTI_MESH_RAYCASTER_CASES = [ case for case in CASES - if case.spec.relative_path == "scripts/demos/sensors/multi_mesh_raycaster.py" and case.visualizer == "none" + if case.spec.relative_path == "examples/sensors/multi_mesh_raycaster.py" and case.visualizer == "none" +] +NEWTON_RAYCAST_CASES = [ + case + for case in CASES + if case.spec.relative_path == "examples/sensors/newton_raycast.py" and case.visualizer == "none" ] RUN_LAUNCH_MATRIX = os.environ.get("ISAACLAB_RUN_STANDALONE_SCRIPT_TESTS") == "1" SCREENSHOT_DIR = os.environ.get("ISAACLAB_STANDALONE_SCREENSHOT_DIR") @@ -65,454 +68,166 @@ STARTUP_TIMEOUT = float(os.environ.get("ISAACLAB_STANDALONE_STARTUP_TIMEOUT", "300")) SCREENSHOT_DELAY = float(os.environ.get("ISAACLAB_STANDALONE_SCREENSHOT_DELAY", "3")) +_READY_THEN_SLEEP = "import time; print('READY'); time.sleep(30)" -def test_every_standalone_script_has_a_readiness_contract_or_exemption(): - """Every executable demo/tutorial must be runnable or explicitly exempted.""" - missing = [spec.relative_path for spec in SPECS if spec.readiness_pattern is None and spec.skip_reason is None] - assert not missing, f"standalone scripts need a readiness marker or OVERRIDES exemption: {missing}" +def _python(source: str) -> list[str]: + """Return a command that runs *source* in an unbuffered Python child process.""" + return [sys.executable, "-u", "-c", source] -def test_overrides_only_reference_discovered_standalone_scripts(): - """Stale override entries must not silently survive script removal or renaming.""" - discovered = {spec.relative_path for spec in SPECS} - stale = sorted(set(OVERRIDES) - discovered) - assert not stale, f"stale standalone script overrides: {stale}" +# Script contracts. -def test_launch_matrix_covers_declared_backends_and_visualizers(): - """Each script must expand across every declared backend and visualizer.""" - for spec in SPECS: - spec_cases = build_cases([spec]) - assert {case.physics_backend for case in spec_cases} == {backend for _, backend in spec.physics_backends} - assert {case.renderer_backend for case in spec_cases} == {backend for _, backend in spec.rendering_backends} - assert {case.visualizer for case in spec_cases} == set(spec.visualizers) - assert len(spec_cases) == len(spec.physics_backends) * len(spec.rendering_backends) * len(spec.visualizers) +def test_every_standalone_script_has_a_launch_contract_or_exemption(): + """Packaged programs must stop themselves; other scripts must be runnable or explicitly exempted.""" + not_finite = [spec.relative_path for spec in SPECS if spec.program is not None and not spec.finite] + assert not not_finite, f"packaged programs need a --max_steps option: {not_finite}" + missing = [ + spec.relative_path + for spec in SPECS + if not spec.finite and spec.readiness_pattern is None and spec.skip_reason is None + ] + assert not missing, f"standalone scripts need --max_steps, a readiness marker, or an OVERRIDES exemption: {missing}" -def test_runtime_groups_partition_matrix_without_overlap(): - """Kit and non-Kit groups must cover every launch case exactly once.""" - cases = build_cases(SPECS) - groups = [select_runtime_group(cases, runtime_group) for runtime_group in ("kit", "non-kit")] - grouped_ids = [case.id for group in groups for case in group] - assert sorted(grouped_ids) == sorted(case.id for case in cases) - assert len(grouped_ids) == len(set(grouped_ids)) - assert all( - case.physics_backend == "isaacsim_physx" or case.renderer_backend == "isaac_rtx" or case.visualizer == "kit" - for case in groups[0] - ) - assert all( - case.physics_backend != "isaacsim_physx" and case.renderer_backend != "isaac_rtx" and case.visualizer != "kit" - for case in groups[1] - ) - with pytest.raises(ValueError, match="runtime group"): - select_runtime_group(cases, "invalid") +def test_overrides_only_reference_discovered_standalone_scripts(): + """Stale override entries must not silently survive script removal or renaming.""" + stale = sorted(set(OVERRIDES) - {spec.relative_path for spec in SPECS}) + assert not stale, f"stale standalone script overrides: {stale}" -def test_script_scope_rejects_empty_selection(): - """A stale or misspelled scope must not produce a vacuously green launch matrix.""" - assert select_script_scope(SPECS, "all") is SPECS - assert all(spec.relative_path.startswith("scripts/demos/mpm/") for spec in select_script_scope(SPECS, "demos/mpm")) - with pytest.raises(ValueError, match="selected no scripts"): - select_script_scope(SPECS, "missing") +def test_every_packaged_script_is_registered_for_cli_launch(): + """Every executable script in the root examples tree must have one CLI entry.""" + packaged_specs = [spec for spec in SPECS if spec.path.is_relative_to(script_cases.EXAMPLE_ROOT)] + assert {spec.path for spec in packaged_specs} == set(script_cases.PROGRAMS_BY_PATH) def test_demo_browser_documents_options_for_each_demo(): - """Every demo card must expose its supported launch options to the command builder.""" + """Every demo card must match a demo and offer exactly the options the demo accepts.""" docs_source = script_cases.ROOT / "docs/source" demos_page = (docs_source / "setup/demos.rst").read_text(encoding="utf-8") - cards = re.findall(r'(?s)]+data-demo-path="[^"]+"[^>]*>', demos_page) documented_entries = {} - for card in cards: + for card in re.findall(r'(?s)]+data-demo-id="[^"]+"[^>]*>', demos_page): attributes = dict(re.findall(r'data-demo-([\w-]+)="([^"]*)"', card)) - path = attributes.pop("path") - assert path not in documented_entries, f"demo browser contains a duplicate card for {path}" - documented_entries[path] = attributes - - referenced_paths = set(re.findall(r"scripts/demos/[A-Za-z0-9_./-]+\.py", demos_page)) - assert documented_entries.keys() == referenced_paths - - demo_specs = { - spec.relative_path: spec - for spec in SPECS - if spec.relative_path.startswith("scripts/demos/") and spec.relative_path in referenced_paths - } - assert demo_specs.keys() == referenced_paths - image_paths = re.findall(r' 1: - return [] - key = type("Key", (), {"fileobj": self.fileobj})() - return [(key, script_cases.selectors.EVENT_READ)] - - def close(self): - pass - - process = FakeProcess() - monkeypatch.setattr(script_cases.subprocess, "Popen", lambda *args, **kwargs: process) - monkeypatch.setattr(script_cases.selectors, "DefaultSelector", FakeSelector) - monkeypatch.setattr(script_cases.os, "read", lambda *args: b"READY\n") - monkeypatch.setattr(fcntl, "ioctl", lambda *args: struct.pack("i", 0)) - monkeypatch.setattr(script_cases, "_terminate_process_group", lambda process: setattr(process, "returncode", -15)) - - result = run_until_ready(["demo.py"], r"READY", startup_timeout=2.0, soak_time=0.0) - assert result.ready +def test_supervisor_ignores_fatal_output_caused_by_its_own_teardown(): + """Errors printed while the supervisor stops a healthy script must not fail the launch.""" + source = ( + "import signal, sys, time\n" + "signal.signal(signal.SIGTERM, lambda *_: (print('Traceback (most recent call last):'), sys.exit(1)))\n" + "print('READY')\n" + "time.sleep(30)\n" + ) + result = run_until_ready(_python(source), r"READY", startup_timeout=5.0, soak_time=0.2) assert result.stopped_after_soak assert "Traceback (most recent call last):" in result.output assert not result.fatal_patterns -@pytest.mark.parametrize("continuous_output", [False, True]) -def test_subprocess_supervisor_classifies_buffered_fatal_output_before_intentional_teardown( - monkeypatch, continuous_output -): - """Drain pre-teardown errors without letting a continuous producer extend the soak.""" - process = mock.Mock(returncode=None) - process.poll.side_effect = lambda: process.returncode - process.communicate.return_value = (b"post-teardown output\n", None) - selector = mock.Mock() - chunks = [b"READY\n", b"x" * 65536, b"Traceback (most recent call last):\n"] - pending_bytes = sum(map(len, chunks[1:])) - reads = 0 - - def read(fd, size): - nonlocal reads - reads += 1 - assert reads <= 4, "Continuous output prevented the supervisor from terminating the process" - return chunks.pop(0) if chunks else b"still running\n" - - selector.select.side_effect = lambda timeout: ( - [(mock.Mock(fileobj=process.stdout), script_cases.selectors.EVENT_READ)] if chunks or continuous_output else [] - ) - monkeypatch.setattr(script_cases.subprocess, "Popen", lambda *args, **kwargs: process) - monkeypatch.setattr(script_cases.selectors, "DefaultSelector", lambda: selector) - monkeypatch.setattr(script_cases.os, "read", read) - monkeypatch.setattr(fcntl, "ioctl", lambda *args: struct.pack("i", pending_bytes)) - monkeypatch.setattr(script_cases, "_terminate_process_group", lambda process: setattr(process, "returncode", -15)) - - result = run_until_ready(["demo.py"], r"READY", startup_timeout=2.0, soak_time=0.0) - assert result.ready - assert result.stopped_after_soak - assert not chunks - assert "Traceback (most recent call last):" in result.fatal_patterns - - -def test_subprocess_supervisor_accepts_clean_exit_after_readiness(): - """A finite script may exit successfully immediately after becoming ready.""" - result = run_until_ready([sys.executable, "-u", "-c", "print('READY')"], r"READY", startup_timeout=2.0) - assert result.ready +def test_supervisor_requires_readiness_before_exit(): + """A successful exit is insufficient for a soaked script that never printed its readiness marker.""" + result = run_until_ready(_python("print('not ready')"), r"READY", startup_timeout=2.0) + assert not result.ready assert result.returncode == 0 - assert not result.stopped_after_soak -def test_subprocess_supervisor_rejects_exit_before_readiness(): - """A successful process exit is insufficient without its readiness contract.""" - result = run_until_ready([sys.executable, "-u", "-c", "print('not ready')"], r"READY", startup_timeout=2.0) - assert not result.ready - assert result.returncode == 0 +def test_supervisor_runs_finite_scripts_to_exit_and_reports_shutdown_errors(): + """Without a readiness marker the script runs to completion, and errors during its shutdown are fatal.""" + clean = run_until_ready(_python("print('stepping')"), None, startup_timeout=5.0) + assert clean.returncode == 0 + assert not clean.fatal_patterns + crashing_shutdown = run_until_ready( + _python("print('stepping'); print('Fatal Python error: during teardown')"), None, startup_timeout=5.0 + ) + assert crashing_shutdown.returncode == 0 + assert "Fatal Python error:" in crashing_shutdown.fatal_patterns -def test_subprocess_supervisor_bounds_startup_time(): - """A process that never becomes ready must be terminated at the startup deadline.""" - command = [sys.executable, "-u", "-c", "import time; time.sleep(30)"] - result = run_until_ready(command, r"READY", startup_timeout=0.05) + +@pytest.mark.parametrize("readiness_pattern", [r"READY", None], ids=["soaked", "finite"]) +def test_supervisor_bounds_run_time(readiness_pattern): + """A script that never becomes ready or never exits is terminated at the time limit.""" + result = run_until_ready(_python("import time; time.sleep(30)"), readiness_pattern, startup_timeout=0.05) assert not result.ready assert result.elapsed < 2.0 - assert result.returncode is not None + assert result.returncode not in (None, 0) -def test_subprocess_supervisor_retains_fatal_state_when_output_is_truncated(monkeypatch): +def test_supervisor_retains_fatal_state_when_output_is_truncated(monkeypatch): """Fatal output must remain detectable after the bounded output tail rolls over.""" monkeypatch.setattr(script_cases, "MAX_OUTPUT_BYTES", 64) source = ( @@ -521,185 +236,47 @@ def test_subprocess_supervisor_retains_fatal_state_when_output_is_truncated(monk "print('nefc overflow - please increase njmax to 10'); " "print('x' * 1024); print('READY')" ) - result = run_until_ready([sys.executable, "-u", "-c", source], r"READY", startup_timeout=2.0) + result = run_until_ready(_python(source), r"READY", startup_timeout=2.0) assert result.ready assert len(result.output.encode()) <= 64 - assert "Traceback (most recent call last):" in result.fatal_patterns - assert "exceeded MJWarp limit" in result.fatal_patterns - assert "nefc overflow" in result.fatal_patterns + assert {"Traceback (most recent call last):", "exceeded MJWarp limit", "nefc overflow"} <= set( + result.fatal_patterns + ) -def test_subprocess_supervisor_captures_requested_screenshot(monkeypatch, tmp_path): - """A healthy visual launch must trigger one screenshot during its soak.""" +def test_supervisor_captures_one_requested_screenshot(monkeypatch, tmp_path): + """A healthy visual launch triggers exactly one screenshot during its soak.""" captured = [] monkeypatch.setattr(script_cases, "_capture_screenshot", captured.append) screenshot_path = tmp_path / "launch.png" - command = [sys.executable, "-u", "-c", "import time; print('READY'); time.sleep(30)"] result = run_until_ready( - command, - r"READY", - startup_timeout=2.0, - soak_time=0.1, - screenshot_path=screenshot_path, + _python(_READY_THEN_SLEEP), r"READY", startup_timeout=2.0, soak_time=0.1, screenshot_path=screenshot_path ) assert result.ready assert captured == [screenshot_path] -def test_screenshot_capture_reports_external_tool_failure(monkeypatch, tmp_path): - """Screenshot failures must be surfaced instead of producing missing visual evidence.""" - completed = subprocess.CompletedProcess([], 1, "", "display unavailable") - monkeypatch.setattr(script_cases.subprocess, "run", lambda *args, **kwargs: completed) - with pytest.raises(RuntimeError, match="display unavailable"): - script_cases._capture_screenshot(tmp_path / "capture.png") - - completed = subprocess.CompletedProcess([], 0, "", "") - monkeypatch.setattr(script_cases.subprocess, "run", lambda *args, **kwargs: completed) - with pytest.raises(RuntimeError, match="did not create a non-empty image"): - script_cases._capture_screenshot(tmp_path / "missing.png") - - -def test_screenshot_capture_invokes_imagemagick(monkeypatch, tmp_path): - """Screenshot capture must create its destination and target the root window.""" - calls = [] - completed = subprocess.CompletedProcess([], 0, "", "") - path = tmp_path / "nested" / "capture.png" - - def run_and_create_image(*args, **kwargs): - calls.append((args, kwargs)) - path.write_bytes(b"image") - return completed - - monkeypatch.setattr(script_cases.subprocess, "run", run_and_create_image) - script_cases._capture_screenshot(path) - assert path.parent.is_dir() - assert calls[0][0][0] == ["import", "-window", "root", str(path)] - - def test_smoke_assertion_rejects_each_failure_mode(): - """The result assertion must reject fatal output, missing readiness, and early crashes.""" - case = build_cases([SPECS[0]])[0] - with pytest.raises(AssertionError, match="fatal output"): - assert_smoke_passed( - SmokeResult(True, 0, "tail", 0.1, False, ("Fatal Python error:",)), - case, - ) + """Fatal output, missing readiness, and early or unclean exits must all fail a launch.""" + soaked = next(case for case in build_cases(SPECS) if not case.spec.finite and case.spec.readiness_pattern) + finite = next(case for case in build_cases(SPECS) if case.spec.finite) + for case in (soaked, finite): + with pytest.raises(AssertionError, match="fatal output"): + assert_smoke_passed(SmokeResult(True, 0, "tail", 0.1, False, ("Fatal Python error:",)), case) with pytest.raises(AssertionError, match="did not reach"): - assert_smoke_passed(SmokeResult(False, 0, "tail", 0.1, False), case) + assert_smoke_passed(SmokeResult(False, 0, "tail", 0.1, False), soaked) with pytest.raises(AssertionError, match="exited with 2"): - assert_smoke_passed(SmokeResult(True, 2, "tail", 0.1, False), case) - - -def test_ast_discovery_recognizes_main_guards_and_literal_choices(): - """Static discovery must distinguish executable scripts and preserve literal choices.""" - tree = ast.parse( - """ -import argparse -parser = argparse.ArgumentParser() -parser.add_argument('--physics', choices=['physx', 'newton_mjwarp']) -parser.add_argument('--viz', choices=('none',)) -parser.add_argument('positional') -if __name__ == '__main__': - pass -""" - ) - assert script_cases._has_main_guard(tree) - assert script_cases._literal_cli_options(tree) == { - "--physics": ("physx", "newton_mjwarp"), - "--viz": ("none",), - } - assert not script_cases._has_main_guard(ast.parse("print('library module')")) + assert_smoke_passed(SmokeResult(True, 2, "tail", 0.1, False), soaked) + with pytest.raises(AssertionError, match="did not exit cleanly"): + assert_smoke_passed(SmokeResult(False, -15, "tail", 300.0, False), finite) + assert_smoke_passed(SmokeResult(False, 0, "tail", 0.1, False), finite) -@pytest.mark.parametrize( - ("backend", "package"), - [ - ("isaacsim_physx", "isaaclab_physx"), - ("newton_mjwarp", "isaaclab_newton"), - ("ovphysx", "isaaclab_ov"), - ], -) -def test_backend_availability_resolves_implementation_package(monkeypatch, backend, package): - """Backend gating must query the package that implements each declared backend.""" - queried = [] - monkeypatch.setattr(script_cases.importlib.util, "find_spec", lambda name: queried.append(name) or object()) - assert backend_is_available(backend) - assert queried == [package] - - -def test_builtin_backend_availability_handles_default_and_isaac_rtx(monkeypatch): - """Built-in backend gates must not require an extension package lookup.""" - monkeypatch.setattr(script_cases.importlib.util, "find_spec", lambda name: None) - assert backend_is_available("default") - assert backend_is_available("isaac_rtx") == (script_cases.ROOT / "_isaac_sim").exists() - - -def test_visualizer_availability_requires_shared_and_backend_packages(monkeypatch): - """External visualizers require both the visualizer extension and selected implementation.""" - available = {"isaaclab_visualizers", "isaaclab_newton", "rerun"} - monkeypatch.setattr(script_cases.importlib.util, "find_spec", lambda name: object() if name in available else None) - assert visualizer_is_available("newton") - assert visualizer_is_available("newton_gl") - assert not visualizer_is_available("newton_rtx") - available.add("ovrtx") - assert visualizer_is_available("newton_rtx") - assert visualizer_is_available("rerun") - assert not visualizer_is_available("viser") - - monkeypatch.setattr(script_cases.importlib.util, "find_spec", lambda name: None) - assert not visualizer_is_available("rerun") - - -def test_builtin_visualizer_availability_handles_none_and_kit(monkeypatch): - """Built-in visualizer gates must recognize headless mode and local Isaac Sim.""" - monkeypatch.setattr(script_cases.importlib.util, "find_spec", lambda name: None) - assert visualizer_is_available("none") - assert visualizer_is_available("kit") == (script_cases.ROOT / "_isaac_sim").exists() - - -def test_gui_availability_accepts_x11_or_wayland(monkeypatch): - """GUI gating must work with either supported Linux display protocol.""" - monkeypatch.delenv("DISPLAY", raising=False) - monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) - assert not gui_is_available() - monkeypatch.setenv("DISPLAY", ":1") - assert gui_is_available() - monkeypatch.delenv("DISPLAY") - monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") - assert gui_is_available() - - -def test_process_group_termination_handles_exited_and_stubborn_processes(monkeypatch): - """Termination must no-op after exit and escalate a process that ignores SIGTERM.""" - exited = type("Exited", (), {"poll": lambda self: 0})() - script_cases._terminate_process_group(exited) - - class Stubborn: - pid = 123 - waits = 0 - - def poll(self): - return None - - def wait(self, timeout): - self.waits += 1 - if self.waits == 1: - raise subprocess.TimeoutExpired("demo", timeout) - return -9 - - signals = [] - monkeypatch.setattr(script_cases.os, "killpg", lambda pid, sig: signals.append((pid, sig))) - stubborn = Stubborn() - script_cases._terminate_process_group(stubborn) - assert signals == [(123, script_cases.signal.SIGTERM), (123, script_cases.signal.SIGKILL)] +# Simulator launches. -@pytest.mark.integration -@pytest.mark.rendering -@pytest.mark.smoke -@pytest.mark.parametrize("case", CASES, ids=lambda case: case.id) -def test_standalone_script_remains_healthy_after_startup(case): - """Each supported script launch must initialize and survive a short soak.""" +def _skip_unsupported(case) -> None: + """Skip a launch the current machine or the script's declared contract cannot run.""" if not RUN_LAUNCH_MATRIX: pytest.skip("set ISAACLAB_RUN_STANDALONE_SCRIPT_TESTS=1 to run the launch matrix") if case.skip_reason: @@ -716,13 +293,12 @@ def test_standalone_script_remains_healthy_after_startup(case): if not visualizer_is_available(case.visualizer): pytest.skip(f"visualizer package for {case.visualizer!r} is not installed") - assert case.spec.readiness_pattern is not None - screenshot_path = None - if SCREENSHOT_DIR and case.visualizer != "none" and gui_is_available(): - screenshot_path = Path(SCREENSHOT_DIR) / f"{case.id}.png" + +def _launch(case, *extra_args: str, screenshot_path: Path | None = None) -> None: + """Launch *case* and assert it ran without a fatal error.""" result = run_until_ready( - case.command(), - case.spec.readiness_pattern, + [*case.command(), *extra_args], + None if case.spec.finite else case.spec.readiness_pattern, startup_timeout=max(STARTUP_TIMEOUT, case.spec.startup_timeout or 0.0), soak_time=SOAK_TIME, screenshot_path=screenshot_path, @@ -731,6 +307,19 @@ def test_standalone_script_remains_healthy_after_startup(case): assert_smoke_passed(result, case) +@pytest.mark.integration +@pytest.mark.rendering +@pytest.mark.smoke +@pytest.mark.parametrize("case", CASES, ids=lambda case: case.id) +def test_standalone_script_runs_cleanly(case): + """Each supported script launch must initialize, step, and stop without a fatal error.""" + _skip_unsupported(case) + screenshot_path = None + if SCREENSHOT_DIR and not case.spec.finite and case.visualizer != "none" and gui_is_available(): + screenshot_path = Path(SCREENSHOT_DIR) / f"{case.id}.png" + _launch(case, screenshot_path=screenshot_path) + + @pytest.mark.integration @pytest.mark.rendering @pytest.mark.smoke @@ -738,88 +327,15 @@ def test_standalone_script_remains_healthy_after_startup(case): @pytest.mark.parametrize("case", MULTI_MESH_RAYCASTER_CASES, ids=lambda case: case.physics_backend) def test_multi_mesh_raycaster_supports_each_asset_type(case, asset_type): """The multi-mesh raycaster must support every non-default asset path on each physics backend.""" - if not RUN_LAUNCH_MATRIX: - pytest.skip("set ISAACLAB_RUN_STANDALONE_SCRIPT_TESTS=1 to run the launch matrix") - if not backend_is_available(case.physics_backend): - pytest.skip(f"physics backend package for {case.physics_backend!r} is not installed") - - command = [*case.command(), "--asset_type", asset_type] - result = run_until_ready( - command, - case.spec.readiness_pattern, - startup_timeout=max(STARTUP_TIMEOUT, case.spec.startup_timeout or 0.0), - soak_time=SOAK_TIME, - ) - assert_smoke_passed(result, case) + _skip_unsupported(case) + _launch(case, "--asset_type", asset_type) -def test_launch_matrix_skips_declared_exemption(monkeypatch): - """A declared script exemption must win before runtime capability checks.""" - monkeypatch.setattr(sys.modules[__name__], "RUN_LAUNCH_MATRIX", True) - case = build_cases([next(spec for spec in SPECS if spec.skip_reason)])[0] - with pytest.raises(pytest.skip.Exception, match=case.skip_reason): - test_standalone_script_remains_healthy_after_startup(case) - - -def test_launch_matrix_skips_unavailable_runtime_capabilities(monkeypatch): - """Display, physics, renderer, and visualizer gates must report distinct reasons.""" - module = sys.modules[__name__] - monkeypatch.setattr(module, "RUN_LAUNCH_MATRIX", True) - base_case = next(case for case in build_cases(SPECS) if case.skip_reason is None and case.visualizer == "none") - - missing_module_case = replace(base_case, spec=replace(base_case.spec, required_modules=("missing_runtime",))) - monkeypatch.setattr(script_cases, "module_is_available", lambda module_name: False) - with pytest.raises(pytest.skip.Exception, match="required runtime module"): - test_standalone_script_remains_healthy_after_startup(missing_module_case) - - monkeypatch.setattr(module, "gui_is_available", lambda: False) - with pytest.raises(pytest.skip.Exception, match="GUI smoke test"): - test_standalone_script_remains_healthy_after_startup(replace(base_case, visualizer="kit")) - - monkeypatch.setattr(module, "backend_is_available", lambda backend: backend != "missing") - with pytest.raises(pytest.skip.Exception, match="physics backend package"): - test_standalone_script_remains_healthy_after_startup(replace(base_case, physics_backend="missing")) - with pytest.raises(pytest.skip.Exception, match="renderer backend package"): - test_standalone_script_remains_healthy_after_startup(replace(base_case, renderer_backend="missing")) - - monkeypatch.setattr(module, "visualizer_is_available", lambda visualizer: False) - with pytest.raises(pytest.skip.Exception, match="visualizer package"): - test_standalone_script_remains_healthy_after_startup(base_case) - - -def test_launch_matrix_runs_supported_case_with_screenshot(monkeypatch, tmp_path): - """A supported visual case must forward supervision and screenshot settings.""" - module = sys.modules[__name__] - monkeypatch.setattr(module, "RUN_LAUNCH_MATRIX", True) - monkeypatch.setattr(module, "SCREENSHOT_DIR", str(tmp_path)) - monkeypatch.setattr(module, "SCREENSHOT_DELAY", 3.0) - monkeypatch.setattr(module, "gui_is_available", lambda: True) - monkeypatch.setattr(module, "backend_is_available", lambda backend: True) - monkeypatch.setattr(module, "visualizer_is_available", lambda visualizer: True) - case = replace(next(case for case in build_cases(SPECS) if case.skip_reason is None), visualizer="newton") - case = replace(case, spec=replace(case.spec, startup_timeout=600.0)) - calls = [] - - def _run(*args, **kwargs): - calls.append((args, kwargs)) - return SmokeResult(True, 0, "ready", 0.1, False) - - monkeypatch.setattr(module, "run_until_ready", _run) - test_standalone_script_remains_healthy_after_startup(case) - - assert calls[0][0] == (case.command(), case.spec.readiness_pattern) - assert calls[0][1]["startup_timeout"] == 600.0 - assert calls[0][1]["screenshot_path"] == tmp_path / f"{case.id}.png" - assert calls[0][1]["screenshot_delay"] == 3.0 - - -def test_launch_matrix_runs_web_visualizer_without_display(monkeypatch): - """Web visualizers must remain testable on headless CI workers.""" - module = sys.modules[__name__] - monkeypatch.setattr(module, "RUN_LAUNCH_MATRIX", True) - monkeypatch.setattr(module, "gui_is_available", lambda: False) - monkeypatch.setattr(module, "backend_is_available", lambda backend: True) - monkeypatch.setattr(module, "visualizer_is_available", lambda visualizer: True) - monkeypatch.setattr(module, "run_until_ready", lambda *args, **kwargs: SmokeResult(True, 0, "ready", 0.1, False)) - case = replace(next(case for case in build_cases(SPECS) if case.skip_reason is None), visualizer="rerun") - test_standalone_script_remains_healthy_after_startup(case) +@pytest.mark.integration +@pytest.mark.rendering +@pytest.mark.smoke +@pytest.mark.parametrize("case", NEWTON_RAYCAST_CASES, ids=lambda case: case.physics_backend) +def test_newton_raycast_supports_moving_geometry(case): + """The consolidated Newton ray-cast example must launch its non-default scene.""" + _skip_unsupported(case) + _launch(case, "--scene", "moving-geometry") diff --git a/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py b/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py index e34b43d870fe..a2d790cbeb1e 100644 --- a/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py +++ b/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py @@ -17,6 +17,9 @@ import isaaclab.__main__ as package_main import isaaclab.cli as cli import isaaclab.paths as paths +import isaaclab.program_browser as browser +import isaaclab.programs as programs +from isaaclab.sim import SimulationContext pytestmark = pytest.mark.unit @@ -94,6 +97,209 @@ def test_workflow_command_propagates_failure_status(): cli.train([]) +def test_demo_catalog_lists_packaged_demos(capsys): + """The demo command must expose stable names without importing simulator modules.""" + cli.demo(["list"]) + + output = capsys.readouterr().out + assert "zoo" in output + assert "teapot-fill" in output + assert "newton-dominoes" not in output + assert "bin-packing" not in output + + +def test_example_catalog_lists_packaged_examples(capsys): + """The example command must distinguish focused programs from showcases.""" + cli.example(["list"]) + + output = capsys.readouterr().out + assert "bin-packing" in output + assert "newton-dominoes" in output + assert "mpm-two-way-coupling" in output + assert "uvx --from 'isaaclab[isaacsim]' isaaclab example camera" in output + assert "teapot-fill" not in output + + +def test_browse_is_not_a_program_command(): + """Program selection lives in Newton GL, not in a separate CLI mode.""" + with pytest.raises(SystemExit, match="2"): + cli.demo(["browse"]) + + +def _visualizer(visualizer_type: str) -> mock.Mock: + """Return a visualizer stand-in of the given type.""" + visualizer = mock.Mock() + visualizer.cfg.visualizer_type = visualizer_type + return visualizer + + +def test_newton_gl_selector_omits_incompatible_programs(): + """The selector must feature GL-compatible showcases and omit Kit-only programs.""" + gl_visualizer = _visualizer("newton_gl") + kit_visualizer = _visualizer("kit") + sim = mock.Mock(visualizers=[gl_visualizer, kit_visualizer]) + imgui = mock.Mock() + imgui.collapsing_header.return_value = True + imgui.tree_node.return_value = True + imgui.selectable.return_value = (False, False) + imgui.is_item_hovered.return_value = False + program_browser = browser.ProgramBrowser({"demo": programs.DEMOS, "example": programs.EXAMPLES}) + program_browser.attach(sim) + program_browser.attach(sim) + gl_visualizer.register_ui_callback.call_args.args[0](imgui) + + labels = {call.args[0] for call in imgui.selectable.call_args_list} + gl_visualizer.register_ui_callback.assert_called_once() + assert gl_visualizer.register_ui_callback.call_args.kwargs == {"position": "panel"} + kit_visualizer.register_ui_callback.assert_not_called() + assert "Zoo##demo:zoo" in labels + assert "Cables##example:cables" in labels + assert "Newton Dominoes##example:newton-dominoes" in labels + assert "Newton Dominoes##demo:newton-dominoes" not in labels + assert "H1 Locomotion##demo:h1-locomotion" in labels + assert "Pick And Place##demo:pick-and-place" not in labels + assert "Ppisp Camera##example:ppisp-camera" not in labels + assert "Haply Teleoperation##example:haply-teleoperation" not in labels + assert imgui.set_next_item_open.call_count == 2 + + +def test_programs_run_without_warp_backward_kernels(monkeypatch): + """Programs only run inference, so their kernels must skip backward code generation.""" + import warp as wp + + monkeypatch.setattr(wp.config, "enable_backward", True) + observed = [] + monkeypatch.setattr(programs, "_run_script", lambda _path: observed.append(wp.config.enable_backward)) + + cli.demo(["zoo"]) + + assert observed == [False] + + +def test_newton_gl_selector_switches_programs_after_script_returns(monkeypatch): + """A GL selection must restart with GL after the current program finishes.""" + visualizer = _visualizer("newton_gl") + imgui = mock.Mock() + imgui.collapsing_header.return_value = True + imgui.tree_node.return_value = True + imgui.selectable.side_effect = lambda label, _selected: (label == "Cables##example:cables", False) + imgui.is_item_hovered.return_value = False + + def run_script(_path): + # Stand in for the program's simulation reset, which fires the launcher's callback. + for callback in tuple(SimulationContext._reset_callbacks.values()): + callback(mock.Mock(visualizers=[visualizer])) + visualizer.register_ui_callback.call_args.args[0](imgui) + + monkeypatch.setattr(programs, "_run_script", run_script) + with mock.patch.object(programs.os, "execv") as execv: + cli.demo(["zoo", "--viz", "newton_gl"]) + + visualizer.request_close.assert_called_once_with() + assert not SimulationContext._reset_callbacks + execv.assert_called_once_with( + sys.executable, [sys.executable, "-m", "isaaclab", "example", "cables", "--viz", "newton_gl"] + ) + + +@pytest.mark.parametrize( + ("catalog", "directory"), [(programs.DEMOS, "examples/demos"), (programs.EXAMPLES, "examples")] +) +def test_program_catalog_resolves_paths(catalog, directory): + """Both catalogs must resolve inside the single examples tree.""" + assert all(program.relative_path.startswith(f"{directory}/") for program in catalog) + assert all(program.path.is_file() for program in catalog) + + +def test_integration_examples_use_root_example_paths(): + """Integration examples must use paths relative to the root examples directory.""" + paths_by_name = {program.name: program.relative_path for program in programs.EXAMPLES} + assert paths_by_name["arl-robot-1"] == "examples/arl_robot_1.py" + assert paths_by_name["haply-teleoperation"] == "examples/haply_teleoperation.py" + assert paths_by_name["newton-dominoes"] == "examples/newton_viewer_dominoes.py" + assert paths_by_name["ppisp-camera"] == "examples/sensors/ppisp_camera.py" + assert paths_by_name["tactile-sensor"] == "examples/sensors/tacsl_sensor.py" + + +def test_newton_raycast_scenes_share_one_example(): + """Newton ray-cast variants must stay behind one focused example entry.""" + paths_by_name = {program.name: program.relative_path for program in programs.EXAMPLES} + assert paths_by_name["newton-raycast"] == "examples/sensors/newton_raycast.py" + assert "newton-raycast-heightfield" not in paths_by_name + assert "newton-raycast-moving-geometry" not in paths_by_name + + +@pytest.mark.parametrize("catalog", [programs.DEMOS, programs.EXAMPLES]) +def test_program_catalog_names_are_unique(catalog): + """Each CLI program name must select exactly one module.""" + names = [program.name for program in catalog] + assert len(names) == len(set(names)) + + +@pytest.mark.parametrize( + ("command", "command_name", "program_name"), + [(cli.demo, "demo", "zoo"), (cli.example, "example", "cables")], +) +def test_program_command_dispatches_to_packaged_script(command, command_name, program_name): + """Program commands must forward all remaining arguments to the selected script.""" + with mock.patch.object(programs, "run_program") as run_program: + command([program_name, "--physics", "newton_mjwarp"]) + + catalog = programs.DEMOS if command_name == "demo" else programs.EXAMPLES + selected = next(program for program in catalog if program.name == program_name) + run_program.assert_called_once_with(command_name, selected, ["--physics", "newton_mjwarp"]) + + +def test_program_command_forwards_help_to_selected_module(): + """Help after a program name belongs to that program, not the catalog parser.""" + with mock.patch.object(programs, "run_program") as run_program: + cli.demo(["zoo", "--help"]) + + run_program.assert_called_once_with("demo", programs.DEMOS[0], ["--help"]) + + +def test_program_runner_preserves_command_name_and_restores_process_arguments(tmp_path): + """Running a program must not leak its arguments to the caller.""" + original_argv = sys.argv + script = tmp_path / "demos" / "example.py" + script.parent.mkdir() + script.write_text("import sys\nassert sys.argv[0] == 'isaaclab demo temporary'\n", encoding="utf-8") + program = programs.ProgramSpec("temporary", f"examples/demos/{script.name}", "Temporary test program.") + with mock.patch.object(programs, "_program_root", return_value=tmp_path): + programs.run_program("demo", program, ["--physics", "newton_mjwarp"]) + + assert sys.argv is original_argv + + +@pytest.mark.parametrize("command", [cli.demo, cli.example]) +def test_program_command_rejects_unknown_name(command): + """Unknown program names must fail before attempting a module import.""" + with pytest.raises(SystemExit, match="2"): + command(["does-not-exist"]) + + +def test_program_command_reports_missing_optional_dependencies(capsys): + """A program with missing extras must print its complete uvx installation command.""" + with mock.patch.object(programs, "find_spec", return_value=None), pytest.raises(SystemExit, match="2"): + cli.example(["camera"]) + + assert "uvx --from 'isaaclab[isaacsim]' isaaclab example camera" in capsys.readouterr().err + + +@pytest.mark.parametrize(("command_name", "args"), [("demo", ["zoo", "--headless"]), ("example", ["cables"])]) +def test_cli_routes_program_without_loading_external_tasks(command_name, args): + """Packaged programs do not need task plug-in discovery before dispatch.""" + with ( + mock.patch.object(cli, "_load_external_tasks") as load_external_tasks, + mock.patch.object(cli, command_name) as command, + mock.patch.object(sys, "argv", ["isaaclab", command_name, *args]), + ): + cli.cli() + + load_external_tasks.assert_not_called() + command.assert_called_once_with(args) + + def test_cli_loads_downstream_tasks_before_benchmark(): """Benchmarking must discover tasks from installed projects.""" task_entry_point = mock.Mock() diff --git a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py index 8da7efa8b780..5063bd2830d1 100644 --- a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py +++ b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py @@ -18,6 +18,30 @@ - from isaaclab_assets.robots.allegro import ALLEGRO_HAND_CFG -> verify importable - from isaaclab.scene import InteractiveSceneCfg -> verify importable - python -m isaaclab --help -> verify CLI functional + - python -c "from isaaclab.programs import DEMOS, EXAMPLES; + assert all(program.path.is_file() for program in (*DEMOS, *EXAMPLES))" + -> verify packaged program catalogs resolve private script resources + - python -c "import contextlib + import io + import sys + import isaaclab.app as app + from isaaclab.programs import DEMOS, EXAMPLES, _run_script + def fail_launch(*args, **kwargs): + raise AssertionError(f'{sys.argv[0]} launched simulation while handling --help') + app.AppLauncher.__init__ = fail_launch + app.launch_simulation = fail_launch + for command, catalog in (('demo', DEMOS), ('example', EXAMPLES)): + for program in catalog: + sys.argv = [f'isaaclab {command} {program.name}', '--help'] + with contextlib.redirect_stdout(io.StringIO()): + try: + _run_script(program.path) + except SystemExit as error: + if error.code != 0: + raise AssertionError(f'{sys.argv[0]} --help exited with {error.code}') from error + else: + raise AssertionError(f'{sys.argv[0]} --help did not exit')" + -> verify packaged programs expose help without launching simulation - verify project-generator resources are installed - import pinocchio -> verify importable - python -c "import importlib.util; raise SystemExit(importlib.util.find_spec('pytetwild') is not None)" @@ -112,6 +136,15 @@ def test_isaaclab_package_has_flat_layout(self): assert "isaaclab/app/__init__.py" in names assert "isaaclab/apps/isaaclab.python.kit" in names + assert "isaaclab/programs.py" in names + assert "isaaclab/examples/demos/zoo.py" in names + assert "isaaclab/examples/assets/nvidia_logo_domino_poses.pth" in names + assert "isaaclab/examples/cables.py" in names + assert "isaaclab/examples/newton_viewer_dominoes.py" in names + assert "isaaclab/examples/mpm/newton_mpm_granular.py" in names + assert not any( + name.startswith(("isaaclab/_demos/", "isaaclab/demos/", "isaaclab/_examples/")) for name in names + ) nested_prefix = "isaaclab/source/isaaclab/isaaclab/" assert not any(name.startswith(nested_prefix) for name in names) @@ -164,6 +197,51 @@ def test_python_m_isaaclab_help_works(self): result = self.run_in_uv_env(["python", "-m", "isaaclab", "--help"]) assert result.returncode == 0, f"isaaclab CLI help failed:\n{result.stdout}\n{result.stderr}" + def test_installed_program_catalogs_resolve_private_resources(self): + """Verify the installed CLI resolves private demo resources without a source checkout.""" + result = self.run_in_uv_env( + [ + "python", + "-c", + "from isaaclab.programs import DEMOS, EXAMPLES; " + "assert all(program.path.is_file() for program in (*DEMOS, *EXAMPLES))", + ] + ) + assert result.returncode == 0, f"installed program catalogs are incomplete:\n{result.stdout}\n{result.stderr}" + + def test_installed_programs_expose_help_without_launching(self): + """Verify every packaged program handles ``--help`` before launching simulation.""" + check_help = """ +import contextlib +import io +import sys + +import isaaclab.app as app +from isaaclab.programs import DEMOS, EXAMPLES, _run_script + + +def fail_launch(*args, **kwargs): + raise AssertionError(f"{sys.argv[0]} launched simulation while handling --help") + + +app.AppLauncher.__init__ = fail_launch +app.launch_simulation = fail_launch + +for command, catalog in (("demo", DEMOS), ("example", EXAMPLES)): + for program in catalog: + sys.argv = [f"isaaclab {command} {program.name}", "--help"] + with contextlib.redirect_stdout(io.StringIO()): + try: + _run_script(program.path) + except SystemExit as error: + if error.code != 0: + raise AssertionError(f"{sys.argv[0]} --help exited with {error.code}") from error + else: + raise AssertionError(f"{sys.argv[0]} --help did not exit") +""" + result = self.run_in_uv_env(["python", "-c", check_help]) + assert result.returncode == 0, f"packaged program help failed:\n{result.stdout}\n{result.stderr}" + def test_project_generator_is_bundled(self): """Verify the installed CLI includes the project generator.""" result = self.run_in_uv_env( diff --git a/source/isaaclab/test/sim/test_simulation_context.py b/source/isaaclab/test/sim/test_simulation_context.py index 2617f1078cd8..e5ab209e5ca3 100644 --- a/source/isaaclab/test/sim/test_simulation_context.py +++ b/source/isaaclab/test/sim/test_simulation_context.py @@ -1332,3 +1332,23 @@ def test_remove_render_callback_noop_for_unknown_name(): sim.remove_render_callback("nonexistent") # must not raise SimulationContext.clear_instance() + + +def test_reset_callback_registered_before_construction_fires_on_reset(): + """A class-level reset callback receives each context it resets until it is removed.""" + from unittest.mock import MagicMock + + cb = MagicMock() + SimulationContext.add_reset_callback("test_cb", cb) + try: + sim = SimulationContext(SimulationCfg(dt=0.01)) + sim.reset() + cb.assert_called_once_with(sim) + finally: + SimulationContext.remove_reset_callback("test_cb") + + sim.reset() + cb.assert_called_once() + SimulationContext.remove_reset_callback("nonexistent") # must not raise + + SimulationContext.clear_instance() diff --git a/source/isaaclab_contrib/changelog.d/package-demos.skip b/source/isaaclab_contrib/changelog.d/package-demos.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_contrib/docs/README.md b/source/isaaclab_contrib/docs/README.md index 86fb0b6dcf58..d93823b0b37e 100644 --- a/source/isaaclab_contrib/docs/README.md +++ b/source/isaaclab_contrib/docs/README.md @@ -206,13 +206,13 @@ The `ThrustAction` term provides flexible preprocessing to support all modes thr -### Demo Script +### Standalone Example A complete demonstration of multirotor simulation is available: ```bash -# Run multirotor demo -uv run python scripts/demos/arl_robot_1.py +# Run the multirotor example +uv run --extra isaacsim isaaclab example arl-robot-1 ``` ## TacSL Tactile Sensor (Detailed) @@ -451,20 +451,20 @@ solver_velocity_iteration_count=1 -### Demo Script +### Standalone Example A complete demonstration of TacSL tactile sensor is available: ```bash -# Run TacSL tactile sensor demo with RGB and force field sensing -uv run python scripts/demos/sensors/tacsl_sensor.py \ +# Run the TacSL tactile sensor example with RGB and force field sensing +uv run --extra isaacsim isaaclab example tactile-sensor \ --use_tactile_rgb \ --use_tactile_ff \ --num_envs 16 \ --contact_object_type nut # Save visualization data -uv run python scripts/demos/sensors/tacsl_sensor.py \ +uv run --extra isaacsim isaaclab example tactile-sensor \ --use_tactile_rgb \ --use_tactile_ff \ --save_viz \ diff --git a/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor.py b/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor.py index 395df63b27f0..e0689e637f93 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor.py +++ b/source/isaaclab_contrib/isaaclab_contrib/sensors/tacsl_sensor/visuotactile_sensor.py @@ -55,7 +55,7 @@ class VisuoTactileSensor(SensorBase): to compute normal and shear forces at discrete tactile points. **Example Usage:** - For a complete working example, see: ``scripts/demos/sensors/tacsl/tacsl_example.py`` + Run ``isaaclab example tactile-sensor`` for a complete working example. **Current Limitations:** - SDF collision meshes must be pre-computed and objects specified before simulation starts diff --git a/source/isaaclab_newton/changelog.d/package-demos.skip b/source/isaaclab_newton/changelog.d/package-demos.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/test/sim/test_mpm_spawners.py b/source/isaaclab_newton/test/sim/test_mpm_spawners.py index 4582034e6bb9..a594784f43ff 100644 --- a/source/isaaclab_newton/test/sim/test_mpm_spawners.py +++ b/source/isaaclab_newton/test/sim/test_mpm_spawners.py @@ -130,22 +130,22 @@ def test_mpm_config_imports_do_not_load_pxr(): @pytest.mark.parametrize( "module", [ - "scripts.demos.mpm.newton_mpm_granular", - "scripts.demos.mpm.newton_mpm_twoway_coupling", - "scripts.demos.mpm.snowball_smash", - "scripts.demos.mpm.teapot_fill", + "examples.mpm.newton_mpm_granular", + "examples.mpm.newton_mpm_twoway_coupling", + "examples.demos.snowball_smash", + "examples.demos.teapot_fill", ], ) -def test_mpm_demo_configs_do_not_load_pxr_before_simulation_launch(module): - """Every MPM demo must delay USD imports until after ``AppLauncher`` starts.""" +def test_mpm_program_configs_do_not_load_pxr_before_simulation_launch(module): + """Every MPM program must delay USD imports until after ``AppLauncher`` starts.""" code = textwrap.dedent( f""" import importlib import sys - sys.argv = ["demo.py", "--max_steps", "0", "--visualizer", "none", "--device", "cuda:0"] - demo = importlib.import_module({module!r}) - demo.create_sim_cfg() + sys.argv = ["program.py", "--max_steps", "0", "--visualizer", "none", "--device", "cuda:0"] + program = importlib.import_module({module!r}) + program.create_sim_cfg() loaded_pxr_modules = [name for name in sys.modules if name == "pxr" or name.startswith("pxr.")] if loaded_pxr_modules: diff --git a/source/isaaclab_visualizers/changelog.d/program-browser.minor.rst b/source/isaaclab_visualizers/changelog.d/program-browser.minor.rst new file mode 100644 index 000000000000..1a6250acdddf --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/program-browser.minor.rst @@ -0,0 +1,9 @@ +Added +^^^^^ + +* Added a clickable demo and example selector to Newton GL for packaged Isaac Lab programs. +* Added :meth:`~isaaclab_visualizers.newton.NewtonGLVisualizer.is_key_down` so scripts can read + keyboard input from the Newton viewer window. +* Added :meth:`~isaaclab_visualizers.newton.NewtonGLVisualizer.register_ui_callback` and + :meth:`~isaaclab_visualizers.newton.NewtonGLVisualizer.request_close` so callers can add viewer panels and close + the window safely from inside them. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 4f70bf5e20ce..5f8f87689759 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -12,6 +12,7 @@ import math import os import sys +from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -778,6 +779,7 @@ def __init__(self, *args, metadata: dict | None = None, update_frequency: int = self._patch_image_logger() self.register_ui_callback(self._render_training_controls, position="side") + self._close_requested = False def is_training_paused(self) -> bool: """Return whether simulation is paused by viewer controls.""" @@ -791,6 +793,16 @@ def is_rendering_paused(self) -> bool: """ return self._paused + def request_close(self) -> None: + """Close the window once the current frame ends.""" + self._close_requested = True + + def end_frame(self) -> None: + """Finish the frame, then close the window if :meth:`request_close` was called.""" + super().end_frame() + if self._close_requested: + self.renderer.close() + def on_key_press(self, symbol, modifiers): """Forward key presses unless UI is currently capturing input.""" if self.ui.is_capturing(): @@ -1369,6 +1381,19 @@ def is_rendering_paused(self) -> bool: return False return self._viewer.is_rendering_paused() + def is_key_down(self, key: str) -> bool: + """Return whether a key is held in the viewer window. + + Args: + key: Key name, such as ``"i"`` or ``"space"``. + + Returns: + True if the viewer is open and the key is held, False otherwise. + """ + if not self._is_initialized or self._viewer is None: + return False + return bool(self._viewer.is_key_down(key)) + def set_camera_view( self, eye: tuple[float, float, float] | list[float], target: tuple[float, float, float] | list[float] ) -> None: @@ -2038,6 +2063,25 @@ def _create_viewer(self, runtime_headless: bool, metadata: dict) -> NewtonViewer update_frequency=self.cfg.update_frequency, ) + def register_ui_callback(self, callback: Callable[[Any], None], position: str = "side") -> None: + """Add a panel to the viewer's ImGui interface. + + Args: + callback: Callable invoked with the ``imgui`` module every UI frame. + position: Newton viewer UI slot, such as ``"side"`` or ``"panel"``. + """ + if self._viewer is not None: + self._viewer.register_ui_callback(callback, position=position) + + def request_close(self) -> None: + """Close the viewer window once the current frame ends. + + Safe to call from a UI callback, where closing immediately would destroy the GL context + mid-frame. + """ + if self._viewer is not None: + self._viewer.request_close() + def supports_live_plots(self) -> bool: """Newton GL supports live scalar/array plots via the ImGui sidebar.""" return True diff --git a/source/isaaclab_visualizers/test/test_newton_visualizer_viewer_release.py b/source/isaaclab_visualizers/test/test_newton_visualizer_viewer_release.py index 42353d1934e1..214a373813c2 100644 --- a/source/isaaclab_visualizers/test/test_newton_visualizer_viewer_release.py +++ b/source/isaaclab_visualizers/test/test_newton_visualizer_viewer_release.py @@ -147,6 +147,22 @@ def test_release_viewer_does_not_close_gl_viewer() -> None: assert visualizer._viewer is None +@pytest.mark.parametrize("requested", [False, True]) +def test_gl_close_request_closes_after_frame(monkeypatch: pytest.MonkeyPatch, requested: bool) -> None: + """A close request must not destroy the GL context inside the UI render callback.""" + events: list[str] = [] + monkeypatch.setattr(newton_visualizer.ViewerGL, "end_frame", lambda self: events.append("frame")) + viewer = object.__new__(newton_visualizer.NewtonViewerGL) + viewer._close_requested = False + if requested: + viewer.request_close() + viewer.renderer = type("Renderer", (), {"close": lambda self: events.append("close")})() + + viewer.end_frame() + + assert events == (["frame", "close"] if requested else ["frame"]) + + def test_close_releases_the_viewer() -> None: """``close()`` must release the viewer through the shared path.""" viewer = _SpyRTXViewer() diff --git a/tools/wheel_builder/stage.py b/tools/wheel_builder/stage.py index c7c7a5f364c7..ed24f242080f 100644 --- a/tools/wheel_builder/stage.py +++ b/tools/wheel_builder/stage.py @@ -23,6 +23,7 @@ def stage_package(repo_root: Path, stage_dir: Path, version: str) -> None: package_dir.mkdir(parents=True) shutil.copytree(repo_root / "apps", package_dir / "apps") + shutil.copytree(repo_root / "examples", package_dir / "examples") shutil.copytree(repo_root / "source", package_dir / "source") shutil.copytree(repo_root / "tools" / "template", package_dir / "tools" / "template")