diff --git a/.github/actions/multi-gpu/mgpu_shard_select.py b/.github/actions/multi-gpu/mgpu_shard_select.py index 38d1ac18827c..c31e23760a93 100644 --- a/.github/actions/multi-gpu/mgpu_shard_select.py +++ b/.github/actions/multi-gpu/mgpu_shard_select.py @@ -13,7 +13,7 @@ Signal: ``ISAACLAB_TEST_DEVICES`` -- the runtime device mask. This is the SAME env var ``isaaclab.test.utils.devices.test_devices()`` reads to decide a test's device parametrization, so this plugin's keep/drop decision agrees with what was -parametrized. Kit-backed tests derive their AppLauncher device from the same mask. +parametrized. Kit-backed tests derive their Kit launch device from the same mask. The plugin reads the mask string directly -- no isaaclab import -- matching the lib-free convention of every other conftest in the repo. The only shared knowledge is the mask grammar below, mirrored from ``devices.py`` (position 0 = cpu, diff --git a/.github/actions/run-tests/run_tests.sh b/.github/actions/run-tests/run_tests.sh index aa05831cbb00..3e8545606967 100755 --- a/.github/actions/run-tests/run_tests.sh +++ b/.github/actions/run-tests/run_tests.sh @@ -282,7 +282,7 @@ run_tests() { if [ -n "$ovrtx_shader_cache_host_dir" ]; then # Canonical OVRTX shader cache mount layout; other boundaries refer here. - # host kit/ -> /isaac-sim/kit/cache/nv_shadercache (Kit / AppLauncher rendering) + # host kit/ -> /isaac-sim/kit/cache/nv_shadercache (Kit rendering) # host kitless/ -> OVRTX_SHADER_CACHE_PATH (standalone OVRTXRenderer) # # kit/ is a nested bind mount overlaying the nv_shadercache directory that the diff --git a/.github/workflows/test-multi-gpu-pytest.yaml b/.github/workflows/test-multi-gpu-pytest.yaml index a6387aa865eb..407a43c89916 100644 --- a/.github/workflows/test-multi-gpu-pytest.yaml +++ b/.github/workflows/test-multi-gpu-pytest.yaml @@ -56,7 +56,8 @@ jobs: triggered-jobs-pr: multi-GPU sharded unit tests patterns: | ^source/isaaclab/isaaclab/test/utils/ :: Device-scope test helpers - ^source/isaaclab/isaaclab/app/app_launcher\.py$ :: Device selection at launch + ^source/isaaclab_physx/isaaclab_physx/app/kit_launcher\.py$ :: Device selection at launch + ^source/isaaclab/isaaclab/app/sim_launcher\.py$ :: Device selection at launch ^source/.+/test/.+/test_.*\.py$ :: Device-parametrized test files ^\.github/workflows/test-multi-gpu-pytest\.yaml$ :: This workflow ^\.github/actions/(run-tests|run-package-tests|ecr-build-push-pull|multi-gpu|detect-changes)/ :: Shared actions diff --git a/.github/workflows/test-multi-gpu.yaml b/.github/workflows/test-multi-gpu.yaml index 99f7fd16ceb4..c1a9296b9b3a 100644 --- a/.github/workflows/test-multi-gpu.yaml +++ b/.github/workflows/test-multi-gpu.yaml @@ -17,8 +17,8 @@ name: Multi-GPU Training Tests on: pull_request: paths: - - "source/isaaclab/isaaclab/app/app_launcher.py" - - "source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py" + - "source/isaaclab_physx/isaaclab_physx/app/kit_launcher.py" + - "source/isaaclab/isaaclab/app/sim_launcher.py" - "scripts/reinforcement_learning/train.py" - "source/isaaclab_rl/isaaclab_rl/entrypoints/**" - ".github/workflows/test-multi-gpu.yaml" diff --git a/apps/isaaclab.python.headless.kit b/apps/isaaclab.python.headless.kit index f68c3f39241b..f4fd927084bd 100644 --- a/apps/isaaclab.python.headless.kit +++ b/apps/isaaclab.python.headless.kit @@ -70,7 +70,7 @@ logSysStdOutput = false [settings] # Use one renderer GPU by default. Applications that need single-process multi-GPU rendering can override these -# settings through AppLauncher's ``kit_args``. +# settings through the Kit launcher's ``kit_args``. renderer.multiGpu.enabled = false renderer.multiGpu.autoEnable = false renderer.multiGpu.maxGpuCount = 1 diff --git a/apps/isaaclab.python.kit b/apps/isaaclab.python.kit index 44d64c63d42e..9c5fc6c1e34a 100644 --- a/apps/isaaclab.python.kit +++ b/apps/isaaclab.python.kit @@ -214,7 +214,7 @@ app.asyncRendering = false app.asyncRenderingLowLatency = false # Use one renderer GPU by default. Applications that need single-process multi-GPU rendering can override these -# settings through AppLauncher's ``kit_args``. +# settings through the Kit launcher's ``kit_args``. renderer.multiGpu.enabled = false renderer.multiGpu.autoEnable = false renderer.multiGpu.maxGpuCount = 1 diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 551eb1b88bd4..3792cc5d0b90 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -128,6 +128,7 @@ The following modules are available in the ``isaaclab_physx`` extension: .. autosummary:: :toctree: lab_physx + app assets cloner physics @@ -173,6 +174,7 @@ The following modules are available in the ``isaaclab_ov`` extension: .. autosummary:: :toctree: lab_ov + app assets cloner physics diff --git a/docs/source/api/lab/isaaclab.app.rst b/docs/source/api/lab/isaaclab.app.rst index 8ca0909bfcd9..b5d5a4283861 100644 --- a/docs/source/api/lab/isaaclab.app.rst +++ b/docs/source/api/lab/isaaclab.app.rst @@ -10,168 +10,32 @@ AppLauncher LoadingScreen Scan + SimulationLauncher .. rubric:: Functions .. autosummary:: + add_launcher_args launch_simulation make_physics_cfg report_activity scan -Environment variables ---------------------- - -The following details the behavior of the class based on the environment variables: - -* **Headless mode**: If the environment variable ``HEADLESS=1``, then SimulationApp will be started in headless mode. - If ``LIVESTREAM={1,2}``, then it will supersede the ``HEADLESS`` envvar and force headlessness. - - * ``HEADLESS=1`` causes the app to run in headless mode. - -* **Livestreaming**: If the environment variable ``LIVESTREAM={1,2}`` , then `livestream`_ is enabled. Any - of the livestream modes being true forces the app to run in headless mode. - - * ``LIVESTREAM=1`` enables streaming via the `WebRTC Livestream`_ extension over **public networks**. This allows users to - connect through the WebRTC Client using the WebRTC protocol. - * ``LIVESTREAM=2`` enables streaming via the `WebRTC Livestream`_ extension over **private and local networks**. This allows users to - connect through the WebRTC Client using the WebRTC protocol. - - .. note:: - - Each Isaac Sim instance can only connect to one streaming client. - Connecting to an Isaac Sim instance that is currently serving a streaming client - results in an error for the second user. - -* **Public IP Address**: When using the environment variable ``LIVESTREAM={1,2}``, set the ``PUBLIC_IP`` envvar to define the public IP address endpoint for livestreaming remotely. - -Camera and offscreen rendering support is enabled automatically. No environment variable or command-line -option is required for camera tasks. - - -To set the environment variables, one can use the following command in the terminal: - -.. tab-set:: - :sync-group: os - - .. tab-item:: :icon:`fa-brands fa-linux` Linux x86_64 - :sync: linux-x86_64 - - .. code-block:: bash - - export LIVESTREAM=2 - # run the python script - 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 isaaclab demo zoo --physics isaacsim_physx --viz kit - - .. tab-item:: :icon:`fa-brands fa-linux` Linux aarch64 (DGX Spark) - :sync: linux-aarch64 - - .. code-block:: bash - - export LIVESTREAM=2 - # run the python script - 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 isaaclab demo zoo --physics isaacsim_physx --viz kit - - .. note:: - - Direct Python commands that import Isaac Sim on aarch64 require the - ``LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1`` prefix shown above. See - :ref:`installation-method-uv`. - - .. warning:: - - Livestreaming is not currently supported or validated on DGX Spark. See the - :doc:`/source/setup/installation/index` for the current list of features not - yet validated on this platform. - - .. tab-item:: :icon:`fa-brands fa-windows` Windows - :sync: windows - - In Command Prompt: - - .. code-block:: batch - - set LIVESTREAM=2 - uv run --extra isaacsim isaaclab demo zoo --physics isaacsim_physx --viz kit - - In PowerShell: - - .. code-block:: powershell - - $env:LIVESTREAM = "2" - uv run --extra isaacsim isaaclab demo zoo --physics isaacsim_physx --viz kit - - .. note:: - - The POSIX inline ``VAR=value `` prefix form used on Linux (for example - ``LIVESTREAM=2 uv run ...``) has no Windows equivalent; use one of the two forms - above instead. - - -Overriding the environment variables ------------------------------------- - -The environment variables can be overridden in the python script itself using the :class:`AppLauncher`. -These can be passed as a dictionary, a :class:`argparse.Namespace` object or as keyword arguments. -When the passed arguments are not the default values, then they override the environment variables. - -The following snippet shows how use the :class:`AppLauncher` in different ways: - -.. code:: python - - import argparse - - from isaaclab.app import AppLauncher - - # add argparse arguments - parser = argparse.ArgumentParser() - # add your own arguments - # .... - # add app launcher arguments for cli - AppLauncher.add_app_launcher_args(parser) - # parse arguments - args = parser.parse_args() - - # launch omniverse isaac-sim app - # -- Option 1: Pass the settings as a Namespace object - app_launcher = AppLauncher(args).app - # -- Option 2: Pass the settings as keywords arguments - app_launcher = AppLauncher(headless=args.headless, livestream=args.livestream) - # -- Option 3: Pass the settings as a dictionary - app_launcher = AppLauncher(vars(args)) - # -- Option 4: Pass no settings - app_launcher = AppLauncher() +Simulation Launcher +------------------- - # obtain the launched app - simulation_app = app_launcher.app +.. autofunction:: launch_simulation +.. autofunction:: add_launcher_args -Simulation App Launcher ------------------------ +.. autoclass:: SimulationLauncher + :members: .. autoclass:: AppLauncher :members: - -Simulation Launcher -------------------- - -.. autofunction:: launch_simulation - .. autofunction:: make_physics_cfg .. autofunction:: scan @@ -180,8 +44,6 @@ Simulation Launcher :members: -.. _livestream: https://docs.isaacsim.omniverse.nvidia.com/latest/installation/manual_livestream_clients.html -.. _`WebRTC Livestream`: https://docs.isaacsim.omniverse.nvidia.com/latest/installation/manual_livestream_clients.html#isaac-sim-short-webrtc-streaming-client Additional Public Classes ------------------------- diff --git a/docs/source/api/lab_ov/isaaclab_ov.app.rst b/docs/source/api/lab_ov/isaaclab_ov.app.rst new file mode 100644 index 000000000000..be7cfe4eca41 --- /dev/null +++ b/docs/source/api/lab_ov/isaaclab_ov.app.rst @@ -0,0 +1,9 @@ +isaaclab\_ov.app +================ + +.. automodule:: isaaclab_ov.app + +.. currentmodule:: isaaclab_ov.app + +.. autoclass:: OvrtxLauncher + :show-inheritance: diff --git a/docs/source/api/lab_physx/isaaclab_physx.app.rst b/docs/source/api/lab_physx/isaaclab_physx.app.rst new file mode 100644 index 000000000000..40c4f022f4d4 --- /dev/null +++ b/docs/source/api/lab_physx/isaaclab_physx.app.rst @@ -0,0 +1,170 @@ +isaaclab\_physx.app +=================== + +.. automodule:: isaaclab_physx.app + + .. rubric:: Classes + + .. autosummary:: + + KitLauncher + KitStageBackend + KitStageBackendCfg + + .. rubric:: Functions + + .. autosummary:: + + show_stage_in_viewport + +Environment variables +--------------------- + +The following details the behavior of the class based on the environment variables: + +* **Headless mode**: If the environment variable ``HEADLESS=1``, then SimulationApp will be started in headless mode. + If ``LIVESTREAM={1,2}``, then it will supersede the ``HEADLESS`` envvar and force headlessness. + + * ``HEADLESS=1`` causes the app to run in headless mode. + +* **Livestreaming**: If the environment variable ``LIVESTREAM={1,2}`` , then `livestream`_ is enabled. Any + of the livestream modes being true forces the app to run in headless mode. + + * ``LIVESTREAM=1`` enables streaming via the `WebRTC Livestream`_ extension over **public networks**. This allows users to + connect through the WebRTC Client using the WebRTC protocol. + * ``LIVESTREAM=2`` enables streaming via the `WebRTC Livestream`_ extension over **private and local networks**. This allows users to + connect through the WebRTC Client using the WebRTC protocol. + + .. note:: + + Each Isaac Sim instance can only connect to one streaming client. + Connecting to an Isaac Sim instance that is currently serving a streaming client + results in an error for the second user. + +* **Public IP Address**: When using the environment variable ``LIVESTREAM={1,2}``, set the ``PUBLIC_IP`` envvar to define the public IP address endpoint for livestreaming remotely. + +Camera and offscreen rendering support is enabled automatically. No environment variable or command-line +option is required for camera tasks. + + +To set the environment variables, one can use the following command in the terminal: + +.. tab-set:: + :sync-group: os + + .. tab-item:: :icon:`fa-brands fa-linux` Linux x86_64 + :sync: linux-x86_64 + + .. code-block:: bash + + export LIVESTREAM=2 + # run the python script + 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 isaaclab demo zoo --physics isaacsim_physx --viz kit + + .. tab-item:: :icon:`fa-brands fa-linux` Linux aarch64 (DGX Spark) + :sync: linux-aarch64 + + .. code-block:: bash + + export LIVESTREAM=2 + # run the python script + 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 isaaclab demo zoo --physics isaacsim_physx --viz kit + + .. note:: + + Direct Python commands that import Isaac Sim on aarch64 require the + ``LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1`` prefix shown above. See + :ref:`installation-method-uv`. + + .. warning:: + + Livestreaming is not currently supported or validated on DGX Spark. See the + :doc:`/source/setup/installation/index` for the current list of features not + yet validated on this platform. + + .. tab-item:: :icon:`fa-brands fa-windows` Windows + :sync: windows + + In Command Prompt: + + .. code-block:: batch + + set LIVESTREAM=2 + uv run --extra isaacsim isaaclab demo zoo --physics isaacsim_physx --viz kit + + In PowerShell: + + .. code-block:: powershell + + $env:LIVESTREAM = "2" + uv run --extra isaacsim isaaclab demo zoo --physics isaacsim_physx --viz kit + + .. note:: + + The POSIX inline ``VAR=value `` prefix form used on Linux (for example + ``LIVESTREAM=2 uv run ...``) has no Windows equivalent; use one of the two forms + above instead. + + +Overriding the environment variables +------------------------------------ + +Scripts do not construct the :class:`KitLauncher` themselves. :func:`~isaaclab.app.launch_simulation` +constructs it when the resolved configuration needs Isaac Sim / Kit, and passes it the launcher arguments. +Arguments that are not at their default values override the environment variables. They can be passed as +an :class:`argparse.Namespace` or as a dictionary: + +.. code:: python + + import argparse + + from isaaclab.app import add_launcher_args, launch_simulation + + parser = argparse.ArgumentParser() + # add your own arguments + # .... + # add the launcher arguments for the command line + add_launcher_args(parser) + args = parser.parse_args() + + # -- Option 1: pass the parsed arguments + with launch_simulation(env_cfg, args): + ... + # -- Option 2: pass the settings as a dictionary + with launch_simulation(env_cfg, {"livestream": 2, "enable_cameras": True}): + ... + + +Kit Launcher +------------ + +.. autoclass:: KitLauncher + :members: + :show-inheritance: + +Kit Stage +--------- + +.. autoclass:: KitStageBackend + :members: + +.. autoclass:: KitStageBackendCfg + :members: + :show-inheritance: + +.. autofunction:: show_stage_in_viewport + +.. _livestream: https://docs.isaacsim.omniverse.nvidia.com/latest/installation/manual_livestream_clients.html +.. _`WebRTC Livestream`: https://docs.isaacsim.omniverse.nvidia.com/latest/installation/manual_livestream_clients.html#isaac-sim-short-webrtc-streaming-client diff --git a/docs/source/developer-tools/scene_data_providers.rst b/docs/source/developer-tools/scene_data_providers.rst index 170ff4085d83..ceba37ba869f 100644 --- a/docs/source/developer-tools/scene_data_providers.rst +++ b/docs/source/developer-tools/scene_data_providers.rst @@ -100,7 +100,6 @@ The system has three layers: consumer's buffers, and expire with the destination. - :meth:`SceneDataProvider.get_camera_transforms`: discovers per-camera, per-env world transforms from the USD stage. - - :attr:`SceneDataProvider.usd_stage`: USD stage handle for stage-walking consumers. - :attr:`SceneDataProvider.num_envs`: environment count inferred from ``/World/envs/env_`` prims. diff --git a/docs/source/migration/migrating_to_isaaclab_3-0.rst b/docs/source/migration/migrating_to_isaaclab_3-0.rst index 9f7f8149a357..a1f94152efd6 100644 --- a/docs/source/migration/migrating_to_isaaclab_3-0.rst +++ b/docs/source/migration/migrating_to_isaaclab_3-0.rst @@ -839,7 +839,7 @@ replacement, prefer the Isaac Lab API** over the ``isaacsim.core.experimental.*` * - ``isaacsim.core.utils.stage`` - :mod:`isaaclab.sim.utils.stage` (e.g. ``get_current_stage``, ``create_new_stage``, ``open_stage``, ``save_stage``, ``close_stage``, - ``clear_stage``, ``update_stage``, ``use_stage``) + ``clear_stage``, ``use_stage``) * - ``isaacsim.core.utils.prims`` - :mod:`isaaclab.sim.utils.prims` (e.g. ``create_prim``, ``delete_prim``, ``change_prim_property``, ``bind_visual_material``, diff --git a/scripts/tools/convert_mesh.py b/scripts/tools/convert_mesh.py index c9584f8b836c..ef4692c7db7f 100644 --- a/scripts/tools/convert_mesh.py +++ b/scripts/tools/convert_mesh.py @@ -93,7 +93,8 @@ import os -import isaaclab.sim as sim_utils +from isaaclab_physx.app import show_stage_in_viewport + from isaaclab.sim.converters import MeshConverter, MeshConverterCfg from isaaclab.sim.schemas import schemas_cfg from isaaclab.utils.assets import check_file_path @@ -182,7 +183,7 @@ def main(): # Show the converted asset if the launch resolved to a window or livestream if AppLauncher.has_gui(): - sim_utils.show_stage_in_viewport(mesh_converter.usd_path) + show_stage_in_viewport(mesh_converter.usd_path) if __name__ == "__main__": diff --git a/scripts/tools/convert_mjcf.py b/scripts/tools/convert_mjcf.py index aff1ee0fec0e..b5359563075a 100644 --- a/scripts/tools/convert_mjcf.py +++ b/scripts/tools/convert_mjcf.py @@ -123,7 +123,9 @@ def preview(usd_path: str, physics_cfg: PhysicsCfg) -> None: if "kit" in visualizers: # a Kit app that resolved without a GUI has no viewport to display the asset in if AppLauncher.has_gui(): - sim_utils.show_stage_in_viewport(usd_path) + from isaaclab_physx.app import show_stage_in_viewport + + show_stage_in_viewport(usd_path) return # Kitless preview: the physics backend ingests the USD stage and every visualizer renders the diff --git a/scripts/tools/convert_urdf.py b/scripts/tools/convert_urdf.py index 2c0ceba8fb78..77906ba17ba7 100644 --- a/scripts/tools/convert_urdf.py +++ b/scripts/tools/convert_urdf.py @@ -127,7 +127,9 @@ def preview(usd_path: str, physics_cfg: PhysicsCfg) -> None: if "kit" in visualizers: # a Kit app that resolved without a GUI has no viewport to display the asset in if AppLauncher.has_gui(): - sim_utils.show_stage_in_viewport(usd_path) + from isaaclab_physx.app import show_stage_in_viewport + + show_stage_in_viewport(usd_path) return # Kitless preview: the physics backend ingests the USD stage and every visualizer renders the diff --git a/source/isaaclab/changelog.d/minimal-kit-exit-lifecycle.major.rst b/source/isaaclab/changelog.d/minimal-kit-exit-lifecycle.major.rst new file mode 100644 index 000000000000..d6d54f3112a5 --- /dev/null +++ b/source/isaaclab/changelog.d/minimal-kit-exit-lifecycle.major.rst @@ -0,0 +1,54 @@ +Added +^^^^^ + +* Added :class:`~isaaclab.app.SimulationLauncher` for backend runtimes and + :func:`isaaclab.test.utils.launch_test_simulation` to start Kit in test modules. +* Added ``class_type`` to :class:`~isaaclab.envs.ManagerBasedEnvCfg` and :class:`~isaaclab.envs.ManagerBasedRLEnvCfg`, + so ``instantiate(env_cfg)`` constructs the environment without importing its class. + +Changed +^^^^^^^ + +* Changed :func:`~isaaclab.app.launch_simulation` to start each runtime through the launcher its resolved + config names in ``launcher_type``, and Isaac Sim / Kit for Kit needs no config names, such as ``--viz kit``. +* Changed Kit exit handling: an unhandled exception exits with 1 and ``SIGINT`` raises + :class:`KeyboardInterrupt`; ``SIGTERM``, ``SIGABRT``, and ``SIGSEGV`` keep their default actions. +* Changed ``seed()`` of the environments to no longer seed Replicator, so core modules no longer import + ``omni.replicator``. The Replicator event terms seed it with ``env.cfg.seed`` instead; call + ``omni.replicator.core.set_global_seed`` directly to reseed Replicator after the environment is created. +* Changed :class:`~isaaclab.sim.SimulationContext` to attach its stage to Kit's USD context through the + :class:`~isaaclab_physx.app.KitStageBackendCfg` backend, which closes it with the simulation. + :func:`~isaaclab.sim.utils.clear_stage` and :func:`~isaaclab.sim.utils.close_stage` no longer run Kit app + updates or close Kit's USD context themselves. +* Changed :meth:`~isaaclab.sim.SimulationContext.clear_instance` to close native backends newest first, so a + backend closes before the backends it was created on top of. + +Deprecated +^^^^^^^^^^ + +* Deprecated ``isaaclab.app.AppLauncher``; it now starts Isaac Sim / Kit through + :func:`~isaaclab.app.launch_simulation`. Migrate to :func:`~isaaclab.app.add_launcher_args` plus + ``with launch_simulation(cfg, args_cli):``. + +Fixed +^^^^^ + +* Fixed ``{DIR}`` in an inherited ``class_type`` resolving against the module of a config subclass that is + missing ``@configclass``, which made ``instantiate(cfg)`` fail with ``ModuleNotFoundError``. + +Removed +^^^^^^^ + +* **Breaking:** Removed ``SceneDataProvider.usd_stage`` and ``SceneDataProvider.get_usd_stage()``; use + ``SimulationContext.instance().stage``. +* **Breaking:** Removed ``isaaclab.sim.utils.is_current_stage_in_memory``; compare the current stage against + :attr:`~isaaclab.sim.SimulationContext.stage` instead. +* **Breaking:** Removed ``isaaclab.sim.utils.update_stage``. The simulation's reset, step, and render process + stage changes; remove the calls. +* **Breaking:** Removed ``isaaclab.sim.utils.show_stage_in_viewport``; use + :func:`isaaclab_physx.app.show_stage_in_viewport`, which requires a running Kit app. +* **Breaking:** Removed ``SettingsManager.initialize_carb_settings``, the module-level + ``initialize_carb_settings``, and ``SettingsManager.is_omniverse_mode``. The Kit launcher sets the + ``carb.settings`` backend through :meth:`~isaaclab.app.SettingsManager.set_backend`; use + :func:`~isaaclab.utils.version.has_kit` to check for Kit. +* **Breaking:** Removed the hidden ``--cpu`` launcher argument; use ``--device cpu``. diff --git a/source/isaaclab/isaaclab/__init__.py b/source/isaaclab/isaaclab/__init__.py index 75aa55b5c752..c4c738991572 100644 --- a/source/isaaclab/isaaclab/__init__.py +++ b/source/isaaclab/isaaclab/__init__.py @@ -115,7 +115,7 @@ def _expose_newton_usd_schemas(): registry. Expose the installed plugin during core initialization so kitless processes find it without importing ``newton_usd_schemas``, which eagerly imports ``pxr``. Core is the only lifecycle point guaranteed to precede both - direct OpenUSD use and AppLauncher. + direct OpenUSD use and the Kit launcher. """ # TODO: Replace this wheel-layout probe with the host-safe discovery from # https://github.com/newton-physics/newton-usd-schemas/issues/90. diff --git a/source/isaaclab/isaaclab/app/__init__.pyi b/source/isaaclab/isaaclab/app/__init__.pyi index 294ce1f85869..5cdf56e10f14 100644 --- a/source/isaaclab/isaaclab/app/__init__.pyi +++ b/source/isaaclab/isaaclab/app/__init__.pyi @@ -5,9 +5,9 @@ __all__ = [ "AppLauncher", + "SimulationLauncher", "SettingsManager", "get_settings_manager", - "initialize_carb_settings", "add_launcher_args", "launch_simulation", "make_physics_cfg", @@ -19,5 +19,12 @@ __all__ = [ from .app_launcher import AppLauncher from .loading_screen import LoadingScreen, report_activity -from .settings_manager import SettingsManager, get_settings_manager, initialize_carb_settings -from .sim_launcher import Scan, add_launcher_args, launch_simulation, make_physics_cfg, scan +from .settings_manager import SettingsManager, get_settings_manager +from .sim_launcher import ( + Scan, + SimulationLauncher, + add_launcher_args, + launch_simulation, + make_physics_cfg, + scan, +) diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index b6134e852e82..db4d9ff8bef6 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -3,1608 +3,79 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Sub-package with the utility class to configure the :class:`isaacsim.simulation_app.SimulationApp`. - -The :class:`AppLauncher` parses environment variables and input CLI arguments to launch the simulator in -various different modes. This includes with or without GUI and switching between different Omniverse remote -clients. Some of these require the extensions to be loaded in a specific order, otherwise a segmentation -fault occurs. The launched :class:`isaacsim.simulation_app.SimulationApp` instance is accessible via the -:attr:`AppLauncher.app` property. -""" +"""Deprecated adapter that starts Isaac Sim / Kit through :func:`~isaaclab.app.launch_simulation`.""" from __future__ import annotations import argparse -import atexit import contextlib -import importlib.util -import inspect import logging -import os -import re -import signal -import sys -from typing import Any, Literal - -try: - import isaacsim # noqa: F401 -except ModuleNotFoundError: - isaacsim = None +from typing import Any -SimulationApp = getattr(isaacsim, "SimulationApp", None) +from .settings_manager import get_settings_manager +from .sim_launcher import _KIT_LAUNCHER, add_launcher_args, launch_simulation -from ..paths import ISAACLAB_ROOT -from ..utils._device import set_cuda_device -from ..utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING -from .loading_screen import report_activity -from .logging_utils import apply_python_logging_level, resolve_python_logging_level -from .settings_manager import get_settings_manager, initialize_carb_settings - -# import logger logger = logging.getLogger(__name__) -_FABRIC_GPU_INTEROP_ENV = "ISAACLAB_FABRIC_USE_GPU_INTEROP" - -# Suppress noisy debug-level logs from third-party libraries -logging.getLogger("websockets").setLevel(logging.WARNING) -logging.getLogger("matplotlib").setLevel(logging.WARNING) -logging.getLogger("h5py").setLevel(logging.WARNING) - - -def _sanitize_sys_argv_for_kit(argv: list[str]) -> list[str]: - """Remove pytest arguments that Kit would otherwise interpret.""" - if "pytest" not in sys.modules: - return argv - - indexes_to_remove: set[int] = set() - for index, argument in enumerate(argv): - if argument == "-m" and index + 1 < len(argv): - marker_expression = argv[index + 1] - if any(marker in marker_expression for marker in ("pytest", "isaacsim_ci", "windows_ci", "arm_ci")): - indexes_to_remove.update((index, index + 1)) - elif ( - (argument.startswith("--config-file=") and "pyproject.toml" in argument) - or argument == "--capture=no" - or re.fullmatch(r"-v+", argument) - ): - indexes_to_remove.add(index) - return [argument for index, argument in enumerate(argv) if index not in indexes_to_remove] +_RUNTIME = contextlib.ExitStack() +"""Runtimes the adapter started; held for the process so they outlive the ``AppLauncher`` object.""" -class ExplicitAction(argparse.Action): - """Custom action to track if an argument was explicitly passed by the user.""" +class _LaunchedApp: + """The running Kit app; ``close()`` stops the runtime the adapter started.""" - def __call__(self, parser, namespace, values, option_string=None): - # Set the parameter value - setattr(namespace, self.dest, values) - # Set a flag indicating the parameter was explicitly passed - setattr(namespace, f"{self.dest}_explicit", True) + def __init__(self, app: Any): + self._app = app + def close(self, *args, **kwargs) -> None: + _RUNTIME.close() -class ExplicitTrueAction(argparse.Action): - """Custom action to track explicit use of boolean flags.""" + def is_exiting(self) -> bool: + return not self._app.is_running() - def __init__(self, option_strings, dest, default=False, required=False, help=None): - super().__init__( - option_strings=option_strings, dest=dest, nargs=0, default=default, required=required, help=help - ) - - def __call__(self, parser, namespace, values, option_string=None): - setattr(namespace, self.dest, True) - setattr(namespace, f"{self.dest}_explicit", True) + def __getattr__(self, name: str) -> Any: + return getattr(self._app, name) class AppLauncher: - """A utility class to launch Isaac Sim application based on command-line arguments and environment variables. - - The class resolves the simulation app settings that appear through environments variables, - command-line arguments (CLI) or as input keyword arguments. Based on these settings, it launches the - simulation app and configures the extensions to load (as a part of post-launch setup). - - The input arguments provided to the class are given higher priority than the values set - from the corresponding environment variables. This provides flexibility to deal with different - users' preferences. - - .. note:: - Explicitly defined arguments are only given priority when their value is set to something outside - their default configuration. For example, the ``livestream`` argument is -1 by default. It only - overrides the ``LIVESTREAM`` environment variable when ``livestream`` argument is set to a - value >-1. In other words, if ``livestream=-1``, then the value from the environment variable - ``LIVESTREAM`` is used. - - The ``ISAACLAB_FABRIC_USE_GPU_INTEROP`` environment variable optionally overrides the - ``/physics/fabricUseGPUInterop`` Kit setting. Set it to ``1`` or ``0`` to enable or disable the setting. - When unset, Kit's configured default is preserved. + """Start Isaac Sim / Kit for scripts that have not moved to :func:`~isaaclab.app.launch_simulation`. + .. deprecated:: + Use :func:`~isaaclab.app.add_launcher_args` and ``with launch_simulation(cfg, args_cli):``, which + start only the runtime the config needs. """ - @staticmethod - def sync_visualizer_cli_settings_to_carb(launcher_args: dict) -> None: - """Write visualizer CLI selection and ``--max_visible_envs`` to carb settings. - - Callers may set ``visualizer_explicit`` / ``visualizer_disable_all`` when those values - were resolved elsewhere (e.g. :class:`AppLauncher` strips flags from *launcher_args*). - Otherwise ``disable_all`` is inferred from ``"none"`` in ``visualizer``. - - Also used when Kit is skipped (see :mod:`isaaclab.app.sim_launcher`). - """ - visualizers = launcher_args.get("visualizer") - - if "max_visible_envs" in launcher_args: - v = launcher_args["max_visible_envs"] - if v is not None and int(v) < 0: - raise ValueError(f"Invalid value for --max_visible_envs: {v}. Expected non-negative int.") - - cli_explicit = bool(launcher_args.get("visualizer_explicit", False)) - if "visualizer_disable_all" in launcher_args: - cli_disable_all = bool(launcher_args["visualizer_disable_all"]) - else: - cli_disable_all = bool(cli_explicit) and visualizers is not None and "none" in visualizers - - with contextlib.suppress(Exception): - visualizer_str = " ".join(visualizers) if visualizers else "" - settings = get_settings_manager() - settings.set_string("/isaaclab/visualizer/types", visualizer_str) - settings.set_bool("/isaaclab/visualizer/explicit", cli_explicit) - settings.set_bool("/isaaclab/visualizer/disable_all", cli_disable_all) - - # Sentinel: ``-1`` means ``--max_visible_envs`` was not passed (see ``SimulationContext``). - if "max_visible_envs" in launcher_args: - settings.set_int("/isaaclab/visualizer/max_visible_envs", int(launcher_args["max_visible_envs"])) - else: - settings.set_int("/isaaclab/visualizer/max_visible_envs", -1) - - @staticmethod - def _parse_visualizer_csv(value: str) -> list[str] | None: - """Parse visualizer list from a single comma-delimited CLI token.""" - _deprecated_aliases = {"newton": "newton_gl"} - valid = {"kit", "newton_gl", "newton_rtx", "rerun", "viser", "none"} | set(_deprecated_aliases) - token = (value or "").strip() - if not token: - raise argparse.ArgumentTypeError( - "Invalid --visualizer value: empty string. Use a comma-separated list, e.g. --viz kit,newton_gl." - ) - if " " in token: - raise argparse.ArgumentTypeError( - "Invalid --visualizer value: spaces are not allowed. " - "Use a comma-separated list without spaces, e.g. --viz kit,newton_gl,rerun,viser." - ) - - names = [item.strip().lower() for item in token.split(",")] - if any(not name for name in names): - raise argparse.ArgumentTypeError( - "Invalid --visualizer value: empty visualizer entry detected. " - "Use a comma-separated list without empty items." - ) - invalid = [name for name in names if name not in valid] - if invalid: - raise argparse.ArgumentTypeError( - f"Invalid --visualizer value(s): {', '.join(invalid)}. " - f"Valid options: {', '.join(sorted(valid - set(_deprecated_aliases)))}." - ) - # Resolve deprecated aliases with a warning. - resolved = [] - for name in names: - if name in _deprecated_aliases: - canonical = _deprecated_aliases[name] - import warnings - - warnings.warn( - f"--viz '{name}' is deprecated. Use '--viz {canonical}' instead.", - DeprecationWarning, - stacklevel=3, - ) - resolved.append(canonical) - else: - resolved.append(name) - if "none" in resolved: - if len(resolved) > 1: - raise argparse.ArgumentTypeError( - "Invalid --visualizer value: 'none' cannot be combined with other visualizer types." - ) - return None - # De-duplicate while preserving order. - return list(dict.fromkeys(resolved)) - - @staticmethod - def _normalize_visualizer_intent(intent: Any) -> tuple[bool, bool]: - """Normalize and validate upstream config visualizer intent payload. - - The expected schema is: - ``{"has_any_visualizers": bool, "has_kit_visualizer": bool}``. - """ - if intent is None: - return False, False - if not isinstance(intent, dict): - raise ValueError("Invalid value for `visualizer_intent`: expected dict or None.") - - has_any = intent.get("has_any_visualizers", False) - has_kit = intent.get("has_kit_visualizer", False) - if not isinstance(has_any, bool) or not isinstance(has_kit, bool): - raise ValueError( - "Invalid `visualizer_intent` values: expected booleans for `has_any_visualizers` and " - "`has_kit_visualizer`." - ) - if has_kit and not has_any: - raise ValueError( - "Invalid `visualizer_intent`: `has_kit_visualizer=True` requires `has_any_visualizers=True`." - ) - return has_any, has_kit - - @staticmethod - def _ensure_isaaclab_info_stream_handler() -> None: - """Add a stream handler for Isaac Lab INFO records hidden by Kit logging.""" - handler_name = "isaaclab_info_stream" - root_logger = logging.getLogger() - if any(getattr(handler, "name", None) == handler_name for handler in root_logger.handlers): - return - - class _IsaacLabInfoFilter(logging.Filter): - def filter(self, record: logging.LogRecord) -> bool: - return record.levelno == logging.INFO and record.name.startswith("isaaclab") - - handler = logging.StreamHandler(sys.stdout) - handler.name = handler_name - handler.setLevel(logging.INFO) - handler.addFilter(_IsaacLabInfoFilter()) - handler.setFormatter(logging.Formatter("[INFO]: %(message)s")) - root_logger.addHandler(handler) - def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwargs): - """Create a `SimulationApp`_ instance based on the input settings. - - Args: - launcher_args: Input arguments to parse using the AppLauncher and set into the SimulationApp. - Defaults to None, which is equivalent to passing an empty dictionary. A detailed description of - the possible arguments is available in the `SimulationApp`_ documentation. - **kwargs : Additional keyword arguments that will be merged into :attr:`launcher_args`. - They serve as a convenience for those who want to pass some arguments using the argparse - interface and others directly into the AppLauncher. Duplicated arguments with - the :attr:`launcher_args` will raise a ValueError. - - Raises: - ImportError: If the full Isaac Sim runtime is unavailable. - ValueError: If there are common/duplicated arguments between ``launcher_args`` and ``kwargs``. - ValueError: If combination of ``launcher_args`` and ``kwargs`` are missing the necessary arguments - that are needed by the AppLauncher to resolve the desired app configuration. - ValueError: If incompatible or undefined values are assigned to relevant environment values, - such as ``LIVESTREAM``. - - .. _argparse.Namespace: https://docs.python.org/3/library/argparse.html?highlight=namespace#argparse.Namespace - .. _SimulationApp: https://docs.isaacsim.omniverse.nvidia.com/latest/py/source/extensions/isaacsim.simulation_app/docs/index.html#isaacsim.simulation_app.SimulationApp - """ - if not self.is_available(): - raise ImportError( - "AppLauncher requires the full Isaac Sim runtime. Install Isaac Sim or avoid constructing " - "AppLauncher in a kitless process." - ) - - # We allow users to pass either a dict or an argparse.Namespace into - # __init__, anticipating that these will be all of the argparse arguments - # used by the calling script. Those which we appended via add_app_launcher_args - # will be used to control extension loading logic. Additional arguments are allowed, - # and will be passed directly to the SimulationApp initialization. - # - # We could potentially require users to enter each argument they want passed here - # as a kwarg, but this would require them to pass livestream, display settings, and - # any other options we choose to add here explicitly, and with the correct keywords. - # - # @hunter: I feel that this is cumbersome and could introduce error, and would prefer to do - # some sanity checking in the add_app_launcher_args function - if launcher_args is None: - launcher_args = {} - elif isinstance(launcher_args, argparse.Namespace): - launcher_args = launcher_args.__dict__ - - # Check that arguments are unique - if len(kwargs) > 0: - if not set(kwargs.keys()).isdisjoint(launcher_args.keys()): - overlapping_args = set(kwargs.keys()).intersection(launcher_args.keys()) - raise ValueError( - f"Input `launcher_args` and `kwargs` both provided common attributes: {overlapping_args}." - " Please ensure that each argument is supplied to only one of them, as the AppLauncher cannot" - " discern priority between them." - ) - launcher_args.update(kwargs) - - # Preserve the Python logging intent before Kit installs its own logging bridge. - self._python_logging_level = resolve_python_logging_level(launcher_args) - - # Define config members that are read from env-vars or keyword args - self._headless: bool # 0: GUI, 1: Headless - self._livestream: Literal[0, 1, 2] # 0: Disabled, 1: WebRTC public, 2: WebRTC private - self._offscreen_render: bool # 0: Disabled, 1: Enabled - self._sim_experience_file: str # Experience file to load - self._video_enabled: bool # Whether --video recording is enabled - - # Exposed to train scripts - self.device_id: int # device ID for GPU simulation (defaults to 0) - self.device: str # resolved device string (e.g. "cuda:0" or "cpu") - self._deferred_cuda_device_id: int | None = None - self.local_rank: int # local rank of GPUs in the current node - self.global_rank: int # global rank for multi-node training - - # Integrate env-vars and input keyword args into simulation app config - self._config_resolution(launcher_args) - - # PyTorch may already have been imported while constructing simulation configs. Drain - # its deferred CUDA capability checks before Kit changes the set of CUDA devices that - # correspond to Vulkan-capable GPUs. Otherwise a queued check for a device that Kit - # filters out fails during the post-Kit ``set_device`` call (for example, device=1 with - # two CUDA GPUs but only GPU 0 attached to the display). - self._initialize_preloaded_torch_cuda() - - # Create SimulationApp, passing the resolved self._config to it for initialization - self._create_app() - self._set_deferred_cuda_device() - # Load IsaacSim extensions - self._load_extensions() - - # Re-run path sanitization. Kit and its extensions may have inserted - # additional ``pip_prebundle`` or conflicting extension directories onto - # ``sys.path`` during startup. A second pass ensures pip-installed - # packages still take priority over bundled copies. - from .. import _deprioritize_prebundle_paths - - _deprioritize_prebundle_paths() - - # Hide the stop button in the toolbar - self._hide_stop_button() - # Set animation recording settings - self._set_animation_recording_settings(launcher_args) - # Set visualizer settings (if requested) - self._set_visualizer_settings(launcher_args) - - # Hide play button callback if the timeline is stopped - import omni.timeline - - self._hide_play_button_callback = ( - omni.timeline.get_timeline_interface() - .get_timeline_event_stream() - .create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.STOP), lambda e: self._hide_play_button(True) - ) - ) - self._unhide_play_button_callback = ( - omni.timeline.get_timeline_interface() - .get_timeline_event_stream() - .create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.PLAY), lambda e: self._hide_play_button(False) - ) - ) - # Report this process's lifecycle to whatever supervises it: the CI runner watches - # the startup announcements, and distributed launchers/schedulers/CI read the exit - # status. See the class docstring of :class:`_SimulationAppLifecycle` for the full - # exit-path policy and its rationale. - self._lifecycle = AppLauncher._SimulationAppLifecycle(self._app) - self._lifecycle.announce_startup() - self._lifecycle.install_exit_handlers() - - """ - Properties. - """ - - @property - def app(self) -> SimulationApp: - """The launched SimulationApp.""" - if self._app is not None: - return self._app - else: - raise RuntimeError("The `AppLauncher.app` member cannot be retrieved until the class is initialized.") - - """ - Operations. - """ - - @classmethod - def is_available(cls) -> bool: - """Return whether the full Isaac Sim runtime is importable, i.e. Kit can be launched. - - This reports launchability, not running state: it is ``True`` in any process with a - full Isaac Sim installation, before and after Kit starts. Use - :func:`~isaaclab.utils.version.has_kit` to check whether Kit is currently running. - """ - return SimulationApp is not None - - @classmethod - def has_gui(cls) -> bool: - """Return whether the resolved app state has an interactive GUI. - - ``True`` when the launch resolved to a local window, a livestream, or an XR session. - AppLauncher publishes this as the ``/isaaclab/has_gui`` setting during initialization, - so the value is ``False`` before any launcher has run in the process. - """ - return bool(get_settings_manager().get("/isaaclab/has_gui")) - - @property - def has_window(self) -> bool: - """Whether a local window exists that can render UI and receive input. - - ``True`` when a windowed visualizer was requested, or when livestreaming, which - renders a window and forwards input from the remote client. Use this rather than - :meth:`has_gui` to decide whether local UI-driven features such as keyboard - bindings are available: livestreaming runs the host headless yet still presents - an interactive window, while XR without an explicit windowed visualizer does not - open a local window. - """ - return not self._headless or self._livestream >= 1 - - @staticmethod - def _fuse_kit_args(argv: list[str]) -> list[str]: - """Fuse ``["--kit_args", ""]`` pairs into single ``--kit_args=`` tokens. - - Argparse rejects a value token that itself looks like an option (starts with ``-`` and contains - no space) with "expected one argument", and Kit arguments always start with ``--``. Fusing the - pair into the ``=``-attached form before parsing makes the documented space-separated form work - for a single Kit argument. All other forms pass through unchanged. - - Args: - argv: Command-line tokens, excluding the program name. - - Returns: - Tokens with any affected pair replaced by one fused token. - """ - fused: list[str] = [] - index = 0 - while index < len(argv): - token = argv[index] - next_token = argv[index + 1] if index + 1 < len(argv) else None - if ( - token == "--kit_args" - and next_token is not None - and next_token.startswith("-") - and " " not in next_token - ): - fused.append(f"--kit_args={next_token}") - index += 2 - else: - fused.append(token) - index += 1 - return fused + logger.warning("AppLauncher is deprecated; use isaaclab.app.launch_simulation.") + # update the caller's namespace in place, as scripts read resolved values such as ``headless`` from it + args = vars(launcher_args) if isinstance(launcher_args, argparse.Namespace) else dict(launcher_args or {}) + args.update(kwargs, require_kit=True) + # Kit cannot share the process with the OVRTX visualizer; drop it as Kit did when it failed to load it + if "newton_rtx" in (args.get("visualizer") or []): + logger.warning("AppLauncher starts Kit, which cannot run the 'newton_rtx' visualizer; skipping it.") + args["visualizer"] = [v for v in args["visualizer"] if v != "newton_rtx"] + _RUNTIME.enter_context(launch_simulation(None, args)) + self.device = args.get("device") + self.has_window = not args["headless"] or bool(args.get("livestream", 0)) + + import omni.kit.app + + self.app = _LaunchedApp(omni.kit.app.get_app()) @staticmethod def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: - """Utility function to configure AppLauncher arguments with an existing argument parser object. - - This function takes an ``argparse.ArgumentParser`` object and does some sanity checking on the existing - arguments for ingestion by the SimulationApp. It then appends custom command-line arguments relevant - to the SimulationApp to the input :class:`argparse.ArgumentParser` instance. This allows overriding the - environment variables using command-line arguments. - - Currently, it adds the following parameters to the argparser object: - - * ``livestream`` (int): If one of {1, 2}, then livestreaming and headless mode is enabled. The values - map the same as that for the ``LIVESTREAM`` environment variable. If :obj:`-1`, then livestreaming is - determined by the ``LIVESTREAM`` environment variable. - Valid options are: - - - ``0``: Disabled - - ``1``: `WebRTC`_ over public network - - ``2``: `WebRTC`_ over local/private network - - * ``device`` (str): The device to run the simulation on. - Valid options are: - - - ``cpu``: Use CPU. - - ``cuda``: Use GPU with device ID ``0``. - - ``cuda:N``: Use GPU, where N is the device ID. For example, "cuda:0". - - * ``experience`` (str): The experience file to load when launching the SimulationApp. If a relative path - is provided, it is resolved relative to the ``apps`` folder in Isaac Sim and Isaac Lab (in that order). - - If provided as an empty string, the experience file is selected from the resolved visualizer and XR - settings. Rendering support is available by default, including in headless execution. - - * ``deterministic`` (bool): Publishes ``/isaaclab/render/deterministic`` for reproducible rendering. - Does not change how the default experience file is chosen. - - * ``kit_args`` (str): Optional command line arguments to be passed to Omniverse Kit directly. - Arguments should be combined into a single string separated by space. - Example usage: --kit_args "--ext-folder=/path/to/ext1 --ext-folder=/path/to/ext2" - A single Kit argument works in both the space-separated and the ``=``-attached form - (e.g. ``--kit_args "--ext-folder=/path/to/ext1"`` or ``--kit_args=--ext-folder=/path/to/ext1``). - Isaac Lab experiences use one renderer GPU by default. Applications that need single-process - multi-GPU rendering can override the ``renderer.multiGpu`` settings through this argument. - - * ``visualizer`` (str): Visualizer backends to enable. - Valid options are: - - - ``rerun``: Use Rerun visualizer. - - ``newton_gl``: Use Newton GL visualizer. - - ``newton_rtx``: Use Newton RTX path-tracer visualizer (experimental). - - ``viser``: Use Viser visualizer. - - ``kit``: Use Omniverse Kit visualizer. - - ``none``: Disable all visualizers explicitly. - - Multiple visualizers can be specified as a comma-delimited list: - ``--viz rerun,newton_gl,viser``. - - .. deprecated:: Use ``newton_gl`` instead of ``newton``. - - * ``max_visible_envs`` (int | None): Optional global override for partial visualization by capping - how many environments are shown in the visualizers. - More partial visualization configuration fields are available in the ``VisualizerCfg`` class. - - .. _`WebRTC`: https://docs.isaacsim.omniverse.nvidia.com/latest/installation/manual_livestream_clients.html#isaac-sim-short-webrtc-streaming-client - - Args: - parser: An argument parser instance to be extended with the AppLauncher specific options. - """ - # argparse rejects an option-like value token after "--kit_args"; fuse the pair before - # anything parses the command line so the space-separated form works on every entry point - sys.argv[1:] = AppLauncher._fuse_kit_args(sys.argv[1:]) - - # If the passed parser has an existing _HelpAction when passed, - # we here remove the options which would invoke it, - # to be added back after the additional AppLauncher args - # have been added. This is equivalent to - # initially constructing the ArgParser with add_help=False, - # but this means we don't have to require that behavior - # in users and can handle it on our end. - # We do this because calling parse_known_args() will handle - # any -h/--help options being passed and then exit immediately, - # before the additional arguments can be added to the help readout. - parser_help = None - if len(parser._actions) > 0 and isinstance(parser._actions[0], argparse._HelpAction): # type: ignore - parser_help = parser._actions[0] - parser._option_string_actions.pop("-h") - parser._option_string_actions.pop("--help") - - # Collect the declared arguments for potential name collisions/type mismatches between the - # config fields SimulationApp expects and the ArgParse arguments that the user added. Read - # from the parser rather than by parsing the command line: parsing exits the process when a - # required argument is missing, which is the case for any script with required positionals - # invoked with '--help', and the launcher arguments would never reach the help output. - config = {action.dest: action.default for action in parser._actions if action.dest != argparse.SUPPRESS} - if len(config) == 0: - logger.warning( - "[WARN][AppLauncher]: There are no arguments attached to the ArgumentParser object." - " If you have your own arguments, please load your own arguments before calling the" - " `AppLauncher.add_app_launcher_args` method. This allows the method to check the validity" - " of the arguments and perform checks for argument names." - ) - else: - AppLauncher._check_argparser_config_params(config) - - # Add custom arguments to the parser - arg_group = parser.add_argument_group( - "app_launcher arguments", - description="Arguments for the AppLauncher. For more details, please check the documentation.", - ) - arg_group.add_argument( - "--livestream", - type=int, - default=AppLauncher._APPLAUNCHER_CFG_INFO["livestream"][1], - choices={0, 1, 2}, - help="Force enable livestreaming. Mapping corresponds to that for the `LIVESTREAM` environment variable.", - ) - arg_group.add_argument( - "--xr", - action="store_true", - default=AppLauncher._APPLAUNCHER_CFG_INFO["xr"][1], - help="Enable XR mode for VR/AR applications.", - ) - arg_group.add_argument( - "--device", - type=str, - action=ExplicitAction, - default=AppLauncher._APPLAUNCHER_CFG_INFO["device"][1], - help='The device to run the simulation on. Can be "cpu", "cuda", "cuda:N", where N is the device ID', - ) - arg_group.add_argument( - "--visualizer", - "--viz", - type=AppLauncher._parse_visualizer_csv, - action=ExplicitAction, - default=None, - help="Visualizer backends to enable as CSV (e.g., kit,newton,rerun,viser).", - ) - # Add the deprecated cpu flag to raise an error if it is used - arg_group.add_argument("--cpu", action="store_true", help=argparse.SUPPRESS) - arg_group.add_argument( - "--verbose", # Note: This is read by SimulationApp through sys.argv - action="store_true", - help="Enable verbose-level log output from the SimulationApp.", - ) - arg_group.add_argument( - "--info", # Note: This is read by SimulationApp through sys.argv - action="store_true", - help="Enable info-level log output from the SimulationApp.", - ) - arg_group.add_argument( - "--experience", - type=str, - default="", - help=( - "The experience file to load when launching the SimulationApp. If an empty string is provided," - " the experience file is determined from the resolved visualizer and XR settings. If a relative" - " path is provided," - " it is resolved relative to the `apps` folder in Isaac Sim and Isaac Lab (in that order)." - ), - ) - arg_group.add_argument( - "--deterministic", - action="store_true", - default=AppLauncher._APPLAUNCHER_CFG_INFO["deterministic"][1], - help="Request reproducible rendering (see AppLauncher docs).", - ) - arg_group.add_argument( - "--kit_args", - type=str, - default="", - help=( - "Command line arguments for Omniverse Kit as a string separated by a space delimiter." - ' Example usage: --kit_args "--ext-folder=/path/to/ext1 --ext-folder=/path/to/ext2".' - ' A single Kit argument works in both forms: --kit_args "--ext-folder=/path/to/ext1"' - " or --kit_args=--ext-folder=/path/to/ext1." - ), - ) - arg_group.add_argument( - "--anim_recording_enabled", - action="store_true", - help="Enable recording time-sampled USD animations from IsaacLab PhysX simulations.", - ) - arg_group.add_argument( - "--anim_recording_start_time", - type=float, - default=0, - help=( - "Set time that animation recording begins playing. If not set, the recording will start from the" - " beginning." - ), - ) - arg_group.add_argument( - "--anim_recording_stop_time", - type=float, - default=10, - help=( - "Set time that animation recording stops playing. If the process is shutdown before the stop time is" - " exceeded, then the animation is not recorded." - ), - ) - arg_group.add_argument( - "--max_visible_envs", - type=int, - default=argparse.SUPPRESS, - help=("When set, caps the nums of envs shown in the launched visualizers."), - ) - - # Corresponding to the beginning of the function, - # if we have removed -h/--help handling, we add it back. - if parser_help is not None: - parser._option_string_actions["-h"] = parser_help - parser._option_string_actions["--help"] = parser_help - - """ - Internal functions. - """ - - # Set by :meth:`_resolve_xr_settings`. Defaulted here so :meth:`_resolve_headless_settings` - # stays independent of resolver call order and of whether XR was resolved at all. - _xr_auto_start: bool = False - - _APPLAUNCHER_CFG_INFO: dict[str, tuple[list[type], Any]] = { - "headless": ([bool], False), - "livestream": ([int], -1), - "enable_cameras": ([bool], False), - "xr": ([bool], False), - "device": ([str], "cuda:0"), - "experience": ([str], ""), - "deterministic": ([bool], False), - "max_visible_envs": ([int, type(None)], None), - } - """A dictionary of arguments added manually by the :meth:`AppLauncher.add_app_launcher_args` method. - - The values are a tuple of the expected type and default value. This is used to check against name collisions - for arguments passed to the :class:`AppLauncher` class as well as for type checking. - - They have corresponding environment variables as detailed in the documentation. - """ - - # TODO: Find some internally managed NVIDIA list of these types. - # SimulationApp.DEFAULT_LAUNCHER_CONFIG almost works, except that - # it is ambiguous where the default types are None - _SIM_APP_CFG_TYPES: dict[str, list[type]] = { - "headless": [bool], - "hide_ui": [bool, type(None)], - "physics_gpu": [int], - "multi_gpu": [bool], - "sync_loads": [bool], - "width": [int], - "height": [int], - "window_width": [int], - "window_height": [int], - "display_options": [int], - "subdiv_refinement_level": [int], - "renderer": [str], - "anti_aliasing": [int], - "samples_per_pixel_per_frame": [int], - "denoiser": [bool], - "max_bounces": [int], - "max_specular_transmission_bounces": [int], - "max_volume_bounces": [int], - "open_usd": [str, type(None)], - "livesync_usd": [str, type(None)], - "fast_shutdown": [bool], - "limit_cpu_threads": [int], - "experience": [str], - } - """A dictionary containing the type of arguments passed to SimulationApp. - - This is used to check against name collisions for arguments passed to the :class:`AppLauncher` class - as well as for type checking. It corresponds closely to the :attr:`SimulationApp.DEFAULT_LAUNCHER_CONFIG`, - but specifically denotes where None types are allowed. - """ + """Add the launcher arguments, see :func:`~isaaclab.app.add_launcher_args`.""" + add_launcher_args(parser) @staticmethod - def _check_argparser_config_params(config: dict) -> None: - """Checks that input argparser object has parameters with valid settings with no name conflicts. - - First, we inspect the dictionary to ensure that the passed ArgParser object is not attempting to add arguments - which should be assigned by calling :meth:`AppLauncher.add_app_launcher_args`. + def is_available() -> bool: + """Return whether the full Isaac Sim runtime is importable, i.e. Kit can be launched.""" + from ..utils.string import string_to_callable - Then, we check that if the key corresponds to a config setting expected by SimulationApp, then the type of - that key's value corresponds to the type expected by the SimulationApp. If it passes the check, the function - prints out that the setting with be passed to the SimulationApp. Otherwise, we raise a ValueError exception. - - Args: - config: A configuration parameters which will be passed to the SimulationApp constructor. - - Raises: - ValueError: If a key is an already existing field in the configuration parameters but - should be added by calling the :meth:`AppLauncher.add_app_launcher_args. - ValueError: If keys corresponding to those used to initialize SimulationApp - (as found in :attr:`_SIM_APP_CFG_TYPES`) are of the wrong value type. - """ - # check that no config key conflicts with AppLauncher config names - applauncher_keys = set(AppLauncher._APPLAUNCHER_CFG_INFO.keys()) - for key, value in config.items(): - if key in applauncher_keys: - raise ValueError( - f"The passed ArgParser object already has the field '{key}'. This field will be added by" - " `AppLauncher.add_app_launcher_args()`, and should not be added directly. Please remove the" - " argument or rename it to a non-conflicting name." - ) - # check that type of the passed keys are valid - simulationapp_keys = set(AppLauncher._SIM_APP_CFG_TYPES.keys()) - for key, value in config.items(): - if key in simulationapp_keys: - given_type = type(value) - expected_types = AppLauncher._SIM_APP_CFG_TYPES[key] - if type(value) not in set(expected_types): - raise ValueError( - f"Invalid value type for the argument '{key}': {given_type}. Expected one of {expected_types}," - " if intended to be ingested by the SimulationApp object. Please change the type if this" - " intended for the SimulationApp or change the name of the argument to avoid name conflicts." - ) - # Print out values which will be used - logger.info("The argument '%s' will be used to configure the SimulationApp.", key) - - def _config_resolution(self, launcher_args: dict): - """Resolve the input arguments and environment variables. - - Args: - launcher_args: A dictionary of all input arguments passed to the class object. - """ - livestream_arg, livestream_env = self._resolve_livestream_settings(launcher_args) - self._resolve_visualizer_settings(launcher_args) - # XR must be resolved before headless so that XR can prevent - # visualizer-intent-based headless forcing. - self._resolve_xr_settings(launcher_args) - self._resolve_headless_settings(launcher_args, livestream_arg, livestream_env) - self._resolve_camera_settings(launcher_args) - self._resolve_viewport_settings(launcher_args) - self._resolve_device_settings(launcher_args) - self._resolve_experience_file(launcher_args) - self._resolve_anim_recording_settings(launcher_args) - self._resolve_kit_args(launcher_args) - - # Prepare final simulation app config - # Remove all values from input keyword args which are not meant for SimulationApp - # Assign all the passed settings to a dictionary for the simulation app - self._sim_app_config = { - key: launcher_args[key] for key in set(AppLauncher._SIM_APP_CFG_TYPES.keys()) & set(launcher_args.keys()) - } - - def _resolve_livestream_settings(self, launcher_args: dict) -> tuple[int, int]: - """Resolve livestream related settings.""" - livestream_env = int(os.environ.get("LIVESTREAM", 0)) - livestream_arg = launcher_args.pop("livestream", AppLauncher._APPLAUNCHER_CFG_INFO["livestream"][1]) - livestream_valid_vals = {0, 1, 2} - # Value checking on LIVESTREAM - if livestream_env not in livestream_valid_vals: - raise ValueError( - f"Invalid value for environment variable `LIVESTREAM`: {livestream_env} ." - f" Expected: {livestream_valid_vals}." - ) - # We allow livestream kwarg to supersede LIVESTREAM envvar - if livestream_arg >= 0: - if livestream_arg in livestream_valid_vals: - self._livestream = livestream_arg - # print info that we overrode the env-var - logger.info( - "Input keyword argument `livestream=%s` has overridden the environment variable `LIVESTREAM=%s`.", - livestream_arg, - livestream_env, - ) - else: - raise ValueError( - f"Invalid value for input keyword argument `livestream`: {livestream_arg} ." - f" Expected: {livestream_valid_vals}." - ) - else: - self._livestream = livestream_env - - # Set public IP address of a remote instance - public_ip_env = os.environ.get("PUBLIC_IP", "127.0.0.1") - - # Process livestream here before launching kit because some of the extensions only work - # when launched with the kit file - self._livestream_args = [] - if self._livestream >= 1: - # Note: Only one livestream extension can be enabled at a time - if self._livestream == 1: - # WebRTC public network - self._livestream_args += [ - f"--/exts/omni.kit.livestream.app/primaryStream/publicIp={public_ip_env}", - "--/exts/omni.kit.livestream.app/primaryStream/signalPort=49100", - "--/exts/omni.kit.livestream.app/primaryStream/streamPort=47998", - "--/exts/omni.kit.livestream.app/primaryStream/allowDynamicResize=true", - "--/exts/omni.kit.livestream.app/primaryStream/streamType=webrtc", - "--enable", - "omni.kit.livestream.app", - ] - elif self._livestream == 2: - # WebRTC private network - # Signal/stream ports and allowDynamicResize must be set explicitly; without - # them NVST cannot bind its server socket (NVST_R_INTERNAL_ERROR) and any - # subsequent window resize after a client connects triggers NVST_R_BUSY. - self._livestream_args += [ - "--/exts/omni.kit.livestream.app/primaryStream/signalPort=49100", - "--/exts/omni.kit.livestream.app/primaryStream/streamPort=47998", - "--/exts/omni.kit.livestream.app/primaryStream/allowDynamicResize=true", - "--/exts/omni.kit.livestream.app/primaryStream/streamType=webrtc", - "--enable", - "omni.kit.livestream.app", - ] - else: - raise ValueError(f"Invalid value for livestream: {self._livestream}. Expected: 1, 2 .") - sys.argv += self._livestream_args - - return livestream_arg, livestream_env - - def _resolve_headless_settings(self, launcher_args: dict, livestream_arg: int, livestream_env: int): - """Resolve headless related settings.""" - # Resolve headless execution of simulation app - # HEADLESS is initially passed as an int instead of - # the bool of headless_arg to avoid messy string processing, - headless_env = int(os.environ.get("HEADLESS", 0)) - headless_arg = launcher_args.pop("headless", AppLauncher._APPLAUNCHER_CFG_INFO["headless"][1]) - headless_valid_vals = {0, 1} - # Value checking on HEADLESS - if headless_env not in headless_valid_vals: - raise ValueError( - f"Invalid value for environment variable `HEADLESS`: {headless_env} . Expected: {headless_valid_vals}." - ) - # We allow headless kwarg to supersede HEADLESS envvar if headless_arg does not have the default value - # Note: Headless is always true when livestreaming - if headless_arg is True: - self._headless = headless_arg - elif self._livestream in {1, 2}: - # we are always headless on the host machine - self._headless = True - # inform who has toggled the headless flag - if self._livestream == livestream_arg: - logger.info( - "Input keyword argument `livestream=%s` has implicitly overridden the " - "environment variable `HEADLESS=%s` to True.", - self._livestream, - headless_env, - ) - elif self._livestream == livestream_env: - logger.info( - "Environment variable `LIVESTREAM=%s` has implicitly overridden the " - "environment variable `HEADLESS=%s` to True.", - self._livestream, - headless_env, - ) - else: - # Headless needs to be a bool to be ingested by SimulationApp - self._headless = bool(headless_env) - - # Resolve headless from visualizer intent when livestream is disabled. - if self._livestream == 0: - if self._xr_auto_start: - # XR without an explicit windowed visualizer: no viewport to start the session from. - if not self._headless: - logger.info( - "XR is enabled without an explicit windowed visualizer, so running headless. " - "To also open a local viewport, pass '--viz ' (for example '--viz kit')." - ) - self._headless = True - elif self._cli_visualizer_explicit: - # Explicit CLI selection controls headless: only Kit implies non-headless. - requested_visualizers = set(self._cli_visualizer_types) - if self._cli_visualizer_disable_all or "kit" not in requested_visualizers: - if not self._headless: - logger.debug( - "Forcing headless mode because visualizer selection " - "excludes 'kit' and livestream is disabled." - ) - self._headless = True - else: - # No CLI visualizer selection: use upstream config intent defaults. - # - no config visualizers => headless - # - config visualizers without kit => headless - # - config includes kit => allow non-headless - if (not self._cfg_has_any_visualizers) or (not self._cfg_has_kit_visualizer): - logger.info( - "No visualizer was selected, so running in headless mode. " - "To launch a visualizer app, pass '--viz ' " - "(for example '--viz kit')." - ) - if not self._headless: - logger.debug( - "Forcing headless mode because no Kit visualizer was requested via CLI or upstream " - "visualizer config intent." - ) - self._headless = True - # Headless needs to be passed to the SimulationApp so we keep it here - launcher_args["headless"] = self._headless - - def _resolve_visualizer_settings(self, launcher_args: dict) -> None: - """Resolve visualizer CLI semantics and normalize selection.""" - raw_visualizers = launcher_args.get("visualizer") - cfg_has_any, cfg_has_kit = AppLauncher._normalize_visualizer_intent( - launcher_args.pop("visualizer_intent", None) - ) - self._cfg_has_any_visualizers = cfg_has_any - self._cfg_has_kit_visualizer = cfg_has_kit - visualizer_explicit = bool(launcher_args.pop("visualizer_explicit", False)) - if not visualizer_explicit and "visualizer" in launcher_args: - visualizer_explicit = raw_visualizers is not None - - visualizer_types: list[str] = [] - if raw_visualizers is not None: - if isinstance(raw_visualizers, str): - parsed_visualizers = AppLauncher._parse_visualizer_csv(raw_visualizers) - visualizer_types = [] if parsed_visualizers is None else parsed_visualizers - else: - visualizer_types = [str(v).strip().lower() for v in raw_visualizers if str(v).strip()] - - if visualizer_explicit and "none" in visualizer_types and len(visualizer_types) > 1: - raise ValueError("Invalid '--visualizer' value: 'none' cannot be combined with other visualizer types.") - - _deprecated_viz_aliases = {"newton": "newton_gl"} - valid_visualizer_types = {"kit", "newton_gl", "newton_rtx", "rerun", "viser", "none"} | set( - _deprecated_viz_aliases - ) - # Secondary validation for the list path (kwargs); the string path is already validated by - invalid_visualizers = [v for v in visualizer_types if v not in valid_visualizer_types] - if invalid_visualizers: - raise ValueError( - f"Invalid value(s) for '--visualizer': {invalid_visualizers}. " - "Expected one or more of: ['kit', 'newton_gl', 'newton_rtx', 'rerun', 'viser', 'none']." - ) - # Resolve deprecated aliases, emitting a DeprecationWarning for each one found. - resolved = [] - for v in visualizer_types: - if v in _deprecated_viz_aliases: - canonical = _deprecated_viz_aliases[v] - import warnings - - warnings.warn( - f"--viz '{v}' is deprecated. Use '--viz {canonical}' instead.", - DeprecationWarning, - stacklevel=2, - ) - resolved.append(canonical) - else: - resolved.append(v) - visualizer_types = resolved - - self._cli_visualizer_explicit = visualizer_explicit - self._cli_visualizer_disable_all = visualizer_explicit and ( - raw_visualizers is None or "none" in visualizer_types - ) - self._cli_visualizer_types = [] if self._cli_visualizer_disable_all else visualizer_types - launcher_args["visualizer"] = self._cli_visualizer_types - - def _resolve_camera_settings(self, launcher_args: dict): - """Resolve camera related settings.""" - self._enable_cameras = bool( - launcher_args.pop("enable_cameras", AppLauncher._APPLAUNCHER_CFG_INFO["enable_cameras"][1]) - ) - self._offscreen_render = False - if self._enable_cameras and self._headless: - self._offscreen_render = True - - def _resolve_xr_settings(self, launcher_args: dict): - """Resolve XR related settings.""" - xr_env = int(os.environ.get("XR", 0)) - xr_arg = launcher_args.get("xr", AppLauncher._APPLAUNCHER_CFG_INFO["xr"][1]) - xr_valid_vals = {0, 1} - if xr_env not in xr_valid_vals: - raise ValueError(f"Invalid value for environment variable `XR`: {xr_env} .Expected: {xr_valid_vals} .") - # We allow xr kwarg to supersede XR envvar - if xr_arg is True: - self._xr = xr_arg - else: - self._xr = bool(xr_env) - - # Determine whether XR should auto-inject a KitVisualizer. - # When XR is enabled but no Kit visualizer was explicitly requested via - # CLI, we auto-inject one so that app.update() and forward() are pumped - # each frame -- the XR runtime needs both to receive updated hand/joint - # transforms. - if self._xr: - has_explicit_kit = self._cli_visualizer_explicit and "kit" in set(self._cli_visualizer_types) - self._xr_auto_start = not has_explicit_kit - else: - self._xr_auto_start = False - - def _resolve_viewport_settings(self, launcher_args: dict): - """Resolve viewport related settings.""" - self._video_enabled = bool(launcher_args.get("video", False)) - if self._video_enabled and any( - importlib.util.find_spec(package) is None for package in ("moviepy", "imageio_ffmpeg") - ): - raise ModuleNotFoundError( - "Video recording with `--video` requires MoviePy and its imageio-ffmpeg backend, " - "which are not installed by default. " - "Run uv commands with `uv run --extra video ...`, or install MoviePy into the " - 'legacy environment with `./isaaclab.sh -p -m pip install "moviepy>=1.0.3,<2.0.0.dev0"` ' - "(`isaaclab.bat -p -m pip install ...` on Windows), and retry." - ) - # Check if we can disable the viewport to improve performance - # This should only happen if we are running headless and do not require livestreaming or video recording - # This is different from offscreen_render because this only affects the default viewport and - # not other render-products in the scene - self._render_viewport = True - if self._headless and not self._livestream and not self._video_enabled: - self._render_viewport = False - - # hide_ui flag - launcher_args["hide_ui"] = False - if self._headless and not self._livestream: - launcher_args["hide_ui"] = True - - # avoid creating new stage at startup by default for performance reasons - launcher_args["create_new_stage"] = False - - def _resolve_device_settings(self, launcher_args: dict): - """Resolve simulation GPU device related settings.""" - self.device_id = 0 - device = launcher_args.get("device", AppLauncher._APPLAUNCHER_CFG_INFO["device"][1]) - - device_explicitly_passed = launcher_args.pop("device_explicit", False) - if self._xr and not device_explicitly_passed: - # If no device is specified, default to the CPU device if we are running in XR - device = "cpu" - - # Overwrite for downstream consumers - launcher_args["device"] = "cpu" - - if "cuda" not in device and "cpu" not in device: - raise ValueError( - f"Invalid value for input keyword argument `device`: {device}." - " Expected: a string with the format 'cuda', 'cuda:', or 'cpu'." - ) - - if "cuda:" in device: - self.device_id = int(device.split(":")[-1]) - - # Raise an error for the deprecated cpu flag - if launcher_args.get("cpu", False): - raise ValueError("The `--cpu` flag is deprecated. Please use `--device cpu` instead.") - - if "distributed" in launcher_args and launcher_args["distributed"]: - # local rank (GPU id) in a current multi-gpu mode - self.local_rank = int(os.getenv("LOCAL_RANK", "0")) + int(os.getenv("JAX_LOCAL_RANK", "0")) - # global rank (GPU id) in multi-gpu multi-node mode - self.global_rank = int(os.getenv("RANK", "0")) + int(os.getenv("JAX_RANK", "0")) - - # When CUDA_VISIBLE_DEVICES restricts each process to a single GPU, - # local_rank may exceed the visible device count. Fall back to cuda:0 - # so the process uses the one GPU it can see. - # We compare local_rank against device_count (not WORLD_SIZE) so that - # multi-node setups work correctly: WORLD_SIZE is global across all - # nodes, but device_count is local. - import torch - - num_visible_gpus = torch.cuda.device_count() - if self.local_rank < num_visible_gpus: - self.device_id = self.local_rank - else: - self.device_id = 0 - - device = "cuda:" + str(self.device_id) - launcher_args["multi_gpu"] = False - # limit CPU threads to minimize thread context switching - # this ensures processes do not take up all available threads and fight for resources - num_cpu_cores = os.cpu_count() - num_threads_per_process = num_cpu_cores // int(os.getenv("WORLD_SIZE", 1)) - # set environment variables to limit CPU threads - os.environ["PXR_WORK_THREAD_LIMIT"] = str(num_threads_per_process) - os.environ["OPENBLAS_NUM_THREADS"] = str(num_threads_per_process) - sys.argv.append(f"--/plugins/carb.tasking.plugin/threadCount={num_threads_per_process}") - - # ``/physics/cudaDevice`` is resolved by CUDA, so the masked index is correct there. - # ``activeGpu`` is deliberately left unset; the renderer device is selected in - # :meth:`_resolve_kit_args` instead. - launcher_args["physics_gpu"] = self.device_id - - # Defer importing torch until after SimulationApp starts. Importing - # torch can import NumPy/OpenBLAS, whose at-fork handlers can crash - # Kit's platform-info fork during startup. - if "cuda" in device: - self._deferred_cuda_device_id = self.device_id - - # Store the resolved device string for downstream consumers (e.g. sim_launcher) - self.device = device - - logger.info("Using device: %s", device) - - def _initialize_preloaded_torch_cuda(self) -> None: - """Initialize CUDA before Kit when another import has already loaded PyTorch. - - Importing PyTorch schedules capability checks for every CUDA device it can see. Kit may - subsequently restrict CUDA to the GPUs that its Vulkan backend can use, leaving those - queued checks with stale device indices. Do not import PyTorch here: when it has not - already been loaded, the normal post-Kit initialization path remains preferable. - """ - if self._deferred_cuda_device_id is None: - return - - torch = sys.modules.get("torch") - if torch is not None and not torch.cuda.is_initialized(): - torch.cuda.init() - - def _set_deferred_cuda_device(self) -> None: - """Set the current CUDA device after Kit startup.""" - if self._deferred_cuda_device_id is None: - return - - set_cuda_device(self._deferred_cuda_device_id) - - def _resolve_experience_file(self, launcher_args: dict): - """Resolve experience file related settings.""" - # Check if input keywords contain an 'experience' file setting - # Note: since experience is taken as a separate argument by Simulation App, we store it separately - self._sim_experience_file = launcher_args.pop("experience", "") - deterministic_mode = bool( - launcher_args.get("deterministic", AppLauncher._APPLAUNCHER_CFG_INFO["deterministic"][1]) - ) - - # If nothing is provided resolve the experience file based on the headless flag. - # EXP_PATH is normally set by ``isaacsim.bootstrap_kernel()`` on first import. - # If it is not set (e.g. on aarch64 where the bootstrap early-return triggered - # under certain install layouts), derive it from the installed isaacsim package. - kit_app_exp_path = os.environ.get("EXP_PATH") - if not kit_app_exp_path: - try: - import isaacsim as _isaacsim_for_paths - except ImportError as e: - raise RuntimeError( - "EXP_PATH is not set and the 'isaacsim' package is not importable." - " Install Isaac Sim (`pip install isaacsim` or the binary distribution)" - " before launching AppLauncher." - ) from e - kit_app_exp_path = os.path.join(os.path.dirname(_isaacsim_for_paths.__file__), "apps") - os.environ["EXP_PATH"] = kit_app_exp_path - isaaclab_app_exp_path = str(ISAACLAB_ROOT / "apps") - if self.is_isaac_sim_version_5(): - isaaclab_app_exp_path = os.path.join(isaaclab_app_exp_path, "isaacsim_5") - - if self._sim_experience_file == "": - # check if the headless flag is set - # xr rendering overrides camera rendering settings - if self._enable_cameras and not self._xr: - if self._headless and not self._livestream: - self._sim_experience_file = os.path.join( - isaaclab_app_exp_path, "isaaclab.python.headless.rendering.kit" - ) - else: - self._sim_experience_file = os.path.join(isaaclab_app_exp_path, "isaaclab.python.rendering.kit") - elif self._xr: - if self._headless and not self._livestream: - self._sim_experience_file = os.path.join( - isaaclab_app_exp_path, "isaaclab.python.xr.openxr.headless.kit" - ) - else: - self._sim_experience_file = os.path.join(isaaclab_app_exp_path, "isaaclab.python.xr.openxr.kit") - elif self._headless and not self._livestream: - self._sim_experience_file = os.path.join(isaaclab_app_exp_path, "isaaclab.python.headless.kit") - else: - self._sim_experience_file = os.path.join(isaaclab_app_exp_path, "isaaclab.python.kit") - elif not os.path.isabs(self._sim_experience_file): - option_1_app_exp_path = os.path.join(kit_app_exp_path, self._sim_experience_file) - option_2_app_exp_path = os.path.join(isaaclab_app_exp_path, self._sim_experience_file) - if os.path.exists(option_1_app_exp_path): - self._sim_experience_file = option_1_app_exp_path - elif os.path.exists(option_2_app_exp_path): - self._sim_experience_file = option_2_app_exp_path - else: - raise FileNotFoundError( - f"Invalid value for input keyword argument `experience`: {self._sim_experience_file}." - "\n No such file exists in either the Kit or Isaac Lab experience paths. Checked paths:" - f"\n\t [1]: {option_1_app_exp_path}" - f"\n\t [2]: {option_2_app_exp_path}" - ) - elif not os.path.exists(self._sim_experience_file): - raise FileNotFoundError( - f"Invalid value for input keyword argument `experience`: {self._sim_experience_file}." - " The file does not exist." - ) - - # Resolve the absolute path of the experience file - self._sim_experience_file = os.path.abspath(self._sim_experience_file) - # Detect a known incompatibility between Isaac Lab and Isaac Sim full-app experiences. - if self._livestream in {1, 2} and self._experience_depends_on_isaacsim_exp_full(self._sim_experience_file): - raise ValueError( - "The experience file depends on 'isaacsim.exp.full', which is known to hang or invalidate PhysX " - "tensor views when launched through Isaac Lab with livestreaming enabled. Omit '--experience' so " - "AppLauncher can select an Isaac Lab experience file, or remove the 'isaacsim.exp.full' dependency." - ) - self._deterministic_rendering = bool(deterministic_mode) - logger.info("Loading experience file: %s", self._sim_experience_file) + return string_to_callable(_KIT_LAUNCHER).is_available() @staticmethod - def _experience_depends_on_isaacsim_exp_full(experience_file: str) -> bool: - """Return whether a Kit experience directly depends on ``isaacsim.exp.full``.""" - with open(experience_file, encoding="utf-8") as file: - return re.search(r'^\s*["\']isaacsim\.exp\.full["\']\s*=', file.read(), re.MULTILINE) is not None - - def _resolve_anim_recording_settings(self, launcher_args: dict): - """Resolve animation recording settings.""" - - # Enable omni.physx.pvd extension if recording is enabled - recording_enabled = launcher_args.get("anim_recording_enabled", False) - if recording_enabled: - if self._headless: - raise ValueError("Animation recording is not supported in headless mode.") - sys.argv += ["--enable", "omni.physx.pvd"] - - def _requires_all_partitions_spectator_view(self) -> bool: - """Return whether the launch needs an unpartitioned all-environment view.""" - if getattr(self, "_cli_visualizer_explicit", False): - has_kit_visualizer = "kit" in getattr(self, "_cli_visualizer_types", []) - else: - has_kit_visualizer = bool(getattr(self, "_cfg_has_kit_visualizer", False)) - return ( - has_kit_visualizer - or bool(getattr(self, "_render_viewport", False)) - or bool(getattr(self, "_video_enabled", False)) - or int(getattr(self, "_livestream", 0)) > 0 - or bool(getattr(self, "_xr", False)) - ) - - def _resolve_kit_args(self, launcher_args: dict): - """Resolve additional arguments passed to Kit.""" - self._kit_args = launcher_args.get("kit_args", "").split() - - fabric_gpu_interop = os.environ.get(_FABRIC_GPU_INTEROP_ENV) - if fabric_gpu_interop is not None: - if fabric_gpu_interop not in {"0", "1"}: - raise ValueError( - f"Invalid value for environment variable `{_FABRIC_GPU_INTEROP_ENV}`: {fabric_gpu_interop}." - " Expected: 0 or 1." - ) - argument = f"--/physics/fabricUseGPUInterop={'true' if fabric_gpu_interop == '1' else 'false'}" - setting = argument.partition("=")[0] - if not any(arg.partition("=")[0] == setting for arg in sys.argv + self._kit_args): - self._kit_args.append(argument) - - argument = "--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false" - setting = argument.partition("=")[0] - if not any(arg.partition("=")[0] == setting for arg in sys.argv + self._kit_args): - self._kit_args.append(argument) - - # RTX allocates spectator support during startup, so visual output intent - # must become a Kit argument before SimulationApp is created. - if self._requires_all_partitions_spectator_view(): - argument = f"--{ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING}=true" - setting = argument.partition("=")[0] - if not any(arg.partition("=")[0] == setting for arg in sys.argv + self._kit_args): - self._kit_args.append(argument) - - # Select the renderer by CUDA index; the trailing comma keeps the setting string-typed. - # XR streams a single stereo swapchain that the CloudXR compositor imports, so the - # renderer has to stay on one known device there too -- otherwise the compositor and - # the renderer can end up on different GPUs and the headset receives noise. This only - # applies once a CUDA device has actually been selected: ``--xr`` on its own resolves - # to ``cpu``, where there is no simulation GPU to align to, so Kit's own choice stands. - if launcher_args.get("multi_gpu") is False or (self._xr and "cuda" in self.device): - argument = f"--/renderer/multiGpu/activeCudaGpus={self.device_id}," - setting = argument.partition("=")[0] - if not any(arg.partition("=")[0] == setting for arg in sys.argv + self._kit_args): - self._kit_args.append(argument) - - sys.argv += self._kit_args - - def _create_app(self): - """Launch and create the SimulationApp based on the parsed simulation config.""" - # Initialize SimulationApp - # hack sys module to make sure that the SimulationApp is initialized correctly - # this is to avoid the warnings from the simulation app about not ok modules - r = re.compile(".*lab.*") - found_modules = list(filter(r.match, list(sys.modules.keys()))) - # remove Isaac Lab modules from sys.modules - hacked_modules = dict() - for key in found_modules: - hacked_modules[key] = sys.modules[key] - del sys.modules[key] - - # disable sys stdout and stderr to avoid printing the warning messages - # this is mainly done to purge the print statements from the simulation app - if "--verbose" not in sys.argv and "--info" not in sys.argv: - sys.stdout = open(os.devnull, "w") # noqa: SIM115 - - sys.argv = _sanitize_sys_argv_for_kit(sys.argv) - - report_activity("Starting Isaac Sim") - self._app = SimulationApp(self._sim_app_config, experience=self._sim_experience_file) - report_activity(None) - - sys.stdout = sys.__stdout__ - - # add Isaac Lab modules back to sys.modules - for key, value in hacked_modules.items(): - sys.modules[key] = value - # remove the threadCount argument from sys.argv if it was added for distributed training - pattern = r"--/plugins/carb\.tasking\.plugin/threadCount=\d+" - sys.argv = [arg for arg in sys.argv if not re.match(pattern, arg)] - - # remove additional OV args from sys.argv - if len(self._kit_args) > 0: - sys.argv = [arg for arg in sys.argv if arg not in self._kit_args] - if len(self._livestream_args) > 0: - sys.argv = [arg for arg in sys.argv if arg not in self._livestream_args] - - def _rendering_enabled(self) -> bool: - """Check if rendering is required by the app.""" - # Indicates whether rendering is required by the app. - # Extensions required for rendering bring startup and simulation costs, so we do not - # enable them if not required. - return not self._headless or self._livestream >= 1 or self._enable_cameras or self._xr - - def _load_extensions(self): - """Load correct extensions based on AppLauncher's resolved config member variables.""" - # These have to be loaded after SimulationApp is initialized. - # Use SettingsManager (backs onto carb when in Omniverse after initialize_carb_settings). - initialize_carb_settings() - - # After SimulationApp starts, Kit installs its Python log bridge at DEBUG level. - # Re-apply the intended Python logging level, then add a scoped stream handler for - # Isaac Lab INFO records that Kit's bridge does not mirror to the console. - apply_python_logging_level(self._python_logging_level) - if self._python_logging_level <= logging.INFO: - AppLauncher._ensure_isaaclab_info_stream_handler() - elif self._python_logging_level == logging.WARNING: - AppLauncher._ensure_isaaclab_info_stream_handler() - # Let Isaac Lab INFO records reach the scoped handler while the other root - # handlers remain at WARNING. - logging.getLogger().setLevel(logging.INFO) - settings = get_settings_manager() - - # Publish whether Kit has an interactive GUI (local window, livestream, or XR). - # SimulationContext and renderers consume this setting during their initialization. - settings.set_bool("/isaaclab/has_gui", not self._headless or self._livestream >= 1 or self._xr) - settings.set_bool("/isaaclab/render/offscreen", self._offscreen_render) - settings.set_bool("/isaaclab/render/active_viewport", self._render_viewport) - settings.set_bool("/isaaclab/xr/enabled", self._xr) - # set setting to indicate XR auto-start mode -- when running headless - # (no Kit GUI) the AR profile must be enabled programmatically so that - # the OpenXR session starts without user interaction - settings.set_bool("/isaaclab/xr/auto_start", self._headless and self._xr) - settings.set_bool("/isaaclab/video/enabled", self._video_enabled) - - # set setting to indicate no RTX sensors are used (set to True when RTX sensor is created) - settings.set_bool("/isaaclab/render/rtx_sensors", False) - - # publish the reproducible-rendering intent; rendering backends read this on initialization - settings.set_bool("/isaaclab/render/deterministic", self._deterministic_rendering) - - # set fabric update flag to disable updating transforms when rendering is disabled - settings.set_bool("/physics/fabricUpdateTransformations", self._rendering_enabled()) - - # use fixed time stepping disabled; custom loop runner from Isaac Sim is used instead - settings.set_bool("/app/player/useFixedTimeStepping", False) - - def _hide_stop_button(self): - """Hide the stop button in the toolbar. - - For standalone executions, having a stop button is confusing since it invalidates the whole simulation. - Thus, we hide the button so that users don't accidentally click it. - """ - # when we are truly headless, then we can't import the widget toolbar - # thus, we only hide the stop button when we are not headless (i.e. GUI is enabled) - if self._livestream >= 1 or not self._headless: - import omni.kit.widget.toolbar - - # grey out the stop button because we don't want to stop the simulation manually in standalone mode - toolbar = omni.kit.widget.toolbar.get_instance() - play_button_group = toolbar._builtin_tools._play_button_group # type: ignore - if play_button_group is not None: - play_button_group._stop_button.visible = False # type: ignore - play_button_group._stop_button.enabled = False # type: ignore - play_button_group._stop_button = None # type: ignore - - def _set_animation_recording_settings(self, launcher_args: dict) -> None: - """Store animation recording settings in settings.""" - recording_enabled = launcher_args.get("anim_recording_enabled", False) - if not recording_enabled: - return - - # arg checks - if launcher_args.get("anim_recording_start_time") >= launcher_args.get("anim_recording_stop_time"): - raise ValueError( - f"'anim_recording_start_time' {launcher_args.get('anim_recording_start_time')} must be less than" - f" 'anim_recording_stop_time' {launcher_args.get('anim_recording_stop_time')}" - ) - - start_time = launcher_args.get("anim_recording_start_time") - stop_time = launcher_args.get("anim_recording_stop_time") - - settings = get_settings_manager() - settings.set_bool("/isaaclab/anim_recording/enabled", recording_enabled) - settings.set_float("/isaaclab/anim_recording/start_time", start_time) - settings.set_float("/isaaclab/anim_recording/stop_time", stop_time) - - def _set_visualizer_settings(self, launcher_args: dict) -> None: - """Persist visualizer CLI flags and ``max_visible_envs`` override for :class:`SimulationContext`.""" - AppLauncher.sync_visualizer_cli_settings_to_carb( - { - **launcher_args, - "visualizer_explicit": getattr(self, "_cli_visualizer_explicit", False), - "visualizer_disable_all": getattr(self, "_cli_visualizer_disable_all", False), - } - ) - - def is_isaac_sim_version_5(self) -> bool: - if not hasattr(self, "_is_sim_ver_5"): - # 1) Try to read the VERSION file (for manual / binary installs) - version_path = os.path.abspath(os.path.join(os.path.dirname(isaacsim.__file__), "../../VERSION")) - if os.path.isfile(version_path): - with open(version_path) as f: - ver = f.readline().strip() - if ver.startswith("5"): - self._is_sim_ver_5 = True - return True - - # 2) Fall back to metadata (for pip installs) - from importlib.metadata import version as pkg_version - - try: - ver = pkg_version("isaacsim") - if ver.startswith("5"): - self._is_sim_ver_5 = True - else: - self._is_sim_ver_5 = False - except Exception: - self._is_sim_ver_5 = False - return self._is_sim_ver_5 - - def _hide_play_button(self, flag): - """Hide/Unhide the play button in the toolbar. - - This is used if the timeline is stopped by a GUI action like "save as" to not allow the user to - resume the timeline afterwards. - """ - # when we are truly headless, then we can't import the widget toolbar - # thus, we only hide the play button when we are not headless (i.e. GUI is enabled) - if self._livestream >= 1 or not self._headless: - import omni.kit.widget.toolbar - - toolbar = omni.kit.widget.toolbar.get_instance() - play_button_group = toolbar._builtin_tools._play_button_group # type: ignore - if play_button_group is not None: - play_button_group._play_button.visible = not flag # type: ignore - play_button_group._play_button.enabled = not flag # type: ignore - - class _SimulationAppLifecycle: - """Reports the lifecycle of the Kit-based :class:`SimulationApp` process. - - Supervisors (the CI runner at startup; torchrun, schedulers, and CI at exit) - only see this process through its output markers and its exit status. Kit - fast shutdown exits with code 0 from inside :meth:`SimulationApp.close`, so - every exit path below must carry its own truthful status. - - Exit-path policy: - - * Normal exit: the ``atexit`` hook closes the app once; a pending unhandled - exception turns into exit code 1. - * ``SIGTERM`` / ``kill -ABRT``: ``close(exit_code=128 + signum)`` — full - teardown before exiting on isaacsim.simulation_app >= 2.18.5, truthful - status either way; if ``close()`` returns (fast shutdown disabled), - re-raise the signal with the default action. - * Signal during a running close: fall back to the default action instead of - re-entering ``close()``, which previously recursed until stack overflow. - * ``SIGSEGV``: not intercepted — a Python handler cannot run for main-thread - faults, reports success for worker-thread faults, and suppresses the carb - crash reporter's minidumps. - * ``SIGINT``: Python's default handler, so Ctrl-C unwinds user code and - exits nonzero. - - Not handled on the sim side, so handled here: SimulationApp installs its own - ``SIGINT`` handler that exits 0 before user code unwinds (changing it there - affects Ctrl-C semantics for every consumer), so the launcher restores - Python's default handler for its own processes. - """ - - def __init__(self, app: SimulationApp): - self._app = app - # set once any close starts; later signals must not start a second teardown - self._closing = False - - def announce_startup(self): - """Print the CI startup marker and Kit version diagnostics.""" - import carb - import omni.kit.app - - # the CI runner greps this marker for hang detection; __stderr__ survives - # stdout being redirected to /dev/null during app creation - print("[ISAACLAB] AppLauncher initialization complete", file=sys.__stderr__, flush=True) - - kit_app = omni.kit.app.get_app() - tokens = carb.tokens.get_tokens_interface() - kit_git_hash = tokens.resolve("${kit_git_hash}") or "unknown" - print(f"[ISAACLAB] Kit version: {kit_app.get_kit_version()}", file=sys.__stderr__, flush=True) - print(f"[ISAACLAB] Kit kernel: {kit_app.get_kernel_version()}", file=sys.__stderr__, flush=True) - print(f"[ISAACLAB] Kit hash: {kit_git_hash}", file=sys.__stderr__, flush=True) - - def install_exit_handlers(self): - """Register the ``atexit`` hook and signal handlers implementing the exit policy.""" - # close on normal exit so Kit shuts down cleanly instead of via __del__ - atexit.register(self._close_at_exit) - signal.signal(signal.SIGTERM, self._on_abort_signal) - signal.signal(signal.SIGABRT, self._on_abort_signal) - # no SIGSEGV handler and default SIGINT — see the class docstring - signal.signal(signal.SIGINT, signal.default_int_handler) - - def _close_at_exit(self): - """Close the app on normal interpreter exit, preserving a pending failure status.""" - self._closing = True - with contextlib.suppress(Exception): - # sys.last_exc is set before atexit callbacks run for an unhandled - # exception (but not for SystemExit, which is not detected yet) - exit_code = 1 if getattr(sys, "last_exc", None) is not None else 0 - self._close_app(exit_code) - - def _on_abort_signal(self, signum, frame): - """Handle SIGTERM/SIGABRT: close the app once, then die by the signal.""" - if self._closing: - signal.signal(signum, signal.SIG_DFL) - signal.raise_signal(signum) - return - self._closing = True - with contextlib.suppress(Exception): - # >= 2.18.5 tears down fully, then exits with this status; older - # builds exit with the status but skip the teardown - self._close_app(128 + signum) - # close() only returns when fast shutdown is disabled - signal.signal(signum, signal.SIG_DFL) - signal.raise_signal(signum) - - def _close_app(self, exit_code): - """Close the app with ``exit_code``, warning loudly if the parameter is unsupported.""" - # Probe the signature instead of catching ``TypeError`` from the call: a - # ``TypeError`` raised *inside* ``close()``'s own teardown would otherwise be - # misread as an unsupported ``exit_code`` and trigger a second ``close()`` on a - # half-torn-down app — the exact re-entrancy this class exists to prevent. - try: - accepts_exit_code = "exit_code" in inspect.signature(self._app.close).parameters - except (TypeError, ValueError): - # Signature could not be introspected (e.g. a C-bound close); fall back to - # the no-argument close so the app still shuts down cleanly. - accepts_exit_code = False - if accepts_exit_code: - self._app.close(exit_code=exit_code) - else: - print( - "[ISAACLAB] WARNING: SimulationApp.close() does not accept exit_code;" - " this process may report exit code 0 instead of its failure status.", - file=sys.__stderr__, - flush=True, - ) - self._app.close() + def has_gui() -> bool: + """Return whether the launched app has an interactive GUI (window, livestream, or XR).""" + return bool(get_settings_manager().get("/isaaclab/has_gui")) diff --git a/source/isaaclab/isaaclab/app/logging_utils.py b/source/isaaclab/isaaclab/app/logging_utils.py index 1650d0192dd0..f3b6ae9a69b4 100644 --- a/source/isaaclab/isaaclab/app/logging_utils.py +++ b/source/isaaclab/isaaclab/app/logging_utils.py @@ -7,7 +7,7 @@ The logging intent expressed by the ``--verbose`` / ``--info`` CLI arguments must be honored by every simulation backend, not just the Kit-based one. Keeping the resolution -here (rather than inside :class:`~isaaclab.app.AppLauncher`) lets the kitless launch path +here (rather than inside :class:`~isaaclab_physx.app.KitLauncher`) lets the kitless launch path apply the same level without constructing Kit. """ diff --git a/source/isaaclab/isaaclab/app/settings_manager.py b/source/isaaclab/isaaclab/app/settings_manager.py index 85499b085d5b..c1b13c964d74 100644 --- a/source/isaaclab/isaaclab/app/settings_manager.py +++ b/source/isaaclab/isaaclab/app/settings_manager.py @@ -6,7 +6,7 @@ """Settings manager for Isaac Lab that works with or without Omniverse carb.settings. This module provides a unified settings interface that can work in two modes: -1. Omniverse mode: Uses carb.settings when SimulationApp is launched +1. Backed mode: Delegates to a ``carb.settings``-compatible backend set by the Kit launcher 2. Standalone mode: Uses pure Python dictionary when running without Omniverse This allows Isaac Lab to run visualizers like Rerun and Newton without requiring @@ -25,7 +25,7 @@ class SettingsManager: This class can work in two modes: - Standalone mode: Uses a Python dictionary to store settings - - Omniverse mode: Delegates to carb.settings when available + - Backed mode: Delegates to the backend given to :meth:`set_backend` The interface is designed to be compatible with the carb.settings API. @@ -55,8 +55,7 @@ def __init__(self): return self._standalone_settings: dict[str, Any] = {} - self._carb_settings = None - self._use_carb = False + self._backend = None self._needs_init = False @classmethod @@ -74,20 +73,16 @@ def instance(cls) -> "SettingsManager": return instance - def initialize_carb_settings(self): - """Initialize carb.settings if SimulationApp has been launched. + def set_backend(self, backend: Any) -> None: + """Delegate all settings to *backend* from now on. - This should be called after SimulationApp is created to enable - Omniverse mode. If not called, the manager operates in standalone mode. - """ - try: - import carb + The Kit launcher calls this with ``carb.settings.get_settings()`` once Kit has started. + Until then, the manager operates in standalone mode. - self._carb_settings = carb.settings.get_settings() - self._use_carb = True - except (ImportError, AttributeError): - # carb not available or SimulationApp not launched - use standalone mode - self._use_carb = False + Args: + backend: A ``carb.settings``-compatible settings interface. + """ + self._backend = backend def set(self, path: str, value: Any) -> None: """Set a setting value at the given path. @@ -96,19 +91,18 @@ def set(self, path: str, value: Any) -> None: path: The settings path (e.g., "/isaaclab/render/offscreen") value: The value to set """ - if self._use_carb and self._carb_settings is not None: - # Delegate to carb.settings + if self._backend is not None: if isinstance(value, bool): - self._carb_settings.set_bool(path, value) + self._backend.set_bool(path, value) elif isinstance(value, int): - self._carb_settings.set_int(path, value) + self._backend.set_int(path, value) elif isinstance(value, float): - self._carb_settings.set_float(path, value) + self._backend.set_float(path, value) elif isinstance(value, str): - self._carb_settings.set_string(path, value) + self._backend.set_string(path, value) else: # For other types, try generic set - self._carb_settings.set(path, value) + self._backend.set(path, value) else: # Standalone mode - use dictionary self._standalone_settings[path] = value @@ -123,9 +117,8 @@ def get(self, path: str, default: Any = None) -> Any: Returns: The value at the path, or default if not found """ - if self._use_carb and self._carb_settings is not None: - # Delegate to carb.settings - value = self._carb_settings.get(path) + if self._backend is not None: + value = self._backend.get(path) return value if value is not None else default else: # Standalone mode - use dictionary @@ -167,15 +160,6 @@ def set_string(self, path: str, value: str) -> None: """ self.set(path, value) - @property - def is_omniverse_mode(self) -> bool: - """Check if the settings manager is using carb.settings (Omniverse mode). - - Returns: - True if using carb.settings, False if using standalone mode - """ - return self._use_carb - def get_settings_manager() -> SettingsManager: """Get the global settings manager instance. @@ -192,13 +176,3 @@ def get_settings_manager() -> SettingsManager: if instance is None: instance = SettingsManager() return instance - - -def initialize_carb_settings(): - """Initialize carb.settings integration for the global settings manager. - - This should be called after SimulationApp is created to enable - Omniverse mode for the global settings manager. - """ - manager = get_settings_manager() - manager.initialize_carb_settings() diff --git a/source/isaaclab/isaaclab/app/sim_launcher.py b/source/isaaclab/isaaclab/app/sim_launcher.py index f6894e298d21..bbf47eda8b3a 100644 --- a/source/isaaclab/isaaclab/app/sim_launcher.py +++ b/source/isaaclab/isaaclab/app/sim_launcher.py @@ -13,13 +13,12 @@ from __future__ import annotations import argparse -import importlib.metadata import logging import os -import sys +import warnings from collections.abc import Generator from contextlib import contextmanager -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any from isaaclab_newton.physics import NewtonCfg, VBDSolverCfg @@ -32,24 +31,80 @@ from ..renderers.renderer_cfg import RendererCfg from ..sensors.camera.camera_cfg import CameraCfg from ..utils._device import set_cuda_device +from ..utils.string import string_to_callable from .logging_utils import apply_python_logging_level, resolve_python_logging_level +from .settings_manager import get_settings_manager logger = logging.getLogger(__name__) -# Class names of the kitless physics backends (Newton, OvPhysX). Matched by exact -# name so subclasses with distinct names opt out. -_KITLESS_PHYSICS_CFGS = ("NewtonCfg", "OvPhysxCfg") +_VISUALIZER_TYPES = ("kit", "newton_gl", "newton_rtx", "rerun", "viser", "none") +_VISUALIZER_ALIASES = {"newton": "newton_gl"} +"""Deprecated ``--visualizer`` names and their replacements.""" + +_KIT_LAUNCHER = "isaaclab_physx.app:KitLauncher" +"""Launcher for Kit needs that no config names, e.g. a default-renderer camera or ``--viz kit``.""" + + +class SimulationLauncher: + """Starts the process runtime a resolved simulation config requires, and stops it. + + A backend package subclasses this and names the subclass in its config's ``launcher_type``. + :func:`launch_simulation` constructs it, which starts the runtime, and calls :meth:`close` when the + simulation ends. + """ + + device: str | None = None + """Simulation device chosen by the runtime, or None to keep the config's device.""" + + def __init__(self, launcher_args: argparse.Namespace | dict | None = None): + """Start the runtime. + + Args: + launcher_args: Parsed launcher arguments. + """ + + def close(self, exit_code: int = 0) -> None: + """Stop the runtime. + + Args: + exit_code: Exit status of the simulation, for runtimes that end the process. + """ def add_launcher_args(parser: argparse.ArgumentParser) -> None: """Add simulation-launcher CLI arguments (``--device``, ``--viz``, etc.) to *parser*. - Delegates to :meth:`AppLauncher.add_app_launcher_args` so that user scripts - do not need to import ``AppLauncher`` directly. + The arguments are defined by the Kit launcher, which consumes most of them. """ - from . import AppLauncher + string_to_callable(_KIT_LAUNCHER).add_app_launcher_args(parser) + + +def fuse_kit_args(argv: list[str]) -> list[str]: + """Fuse ``["--kit_args", ""]`` pairs into single ``--kit_args=`` tokens. - AppLauncher.add_app_launcher_args(parser) + Argparse rejects a value token that itself looks like an option (starts with ``-`` and contains + no space) with "expected one argument", and Kit arguments always start with ``--``. Fusing the + pair into the ``=``-attached form before parsing makes the documented space-separated form work + for a single Kit argument. All other forms pass through unchanged. + + Args: + argv: Command-line tokens, excluding the program name. + + Returns: + Tokens with any affected pair replaced by one fused token. + """ + fused: list[str] = [] + index = 0 + while index < len(argv): + token = argv[index] + next_token = argv[index + 1] if index + 1 < len(argv) else None + if token == "--kit_args" and next_token is not None and next_token.startswith("-") and " " not in next_token: + fused.append(f"--kit_args={next_token}") + index += 2 + else: + fused.append(token) + index += 1 + return fused def make_physics_cfg(physics_cfg_str: str) -> PhysicsCfg: @@ -143,65 +198,120 @@ def _set_arg(launcher_args: argparse.Namespace | dict | None, key: str, value: A launcher_args[key] = value -def _get_visualizer_types(launcher_args: argparse.Namespace | dict | None) -> set[str]: - """Extract requested visualizer type names (``--visualizer kit,newton,...``).""" - visualizers = _get_arg(launcher_args, "visualizer") - if not visualizers: - return set() - if isinstance(visualizers, str): - visualizers = visualizers.split(",") - return {str(v).strip().lower() for v in visualizers if str(v).strip()} +def _parse_visualizer_csv(value: str) -> list[str] | None: + """Parse the ``--visualizer`` comma-separated list into canonical names; ``none`` yields None.""" + token = (value or "").strip() + if not token: + raise argparse.ArgumentTypeError( + "Invalid --visualizer value: empty string. Use a comma-separated list, e.g. --viz kit,newton_gl." + ) + if " " in token: + raise argparse.ArgumentTypeError( + "Invalid --visualizer value: spaces are not allowed. " + "Use a comma-separated list without spaces, e.g. --viz kit,newton_gl,rerun,viser." + ) + names = [item.strip().lower() for item in token.split(",")] + if any(not name for name in names): + raise argparse.ArgumentTypeError( + "Invalid --visualizer value: empty visualizer entry detected. " + "Use a comma-separated list without empty items." + ) + invalid = [name for name in names if name not in _VISUALIZER_TYPES and name not in _VISUALIZER_ALIASES] + if invalid: + raise argparse.ArgumentTypeError( + f"Invalid --visualizer value(s): {', '.join(invalid)}. " + f"Valid options: {', '.join(sorted(_VISUALIZER_TYPES))}." + ) + for name in names: + if name in _VISUALIZER_ALIASES: + warnings.warn( + f"--viz '{name}' is deprecated. Use '--viz {_VISUALIZER_ALIASES[name]}' instead.", + DeprecationWarning, + stacklevel=3, + ) + names = [_VISUALIZER_ALIASES.get(name, name) for name in names] + if "none" in names: + if len(names) > 1: + raise argparse.ArgumentTypeError( + "Invalid --visualizer value: 'none' cannot be combined with other visualizer types." + ) + return None + return list(dict.fromkeys(names)) def _get_livestream_mode(launcher_args: argparse.Namespace | dict | None) -> int: - """Return effective livestream mode using the same CLI-over-env precedence as AppLauncher.""" - livestream_arg = _get_arg(launcher_args, "livestream", -1) - if livestream_arg is not None and int(livestream_arg) >= 0: - return int(livestream_arg) - return int(os.environ.get("LIVESTREAM", 0)) - - -def _ensure_livestream_kit_visualizer(launcher_args: argparse.Namespace | dict | None) -> None: - """Request the Kit visualizer when livestreaming needs a video-producing viewport.""" - if launcher_args is None or _get_livestream_mode(launcher_args) == 0: + """Return the livestream mode: ``--livestream`` when set (>= 0), else the ``LIVESTREAM`` environment variable.""" + livestream = _get_arg(launcher_args, "livestream", -1) + if livestream is None or int(livestream) < 0: + livestream = os.environ.get("LIVESTREAM", 0) + livestream = int(livestream) + if livestream not in (0, 1, 2): + raise ValueError(f"Invalid livestream mode: {livestream}. Expected 0 (disabled), 1, or 2.") + return livestream + + +def _normalize_launcher_args(launcher_args: argparse.Namespace | dict | None) -> None: + """Resolve the livestream mode and the visualizer selection in place, once for every consumer. + + Writes ``livestream`` (the effective mode), ``visualizer`` (canonical names, or None when all are + disabled), ``visualizer_explicit`` and ``visualizer_disable_all``. Livestreaming adds the Kit + visualizer, whose viewport produces the stream. Normalizing twice gives the same result. + """ + if launcher_args is None: return - - visualizer_explicit = bool(_get_arg(launcher_args, "visualizer_explicit", False)) + livestream = _get_livestream_mode(launcher_args) visualizers = _get_arg(launcher_args, "visualizer") - if visualizer_explicit and (visualizers is None or "none" in _get_visualizer_types(launcher_args)): - raise ValueError("Livestreaming requires the Kit visualizer. Remove '--viz none' or pass '--viz kit'.") - - visualizer_types = _get_visualizer_types(launcher_args) - if "kit" in visualizer_types: - return - - requested_visualizers = [] + explicit = bool(_get_arg(launcher_args, "visualizer_explicit", False)) or visualizers is not None + if visualizers and not isinstance(visualizers, str): + visualizers = ",".join(str(visualizer).strip() for visualizer in visualizers) if visualizers: - requested_visualizers = ( - [visualizer.strip() for visualizer in visualizers.split(",")] - if isinstance(visualizers, str) - else [str(visualizer).strip() for visualizer in visualizers if str(visualizer).strip()] - ) - requested_visualizers.append("kit") - _set_arg(launcher_args, "visualizer", requested_visualizers) + try: + visualizers = _parse_visualizer_csv(visualizers) + except argparse.ArgumentTypeError as error: + raise ValueError(str(error)) from error + disable_all = explicit and visualizers is None + if livestream > 0: + if disable_all: + raise ValueError("Livestreaming requires the Kit visualizer. Remove '--viz none' or pass '--viz kit'.") + if "kit" not in (visualizers or []): + visualizers = [*(visualizers or []), "kit"] + explicit = True + _set_arg(launcher_args, "livestream", livestream) + _set_arg(launcher_args, "visualizer", visualizers) + _set_arg(launcher_args, "visualizer_explicit", explicit) + _set_arg(launcher_args, "visualizer_disable_all", disable_all) + + +def _sync_visualizer_cli_settings(launcher_args: argparse.Namespace | dict) -> None: + """Write the normalized visualizer selection and ``--max_visible_envs`` to the settings.""" + max_visible_envs = _get_arg(launcher_args, "max_visible_envs") + if max_visible_envs is not None and int(max_visible_envs) < 0: + raise ValueError(f"Invalid value for --max_visible_envs: {max_visible_envs}. Expected non-negative int.") + settings = get_settings_manager() + settings.set_string("/isaaclab/visualizer/types", " ".join(_get_arg(launcher_args, "visualizer") or ())) + settings.set_bool("/isaaclab/visualizer/explicit", bool(_get_arg(launcher_args, "visualizer_explicit", False))) + settings.set_bool( + "/isaaclab/visualizer/disable_all", bool(_get_arg(launcher_args, "visualizer_disable_all", False)) + ) + # Sentinel: ``-1`` means ``--max_visible_envs`` was not passed (see ``SimulationContext``). + settings.set_int("/isaaclab/visualizer/max_visible_envs", -1 if max_visible_envs is None else int(max_visible_envs)) -def _get_visualizer_intent(cfg) -> dict[str, bool]: - """Compute upstream visualizer intent from ``cfg.sim.visualizer_cfgs``.""" +def _get_visualizer_intent(cfg, launcher_args: argparse.Namespace | dict | None) -> dict[str, bool]: + """Compute the visualizer intent of ``cfg.sim.visualizer_cfgs``, OR-ed with a caller's ``visualizer_intent``.""" # Accept both env_cfg (has .sim.visualizer_cfgs) and a bare SimulationCfg # (has .visualizer_cfgs directly). sim = getattr(cfg, "sim", None) visualizer_cfgs = getattr(sim, "visualizer_cfgs", None) or getattr(cfg, "visualizer_cfgs", None) if visualizer_cfgs is None: - return {"has_any_visualizers": False, "has_kit_visualizer": False, "has_kit_streaming_view": False} + visualizer_cfgs = [] cfgs = visualizer_cfgs if isinstance(visualizer_cfgs, list) else [visualizer_cfgs] - cfgs = [c for c in cfgs if c is not None] + kit_cfgs = [c for c in cfgs if getattr(c, "visualizer_type", None) == "kit"] + caller_intent = _get_arg(launcher_args, "visualizer_intent") or {} return { - "has_any_visualizers": len(cfgs) > 0, - "has_kit_visualizer": any(getattr(c, "visualizer_type", None) == "kit" for c in cfgs), - "has_kit_streaming_view": any( - getattr(c, "visualizer_type", None) == "kit" and bool(getattr(c, "streaming_view", False)) for c in cfgs - ), + "has_kit_visualizer": bool(kit_cfgs) or bool(caller_intent.get("has_kit_visualizer")), + "has_kit_streaming_view": any(bool(getattr(c, "streaming_view", False)) for c in kit_cfgs) + or bool(caller_intent.get("has_kit_streaming_view")), } @@ -229,16 +339,15 @@ class Scan: has_ovrtx: bool has_kit_camera: bool has_kit_physics: bool # PhysX (Kit-based) - has_kitless_physics: bool # Newton or OvPhysX has_ovphysx_physics: bool needs_kit: bool + launcher_types: list[str] = field(default_factory=list) # named by the physics and renderer configs def _refresh_physics_scan_flags(config_scan: Scan, concrete_physics_cfgs: list[PhysicsCfg], has_physics: bool) -> None: """Refresh physics-derived launch signals from concrete physics configs.""" names = [type(pcfg).__name__ for pcfg in concrete_physics_cfgs] config_scan.has_kit_physics = "PhysxCfg" in names - config_scan.has_kitless_physics = any(name in _KITLESS_PHYSICS_CFGS for name in names) config_scan.has_ovphysx_physics = "OvPhysxCfg" in names config_scan.needs_kit = config_scan.has_kit_camera or config_scan.has_kit_physics or not has_physics @@ -258,20 +367,25 @@ def scan(cfg, launcher_args: argparse.Namespace | dict | None = None) -> Scan: same launch decision. """ # Livestreaming implies a Kit visualizer; make that visible to auto RTX resolution. - _ensure_livestream_kit_visualizer(launcher_args) + _normalize_launcher_args(launcher_args) physics_str = _get_arg(launcher_args, "physics", None) physics_cfgs: list[PhysicsCfg] = [] concrete_physics_cfgs: list[PhysicsCfg] = [] effective_cfg: Any = cfg - has_ovrtx = "newton_rtx" in _get_visualizer_types(launcher_args) + has_ovrtx = "newton_rtx" in (_get_arg(launcher_args, "visualizer") or ()) has_auto_rtx = False has_auto_physx = False has_kit_camera = False auto_rtx_locations: list[tuple[Any, Any, bool]] = [] # (parent, key, is_cam_renderer) for each auto RTX placeholder auto_physx_locations: list[tuple[PhysicsCfg, Any, Any, bool]] = [] # (node, parent, key, is_first_physics) + launcher_types: list[str] = [] visited: set[int] = set() + def add_launcher_type(node): + if getattr(node, "launcher_type", None): + launcher_types.append(node.launcher_type) + def visit(node, parent, key): nonlocal effective_cfg, has_ovrtx, has_auto_rtx, has_auto_physx, has_kit_camera if _is_auto_rtx_renderer(node): @@ -300,6 +414,8 @@ def visit(node, parent, key): has_ovrtx = True elif _is_kit_camera(node): has_kit_camera = True + if isinstance(node, (PhysicsCfg, RendererCfg)): + add_launcher_type(node) try: children = vars(node) @@ -316,13 +432,13 @@ def visit(node, parent, key): config_scan = Scan( resolved_physics_cfg=physics_cfgs[0] if physics_cfgs else None, effective_cfg=effective_cfg, - visualizer_intent=_get_visualizer_intent(cfg), + visualizer_intent=_get_visualizer_intent(cfg, launcher_args), has_ovrtx=has_ovrtx, has_kit_camera=has_kit_camera, has_kit_physics=False, - has_kitless_physics=False, has_ovphysx_physics=False, needs_kit=False, + launcher_types=launcher_types, ) _refresh_physics_scan_flags(config_scan, concrete_physics_cfgs, has_physics) @@ -332,6 +448,7 @@ def visit(node, parent, key): for node, parent, key, is_first_physics in auto_physx_locations: physics_cfg = _resolve_physx_auto_cfg(node, use_isaac_sim) concrete_physics_cfgs.append(physics_cfg) + add_launcher_type(physics_cfg) if parent is None: effective_cfg = physics_cfg config_scan.effective_cfg = physics_cfg @@ -354,7 +471,9 @@ def visit(node, parent, key): for location in auto_rtx_locations: if location[0] is None: raise ValueError("Automatic RTX renderer placeholders cannot be resolved as the root config.") - setattr(location[0], location[1], renderer_factory()) + renderer_cfg = renderer_factory() + add_launcher_type(renderer_cfg) + setattr(location[0], location[1], renderer_cfg) has_auto_camera = has_auto_camera or location[2] # Update the scan with the resolved auto RTX renderer type and Kit-camera status. @@ -373,9 +492,10 @@ def visit(node, parent, key): def _has_kit_visualizer(config_scan: Scan, launcher_args: argparse.Namespace | dict | None) -> bool: - """Return whether the run requests the Kit visualizer through config or CLI.""" - visualizer_types = _get_visualizer_types(launcher_args) - return "kit" in visualizer_types or config_scan.visualizer_intent["has_kit_visualizer"] + """Return whether the run uses the Kit visualizer; an explicit CLI selection overrides the config.""" + if _get_arg(launcher_args, "visualizer_explicit", False): + return "kit" in (_get_arg(launcher_args, "visualizer") or ()) + return config_scan.visualizer_intent["has_kit_visualizer"] def _get_kit_runtime_sources(config_scan: Scan, launcher_args: argparse.Namespace | dict | None) -> tuple[str, ...]: @@ -444,11 +564,10 @@ def _validate_runtime(scan: Scan, kit_sources: tuple[str, ...]) -> None: def _resolve_distributed_device(cfg, launcher_args: argparse.Namespace | dict | None) -> None: - """Set ``cfg.sim.device`` for distributed training, mirroring AppLauncher's fallback. + """Set ``cfg.sim.device`` and the launcher ``device`` for distributed training. When ``--distributed`` restricts each process to one GPU, ``local_rank`` may exceed - the visible device count. The Kit path later overwrites this with ``AppLauncher.device``; - the kitless path relies solely on this value. + the visible device count, so the process falls back to the one GPU it can see. """ if not _get_arg(launcher_args, "distributed", False): return @@ -463,6 +582,7 @@ def _resolve_distributed_device(cfg, launcher_args: argparse.Namespace | dict | sim_cfg = getattr(cfg, "sim", None) if sim_cfg is not None: sim_cfg.device = device_str + _set_arg(launcher_args, "device", device_str) set_cuda_device(device_str) logger.info( "Distributed device resolved to %s (local_rank=%d, visible_gpus=%d)", @@ -481,7 +601,7 @@ def launch_simulation( Walks the config tree once (resolving ``--physics``, validating the physics/renderer/visualizer combination, and deciding whether Isaac Sim Kit is - needed), then launches ``AppLauncher`` for Kit-based backends (closed on exit) or + needed), then starts the launcher each required runtime's config names (closed on exit) or does nothing for kitless ones. Cameras are auto-enabled for Kit-renderer sensors. Yields the resolved physics config, so a script can pass a bare placeholder and @@ -504,6 +624,8 @@ def launch_simulation( a tool that reaches a Kit-only extension API. This is additive -- it can only turn a kitless launch into a Kit one, never the reverse, so a config that already needs Kit still launches it when the key is absent or ``False``. + * ``visualizer_intent``: Visualizer intent the config cannot express, e.g. + ``{"has_kit_visualizer": True}``; it is combined with the intent of *cfg*. """ if launcher_args is None: launcher_args = {} @@ -513,22 +635,14 @@ def launch_simulation( config_scan = scan(cfg, launcher_args) effective_cfg = config_scan.effective_cfg physics_cfg = config_scan.resolved_physics_cfg - visualizer_types = _get_visualizer_types(launcher_args) kit_sources = _get_kit_runtime_sources(config_scan, launcher_args) _validate_runtime(config_scan, kit_sources) - if config_scan.has_ovrtx: - # USD discovers schema plugins once, so load OVRTX only after rejecting incompatible Kit runtimes. - import ovrtx - - ovrtx.register_schema_paths() needs_kit = bool(kit_sources) - _set_arg(launcher_args, "visualizer_intent", config_scan.visualizer_intent) + _set_arg(launcher_args, "kit_visualizer", _has_kit_visualizer(config_scan, launcher_args)) - # Kit-based backends apply the Python logging level inside AppLauncher; kitless backends - # never construct it, so honor --verbose / --info here to keep behavior consistent. - if not needs_kit: - apply_python_logging_level(resolve_python_logging_level(launcher_args)) + # Honor --verbose / --info; the Kit launcher re-applies this level once Kit has started. + apply_python_logging_level(resolve_python_logging_level(launcher_args)) if needs_kit and (config_scan.has_kit_camera or config_scan.visualizer_intent.get("has_kit_streaming_view")): if not _get_arg(launcher_args, "enable_cameras", False): @@ -538,39 +652,24 @@ def launch_simulation( ) _set_arg(launcher_args, "enable_cameras", True) - # Resolve distributed device early, before AppLauncher or physics init. + # Resolve distributed device early, before any launcher or physics init. _resolve_distributed_device(effective_cfg, launcher_args) - visualizer_explicit_none = _get_arg(launcher_args, "visualizer") is None and _get_arg( - launcher_args, "visualizer_explicit", False - ) - - close_fn: Any = None - if needs_kit: - _ensure_isaac_sim_available() - from ..utils import has_kit - - if not has_kit(): - from . import AppLauncher - - app_launcher = AppLauncher(launcher_args) - # AppLauncher may refine the device choice; propagate its final value, - # intentionally overwriting the value set by _resolve_distributed_device. - sim_cfg = getattr(effective_cfg, "sim", None) - if sim_cfg is not None and hasattr(app_launcher, "device"): - sim_cfg.device = app_launcher.device - close_fn = app_launcher.app.close - elif visualizer_types or visualizer_explicit_none: - # Kitless path: AppLauncher is skipped, so persist the visualizer selection in - # SettingsManager so SimulationContext._get_cli_visualizer_types() can find it. - from . import AppLauncher - - disable_all = visualizer_explicit_none or "none" in visualizer_types - base = vars(launcher_args) if isinstance(launcher_args, argparse.Namespace) else launcher_args - if base is not None: - AppLauncher.sync_visualizer_cli_settings_to_carb( - {**base, "visualizer_explicit": True, "visualizer_disable_all": disable_all} - ) + # Start the launchers the resolved config names, plus Kit and OVRTX for needs that no config names + # (e.g. a default-renderer camera, ``--viz kit`` or ``--viz newton_rtx``); Kit starts first. + launcher_types = [_KIT_LAUNCHER] if needs_kit else [] + launcher_types += config_scan.launcher_types + if config_scan.has_ovrtx: + # validated above: OVRTX never shares the process with Kit + launcher_types.append(OVRTXRendererCfg.launcher_type) + launchers = [string_to_callable(launcher_type)(launcher_args) for launcher_type in dict.fromkeys(launcher_types)] + for launcher in launchers: + # the runtime may refine the device choice made by _resolve_distributed_device + sim_cfg = getattr(effective_cfg, "sim", None) + if sim_cfg is not None and launcher.device is not None: + sim_cfg.device = launcher.device + # after the launchers, so a started Kit already backs the settings + _sync_visualizer_cli_settings(launcher_args) exit_code = 0 try: @@ -587,64 +686,5 @@ def launch_simulation( traceback.print_exc() raise finally: - if close_fn is not None: - if exit_code: - close_fn(exit_code=exit_code) - else: - close_fn() - - -def _ensure_isaac_sim_available() -> None: - """Raise ``SystemExit`` with an actionable hint when Isaac Sim / Kit is missing.""" - from . import AppLauncher # noqa: PLC0415 - - if AppLauncher.is_available(): - return - - isaaclab_path = os.environ.get("ISAACLAB_PATH") - local_sim = os.path.join(isaaclab_path, "_isaac_sim") if isaaclab_path else None - extra_hint = "" - if local_sim and os.path.isdir(local_sim): - launcher, source = ("isaaclab.bat", f'call "{local_sim}\\setup_conda_env.bat"') - if sys.platform != "win32": - launcher, source = ("./isaaclab.sh", f'source "{local_sim}/setup_conda_env.sh"') - extra_hint = ( - f" Found a local Isaac Sim at {local_sim} but its environment is not active.\n" - f" Either run via `{launcher} ...` (which sources the Isaac Sim env automatically),\n" - f" or in your current shell run:\n" - f" {source}\n" - ) - - try: - installed_version = importlib.metadata.version("isaacsim") - except importlib.metadata.PackageNotFoundError: - installed_version = None - - if installed_version: - logger.error( - f"\n[ERROR] Isaac Sim {installed_version} is installed, but its full runtime is unavailable.\n" - "\n" - " This environment requires Isaac Sim and Omniverse Kit.\n" - " PhysX backend and Kit visualizer require Isaac Sim.\n" - "\n" - " The current Python environment does not expose the SimulationApp API.\n" - f"{extra_hint}" - " Install the full Isaac Sim runtime from the Isaac Lab directory by running:\n" - " uv run isaaclab -i isaacsim\n" - "\n" - " See https://isaac-sim.github.io/IsaacLab/main/source/setup/installation for details.\n" - ) - raise SystemExit(1) - - logger.error( - "\n[ERROR] Isaac Sim is not installed or not found on PYTHONPATH.\n" - "\n" - " This environment requires Isaac Sim and Omniverse Kit.\n" - " PhysX backend and Kit visualizer currently requires Isaac Sim.\n" - "\n" - f"{extra_hint}" - " To fix this, ensure Isaac Sim is installed and available in the current environment.\n" - "\n" - " See https://isaac-sim.github.io/IsaacLab/main/source/setup/installation for details.\n" - ) - raise SystemExit(1) + for launcher in reversed(launchers): + launcher.close(exit_code) diff --git a/source/isaaclab/isaaclab/benchmark/asset_suites/cli.py b/source/isaaclab/isaaclab/benchmark/asset_suites/cli.py index 8a215bed5e82..4c54d36aa328 100644 --- a/source/isaaclab/isaaclab/benchmark/asset_suites/cli.py +++ b/source/isaaclab/isaaclab/benchmark/asset_suites/cli.py @@ -65,9 +65,9 @@ def run_asset_benchmark_cli( ) parser.add_argument("--no_shape_checks", action="store_true", help="Disable shape and dtype assertions") if include_app_launcher_args: - from ...app import AppLauncher + from ...app import add_launcher_args - AppLauncher.add_app_launcher_args(parser) + add_launcher_args(parser) else: parser.add_argument("--device", type=str, default="cuda:0", help="Tensor device") args = parser.parse_args(argv) diff --git a/source/isaaclab/isaaclab/benchmark/dispatch.py b/source/isaaclab/isaaclab/benchmark/dispatch.py index e89eaae305f4..4b24c02c3d01 100644 --- a/source/isaaclab/isaaclab/benchmark/dispatch.py +++ b/source/isaaclab/isaaclab/benchmark/dispatch.py @@ -239,16 +239,7 @@ def _append_value(argv: list[str], option: str, value: object | None) -> None: def _fuse_kit_args(argv: list[str]) -> list[str]: - """Fuse an option-like Kit argument value into argparse-compatible form.""" - fused: list[str] = [] - index = 0 - while index < len(argv): - token = argv[index] - next_token = argv[index + 1] if index + 1 < len(argv) else None - if token == "--kit_args" and next_token is not None and next_token.startswith("-") and " " not in next_token: - fused.append(f"--kit_args={next_token}") - index += 2 - else: - fused.append(token) - index += 1 - return fused + # imported here so that importing this module stays lightweight + from ..app.sim_launcher import fuse_kit_args + + return fuse_kit_args(argv) diff --git a/source/isaaclab/isaaclab/cli/commands/deploy.py b/source/isaaclab/isaaclab/cli/commands/deploy.py index 03e8a61854f6..117e189f0fe1 100644 --- a/source/isaaclab/isaaclab/cli/commands/deploy.py +++ b/source/isaaclab/isaaclab/cli/commands/deploy.py @@ -16,7 +16,8 @@ import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils.hydra import resolve_task_config -from ...app import AppLauncher +from ...app import add_launcher_args, launch_simulation +from ...app.sim_launcher import fuse_kit_args def command_deploy_leapp(argv: list[str] | None = None) -> int: @@ -35,23 +36,15 @@ def command_deploy_leapp(argv: list[str] | None = None) -> int: parser.add_argument("--task", required=True, help="Name of the registered Isaac Lab task.") parser.add_argument("--pipeline", required=True, help="Path to the exported LEAPP YAML pipeline description.") parser.add_argument("--seed", type=int, default=None, help="Seed for the environment.") - AppLauncher.add_app_launcher_args(parser) + add_launcher_args(parser) if argv is None: argv = sys.argv[1:] - args_cli, hydra_args = parser.parse_known_args(AppLauncher._fuse_kit_args(argv)) + args_cli, hydra_args = parser.parse_known_args(fuse_kit_args(argv)) original_argv = sys.argv sys.argv = [original_argv[0]] + hydra_args - simulation_app = None - env = None try: - simulation_app = AppLauncher(args_cli).app - - # Runtime environment classes load simulation modules and must only be - # imported after SimulationApp has initialized Kit. - from ...envs import LeappDeploymentEnv - task_name = args_cli.task.split(":")[-1] env_cfg, _ = resolve_task_config(task_name, "") @@ -60,32 +53,34 @@ def command_deploy_leapp(argv: list[str] | None = None) -> int: if args_cli.device is not None: env_cfg.sim.device = args_cli.device - env = LeappDeploymentEnv(env_cfg, args_cli.pipeline) - - if getattr(args_cli, "headless", False): - print( - "[WARN]: Running deploy without a viewport. This happens when headless mode is active, " - "including the default case where no visualizer was selected. The policy may be " - "stepping normally, but no viewport will appear unless you specify the " - "`--visualizer` field." - ) - - print(f"[INFO]: Deploying task '{task_name}' with LEAPP pipeline: {args_cli.pipeline}") - print(f"[INFO]: Num envs: {env.num_envs}, decimation: {env.cfg.decimation}, step_dt: {env.step_dt:.4f}s") - - env.reset() - with torch.inference_mode(): - while simulation_app.is_running(): - env.step() + with launch_simulation(env_cfg, args_cli): + # Runtime environment classes load simulation modules and must only be + # imported after the simulation runtime has started. + from ...envs import LeappDeploymentEnv + + env = LeappDeploymentEnv(env_cfg, args_cli.pipeline) + + if getattr(args_cli, "headless", False): + print( + "[WARN]: Running deploy without a viewport. This happens when headless mode is active, " + "including the default case where no visualizer was selected. The policy may be " + "stepping normally, but no viewport will appear unless you specify the " + "`--visualizer` field." + ) + + print(f"[INFO]: Deploying task '{task_name}' with LEAPP pipeline: {args_cli.pipeline}") + print(f"[INFO]: Num envs: {env.num_envs}, decimation: {env.cfg.decimation}, step_dt: {env.step_dt:.4f}s") + + env.reset() + try: + with torch.inference_mode(): + while env.sim.is_headless_or_exist_active_visualizer(): + env.step() + finally: + env.close() except KeyboardInterrupt: return 0 finally: - try: - if env is not None: - env.close() - finally: - if simulation_app is not None: - simulation_app.close() - sys.argv = original_argv + sys.argv = original_argv return 0 diff --git a/source/isaaclab/isaaclab/envs/direct_marl_env.py b/source/isaaclab/isaaclab/envs/direct_marl_env.py index 3e4fa9f80937..2e7179ca8208 100644 --- a/source/isaaclab/isaaclab/envs/direct_marl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_marl_env.py @@ -499,14 +499,6 @@ def seed(seed: int = -1) -> int: Returns: The seed used for random generator. """ - # set seed for replicator - try: - import omni.replicator.core as rep - - rep.set_global_seed(seed) - except ModuleNotFoundError: - pass - # set seed for torch and other libraries return configure_seed(seed) def render(self, recompute: bool = False) -> np.ndarray | None: diff --git a/source/isaaclab/isaaclab/envs/direct_rl_env.py b/source/isaaclab/isaaclab/envs/direct_rl_env.py index 0851f9f2cd21..2d4be7a96bdd 100644 --- a/source/isaaclab/isaaclab/envs/direct_rl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_rl_env.py @@ -477,14 +477,6 @@ def seed(seed: int = -1) -> int: Returns: The seed used for random generator. """ - # set seed for replicator - try: - import omni.replicator.core as rep - - rep.set_global_seed(seed) - except ModuleNotFoundError: - pass - # set seed for torch and other libraries return configure_seed(seed) def render(self, recompute: bool = False) -> np.ndarray | None: diff --git a/source/isaaclab/isaaclab/envs/manager_based_env.py b/source/isaaclab/isaaclab/envs/manager_based_env.py index 2eaf81c963c7..778023f6a58b 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_env.py @@ -584,14 +584,6 @@ def seed(seed: int = -1) -> int: Returns: The seed used for random generator. """ - # set seed for replicator - try: - import omni.replicator.core as rep - - rep.set_global_seed(seed) - except (ModuleNotFoundError, AttributeError): - pass - # set seed for torch and other libraries return configure_seed(seed) def close(self): diff --git a/source/isaaclab/isaaclab/envs/manager_based_env_cfg.py b/source/isaaclab/isaaclab/envs/manager_based_env_cfg.py index 9ecb341bd473..5ff8d38fb970 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/manager_based_env_cfg.py @@ -43,6 +43,9 @@ class DefaultEventManagerCfg: class ManagerBasedEnvCfg: """Base configuration of the environment.""" + class_type: type | str = "{DIR}.manager_based_env:ManagerBasedEnv" + """The environment class constructed from this configuration.""" + # simulation settings sim: SimulationCfg = SimulationCfg() """Physics simulation configuration. Default is SimulationCfg().""" diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py index 5db57ff095b6..1a46e2dc41dc 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env_cfg.py @@ -16,6 +16,9 @@ class ManagerBasedRLEnvCfg(ManagerBasedEnvCfg): """Configuration for a reinforcement learning environment with the manager-based workflow.""" # ui settings + class_type: type | str = "{DIR}.manager_based_rl_env:ManagerBasedRLEnv" + """The environment class constructed from this configuration.""" + ui_window_class_type: type | str | None = "isaaclab.envs.ui.manager_based_rl_env_window:ManagerBasedRLEnvWindow" # general settings diff --git a/source/isaaclab/isaaclab/scene_data/__init__.py b/source/isaaclab/isaaclab/scene_data/__init__.py index 4e0ccf0dc08a..f23be08abc2d 100644 --- a/source/isaaclab/isaaclab/scene_data/__init__.py +++ b/source/isaaclab/isaaclab/scene_data/__init__.py @@ -14,7 +14,7 @@ This package is deliberately separate from :mod:`isaaclab.scene` so that physics backends (``isaaclab_physx``, ``isaaclab_newton``) can subclass :class:`SceneDataBackend` without pulling :mod:`isaaclab.scene` into the -``AppLauncher`` pre-launch import chain. +Kit launcher pre-launch import chain. """ from ..utils.module import lazy_export diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py index 2a14892296d1..2550e0b17677 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py @@ -8,8 +8,8 @@ These types live in :mod:`isaaclab.scene_data` rather than :mod:`isaaclab.scene` so that physics backends (``isaaclab_physx``, ``isaaclab_newton``) can subclass :class:`SceneDataBackend` without pulling -:mod:`isaaclab.scene` into the ``AppLauncher`` pre-launch import chain. -``AppLauncher._create_app`` pops ``*lab*`` modules from ``sys.modules`` +:mod:`isaaclab.scene` into the Kit launcher pre-launch import chain. +``KitLauncher._create_app`` pops ``*lab*`` modules from ``sys.modules`` during Kit init and any submodule imported during that window ends up orphaned from its parent's ``__dict__`` after restoration. """ diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index c1b611af6ef6..98f889a57973 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -202,32 +202,6 @@ def transform_count(self) -> int: """Number of transforms available from the sim backend.""" return self.backend.transform_count - @property - def usd_stage(self) -> Usd.Stage | None: - """Pixar :class:`Usd.Stage` for visualizers and renderers that walk USD. - - Resolves to :attr:`isaaclab.sim.SimulationContext.stage`, falling back to - ``omni.usd.get_context().get_stage()`` when the simulation context has no - cached stage. Returns ``None`` on Newton-only headless runs without a USD - stage. - """ - from isaaclab.sim import SimulationContext - - sim = SimulationContext.instance() - stage = getattr(sim, "stage", None) if sim is not None else None - if stage is not None: - return stage - try: - import omni.usd - - return omni.usd.get_context().get_stage() - except Exception: - return None - - def get_usd_stage(self) -> Usd.Stage | None: - """Return the USD stage for callers using the older method-style API.""" - return self.usd_stage - @property def num_envs(self) -> int: """Number of environments discovered from ``/World/envs/env_`` prims. @@ -237,7 +211,7 @@ def num_envs(self) -> int: """ if self._num_envs_cache is not None: return self._num_envs_cache - self._num_envs_cache = _discover_num_envs(self.usd_stage) + self._num_envs_cache = _discover_num_envs(sim_utils.SimulationContext.instance().stage) return self._num_envs_cache def get_camera_transforms(self) -> dict[str, Any] | None: @@ -249,7 +223,7 @@ def get_camera_transforms(self) -> dict[str, Any] | None: lists, with ``None`` for absent envs), and ``num_envs``. Returns ``None`` when no USD stage is available. """ - return _walk_camera_prims(self.usd_stage) + return _walk_camera_prims(sim_utils.SimulationContext.instance().stage) def init_output( self, diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index ccca5d87bae5..0a22d87898f6 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -209,11 +209,8 @@ __all__ = [ "count_total_labels", "resolve_paths", "create_new_stage", - "is_current_stage_in_memory", "open_stage", - "show_stage_in_viewport", "use_stage", - "update_stage", "save_stage", "close_stage", "clear_stage", @@ -442,7 +439,6 @@ from .utils import ( get_usd_references, has_deformable_body_api, has_deformable_curve_api, - is_current_stage_in_memory, is_prim_path_valid, make_uninstanceable, matches_path_expr_prefix, @@ -459,10 +455,8 @@ from .utils import ( save_stage, select_usd_variants, set_prim_visibility, - show_stage_in_viewport, standardize_xform_ops, traverse_stage, - update_stage, use_stage, validate_standard_xform_ops, ) diff --git a/source/isaaclab/isaaclab/sim/simulation_cfg.py b/source/isaaclab/isaaclab/sim/simulation_cfg.py index a1da0f12a5c5..1812e14c8c5f 100644 --- a/source/isaaclab/isaaclab/sim/simulation_cfg.py +++ b/source/isaaclab/isaaclab/sim/simulation_cfg.py @@ -46,7 +46,7 @@ class SimulationCfg: Valid options are: - ``"cpu"``: Use CPU. - - ``"cuda"``: Use GPU, where the device ID is inferred from :class:`~isaaclab.app.AppLauncher`'s config. + - ``"cuda"``: Use GPU, where the device ID is inferred from :class:`~isaaclab_physx.app.KitLauncher`'s config. - ``"cuda:N"``: Use GPU, where N is the device ID. For example, "cuda:0". """ diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index ff13c270653f..582ed776ba8a 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -182,14 +182,11 @@ def __init__(self, cfg: SimulationCfg | None = None): # Set as current stage in thread-local context for get_current_stage() stage_utils._context.stage = self.stage - # When Kit is running, attach the stage to Kit's USD context so that - # Kit extensions (PhysX views, Articulation, viewport) can discover it. if use_isaac_sim: - import omni.usd + from isaaclab_physx.app.kit_stage import KitStageBackendCfg # noqa: PLC0415 - kit_context = omni.usd.get_context() - if kit_context is not None and kit_context.get_stage() is not self.stage: - kit_context.attach_stage_with_callback(stage_cache.GetId(self.stage).ToLongInt()) + # Kit extensions (PhysX views, articulations, the viewport) find the stage through Kit's USD context + self.get_or_create_backend(KitStageBackendCfg(stage=self.stage)) # Acquire settings interface (SettingsManager: standalone dict or Omniverse when available) self.settings = SettingsManager.instance() @@ -500,8 +497,9 @@ def _get_cli_visualizer_types(self) -> list[str]: def _apply_visualizer_cli_overrides(self, visualizer_cfgs: list[Any]) -> None: """Apply ``--max_visible_envs`` to every resolved visualizer cfg when set in settings. - AppLauncher stores ``/isaaclab/visualizer/max_visible_envs`` as ``-1`` when the flag was - omitted; any non-negative int overrides :attr:`VisualizerCfg.max_visible_envs` on each cfg. + :func:`~isaaclab.app.launch_simulation` stores ``/isaaclab/visualizer/max_visible_envs`` as ``-1`` + when the flag was omitted; any non-negative int overrides :attr:`VisualizerCfg.max_visible_envs` + on each cfg. """ raw = self.get_setting("/isaaclab/visualizer/max_visible_envs") try: @@ -1027,7 +1025,9 @@ def run_cleanup(callback: Callable[[], Any]) -> None: instance._pending_visualizers.clear() instance.clone_contexts.clear() - for cfg, resource in instance._backend_registry: + # Newest first: the Kit USD-context backend, registered first, closes last but + # before close_stage() clears the stage cache. + for cfg, resource in reversed(instance._backend_registry): if isinstance(cfg, BackendCfg) and not isinstance(cfg, RendererCfg): run_cleanup(lambda resource=resource: resource.close()) instance._backend_registry.clear() diff --git a/source/isaaclab/isaaclab/sim/utils/__init__.pyi b/source/isaaclab/isaaclab/sim/utils/__init__.pyi index b37b8b6e035f..f651d961a787 100644 --- a/source/isaaclab/isaaclab/sim/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/utils/__init__.pyi @@ -49,11 +49,8 @@ __all__ = [ "count_total_labels", "resolve_paths", "create_new_stage", - "is_current_stage_in_memory", "open_stage", - "show_stage_in_viewport", "use_stage", - "update_stage", "save_stage", "close_stage", "clear_stage", @@ -125,11 +122,8 @@ from .semantics import ( from .stage import ( resolve_paths, create_new_stage, - is_current_stage_in_memory, open_stage, - show_stage_in_viewport, use_stage, - update_stage, save_stage, close_stage, clear_stage, diff --git a/source/isaaclab/isaaclab/sim/utils/stage.py b/source/isaaclab/isaaclab/sim/utils/stage.py index b0a0f3df8394..163ed107a323 100644 --- a/source/isaaclab/isaaclab/sim/utils/stage.py +++ b/source/isaaclab/isaaclab/sim/utils/stage.py @@ -22,10 +22,6 @@ logger = logging.getLogger(__name__) _context = threading.local() # thread-local storage to handle nested contexts and concurrent access -# Kit-dependent imports (only available when running with Kit/Isaac Sim) -if has_kit(): - import omni.kit.app - def _check_ancestral(prim: Usd.Prim) -> bool: """Check if a prim is brought into composition by its ancestor (an ancestral prim). @@ -140,29 +136,6 @@ def _modify_path(asset_path: str) -> str: # ############################################################################## -_isaacsim_stage_context_synced = False - - -def _sync_isaacsim_stage_context() -> None: - """Point Isaac Sim's stage helper at Isaac Lab's thread-local stage context.""" - global _isaacsim_stage_context_synced - - if _isaacsim_stage_context_synced or not has_kit(): - return - - try: - # Do not enable ``isaacsim.core.experimental.utils`` here. Stage creation is used by - # Newton tests before Newton imports Warp, and enabling Isaac Sim experimental utils can - # make Kit's importer expose the bundled ``omni.warp.core`` package ahead of pip Warp. - from isaacsim.core.experimental.utils import stage as sim_stage # noqa: PLC0415 - except ImportError: - return - - # Isaac Sim stage helpers read this singleton context. - sim_stage._context = _context # type: ignore - _isaacsim_stage_context_synced = True - - def create_new_stage() -> Usd.Stage: """Create a new in-memory USD stage. @@ -183,8 +156,6 @@ def create_new_stage() -> Usd.Stage: sessionLayer=Sdf.Find('anon:0x7fba6c01c5c0:World7-session.usda'), pathResolverContext=) """ - _sync_isaacsim_stage_context() - from pxr import Usd, UsdUtils # noqa: PLC0415 stage: Usd.Stage = Usd.Stage.CreateInMemory() @@ -193,40 +164,6 @@ def create_new_stage() -> Usd.Stage: return stage -def is_current_stage_in_memory() -> bool: - """Checks if the current stage is NOT attached to the USD context. - - This function compares the current stage (from :func:`get_current_stage`) with - the stage attached to Kit's ``omni.usd`` context. If they are different, - the current stage is considered "in memory" - meaning it's not the stage - that the viewport/UI displays. - - This is useful for determining if we're working with a separate in-memory - stage created via :func:`create_new_stage_in_memory` with - ``SimulationCfg(create_stage_in_memory=True)``. - - In kitless mode (no USD context), this always returns True. - - Returns: - True if the current stage is different from (not attached to) the context stage. - Also returns True if there is no context stage at all. - """ - if not has_kit(): - return True - - import omni.usd - - context = omni.usd.get_context() - if context is None: - return True - - context_stage = context.get_stage() - if context_stage is None: - return True - - return get_current_stage() is not context_stage - - def open_stage(usd_path: str) -> Usd.Stage: """Open the given USD file. @@ -244,8 +181,6 @@ def open_stage(usd_path: str) -> Usd.Stage: ValueError: When input path is not a supported file type by USD. RuntimeError: When failed to open the stage. """ - _sync_isaacsim_stage_context() - from pxr import Usd # noqa: PLC0415 if not Usd.Stage.IsSupportedFile(usd_path): @@ -314,58 +249,6 @@ def use_stage(stage: Usd.Stage) -> Generator[None, None, None]: _context.stage = previous_stage -def update_stage() -> None: - """Triggers a full application update cycle to process USD stage changes. - - This function calls ``omni.kit.app.get_app_interface().update()`` which triggers - a complete application update including: - - * Physics simulation step (if ``/app/player/playSimulations`` is True) - * Rendering (RTX path tracing, viewport updates) - * UI updates (widgets, windows) - * Timeline events and callbacks - * Extension updates - * USD/Fabric synchronization - - When to Use: - * **After creating a new stage**: ``create_new_stage()`` → ``update_stage()`` - * **After spawning prims**: ``cfg.func("/World/Robot", cfg)`` → ``update_stage()`` - * **After USD authoring**: Creating materials, lights, meshes, etc. - * **Before simulation starts**: During setup phase, before ``sim.reset()`` - * **In test fixtures**: To ensure consistent state before each test - - When NOT to Use: - * **During active simulation** (after ``sim.play()``): Can interfere with - physics stepping and cause double-stepping or timing issues. - * **During sensor updates**: Can reset RTX renderer state mid-cycle, - causing incorrect sensor outputs (e.g., ``inf`` depth values). - * **Inside physics/render callbacks**: Can cause recursion or timing issues. - * **Inside ``sim.step()`` or ``sim.render()``**: These already perform - app updates internally with proper safeguards. - - For rendering during simulation without physics stepping, use:: - - sim.set_setting("/app/player/playSimulations", False) - omni.kit.app.get_app().update() - sim.set_setting("/app/player/playSimulations", True) - - Example: - >>> import isaaclab.sim as sim_utils - >>> - >>> # Setup phase - safe to use - >>> sim_utils.create_new_stage() - >>> robot_cfg.func("/World/Robot", robot_cfg) - >>> sim_utils.update_stage() # Commit USD changes - >>> - >>> # Simulation phase - DO NOT use update_stage() - >>> sim.reset() - >>> sim.play() - >>> for _ in range(100): - ... sim.step() # Handles updates internally - """ - omni.kit.app.get_app_interface().update() - - def save_stage(usd_path: str, save_and_reload_in_place: bool = True) -> bool: """Saves contents of the root layer of the current stage to the specified USD file. @@ -408,9 +291,8 @@ def save_stage(usd_path: str, save_and_reload_in_place: bool = True) -> bool: def close_stage() -> bool: """Closes the current USD stage. - If Kit is running, this first closes the stage via the Kit USD context - (``omni.usd.get_context().close_stage()``), then clears the stage cache. - Without Kit, only the stage cache is cleared. + Clears the stage cache. Backends that attached the stage (e.g. Kit's USD context) release it + when the simulation is cleared, before this runs. .. note:: @@ -428,14 +310,6 @@ def close_stage() -> bool: """ from pxr import UsdUtils # noqa: PLC0415 - # Close Kit's USD context first (while the stage is still in the cache), - # then clear the cache. Reversing this order causes Kit to fail with - # "Removal of UsdStage from cache failed" and can hang during teardown. - if has_kit(): - import omni.usd - - omni.usd.get_context().close_stage() - stage_cache = UsdUtils.StageCache.Get() stage_cache.Clear() _context.stage = None @@ -513,8 +387,6 @@ def _predicate_from_path(prim: Usd.Prim) -> bool: prim_paths_to_delete = [prim.GetPath().pathString for prim in prims] # delete prims delete_prim(prim_paths_to_delete) - if has_kit(): - omni.kit.app.get_app_interface().update() def get_current_stage(fabric: bool = False) -> Usd.Stage: @@ -534,8 +406,6 @@ def get_current_stage(fabric: bool = False) -> Usd.Stage: sessionLayer=Sdf.Find('anon:0x7fba6c01c5c0:World7-session.usda'), pathResolverContext=) """ - _sync_isaacsim_stage_context() - # First check thread-local context for an in-memory stage stage = getattr(_context, "stage", None) if stage is not None: @@ -578,30 +448,3 @@ def get_current_stage_id() -> int: raise RuntimeError("Stage has no root layer - cannot cache an incomplete stage.") stage_id = stage_cache.Insert(stage).ToLongInt() return stage_id - - -def show_stage_in_viewport(usd_path: str) -> None: - """Open a USD file in the running Kit viewport and block until the app is closed. - - Opens the stage through the Kit USD context so it appears in the viewport (or the - livestream client), then spins the Kit update loop until the window is closed or the - loop is interrupted. Must only be called inside a running Kit process; use - :func:`~isaaclab.utils.version.has_kit` or :meth:`~isaaclab.app.AppLauncher.has_gui` - to gate the call. - - Args: - usd_path: Path of the USD file to display. - """ - import omni.usd # noqa: PLC0415 - - # A failed open leaves the previously loaded stage in the viewport, which would look like a - # successful preview of the wrong asset, so surface the failure instead of blocking on it. - result = omni.usd.get_context().open_stage(usd_path) - opened = result[0] if isinstance(result, tuple) else result - if opened is False: - raise RuntimeError(f"Failed to open the USD stage in the Kit viewport: {usd_path}") - - app = omni.kit.app.get_app_interface() - with contextlib.suppress(KeyboardInterrupt): - while app.is_running(): - app.update() diff --git a/source/isaaclab/isaaclab/test/env_cfgs.py b/source/isaaclab/isaaclab/test/env_cfgs.py index 10ea92dfef3c..6a4af7489566 100644 --- a/source/isaaclab/isaaclab/test/env_cfgs.py +++ b/source/isaaclab/isaaclab/test/env_cfgs.py @@ -6,7 +6,7 @@ """Shared core-only environment configurations for Isaac Lab tests. The configuration classes and factories in this module are safe to import, construct, and -validate without starting :class:`isaaclab.app.AppLauncher`. Creating environments from the +validate without starting :class:`~isaaclab_physx.app.KitLauncher`. Creating environments from the resulting configurations still requires a running simulator. """ diff --git a/source/isaaclab/isaaclab/test/utils/__init__.py b/source/isaaclab/isaaclab/test/utils/__init__.py index c9631a080592..fc05bb7194bb 100644 --- a/source/isaaclab/isaaclab/test/utils/__init__.py +++ b/source/isaaclab/isaaclab/test/utils/__init__.py @@ -7,12 +7,14 @@ Exposes :class:`DeviceScope` and :func:`test_devices` for selecting the device list to parametrize tests over, plus :func:`resolve_test_sim_device` for deriving -a Kit-backed test's AppLauncher device from the same runtime mask. The selected +a Kit-backed test's device from the same runtime mask, and :func:`launch_test_simulation` +to start the simulation runtime for a test module. The selected set is ``scope ∩ runtime``: ``scope`` is the call-site argument (the devices the test is valid on), the runtime is the ``ISAACLAB_TEST_DEVICES`` env var (the devices the run may use). """ from .devices import DeviceScope, resolve_test_sim_device, test_devices +from .simulation import launch_test_simulation -__all__ = ["DeviceScope", "resolve_test_sim_device", "test_devices"] +__all__ = ["DeviceScope", "launch_test_simulation", "resolve_test_sim_device", "test_devices"] diff --git a/source/isaaclab/isaaclab/test/utils/devices.py b/source/isaaclab/isaaclab/test/utils/devices.py index 330964be6747..64fb1d3f36f0 100644 --- a/source/isaaclab/isaaclab/test/utils/devices.py +++ b/source/isaaclab/isaaclab/test/utils/devices.py @@ -9,7 +9,9 @@ from isaaclab.test.utils import DeviceScope, resolve_test_sim_device, test_devices - simulation_app = AppLauncher(headless=True, device=resolve_test_sim_device()).app +from isaaclab.test.utils import launch_test_simulation + + launch_test_simulation(device=resolve_test_sim_device()) @pytest.mark.parametrize("device", test_devices()) # cpu + cuda:0 + a non-default GPU @@ -25,7 +27,7 @@ def test_foo(device): ... * **runtime** — the ``ISAACLAB_TEST_DEVICES`` env var: the devices the *run* may use. The operator / CI owns this; defaults to ``"110"`` (cpu + cuda:0). The multi-GPU workflow sets it to one device per shard. Kit-backed tests use - :func:`resolve_test_sim_device` to derive their AppLauncher boot device from + :func:`resolve_test_sim_device` to derive their Kit boot device from the same mask. A test author never names the shard's GPU; the operator never inspects a @@ -119,10 +121,10 @@ def mask(self) -> str: def resolve_test_sim_device() -> str: - """Resolve the AppLauncher device from the test runtime device mask. + """Resolve the Kit launch device from the test runtime device mask. This function intentionally parses the mask without enumerating devices so - it can run before AppLauncher without importing torch or initializing Warp. + it can run before Kit launches without importing torch or initializing Warp. A Kit-backed test process can boot on only one GPU, so an explicitly set runtime must select at most one CUDA device. The CPU bit may accompany that GPU because the default single-GPU runtime covers both CPU and CUDA tests. @@ -142,7 +144,7 @@ def resolve_test_sim_device() -> str: raise ValueError(f"Invalid {_RUNTIME_DEVICES_ENV_VAR} mask: {runtime!r}") if runtime.endswith("X"): raise ValueError( - f"{_RUNTIME_DEVICES_ENV_VAR}={runtime!r} is ambiguous for AppLauncher; use a concrete runtime mask" + f"{_RUNTIME_DEVICES_ENV_VAR}={runtime!r} is ambiguous for a Kit launch; use a concrete runtime mask" ) cuda_positions = [position for position, included in enumerate(runtime[1:], start=1) if included == "1"] @@ -241,7 +243,7 @@ def _list_available_devices() -> list[str]: Returns: Ordered list of device strings as torch addresses them. """ - # Keep the import local so importing this test helper before AppLauncher + # Keep the import local so importing this test helper before Kit launches # does not import torch (and its transitive native libraries) before Kit. import torch diff --git a/source/isaaclab/isaaclab/test/utils/simulation.py b/source/isaaclab/isaaclab/test/utils/simulation.py new file mode 100644 index 000000000000..41695abcf2ad --- /dev/null +++ b/source/isaaclab/isaaclab/test/utils/simulation.py @@ -0,0 +1,37 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Start the simulation runtime for a test module.""" + +from __future__ import annotations + +import contextlib + +from .devices import resolve_test_sim_device + +_RUNTIME = contextlib.ExitStack() + + +def launch_test_simulation(**launcher_args): + """Start Isaac Sim / Kit for the rest of the test process through :func:`~isaaclab.app.launch_simulation`. + + Call it at module level, before importing modules that need Kit. The runtime stays up until the + process exits, when the Kit launcher closes it with the process's exit status. + + Args: + **launcher_args: Launcher arguments, for example ``device`` or ``enable_cameras``. ``device`` + defaults to :func:`~isaaclab.test.utils.resolve_test_sim_device`. + + Returns: + The running Kit application, for tests that pump it with ``update()``. + """ + from isaaclab.app import launch_simulation + + if "device" not in launcher_args: + launcher_args["device"] = resolve_test_sim_device() + _RUNTIME.enter_context(launch_simulation(None, {"require_kit": True, "headless": True, **launcher_args})) + import omni.kit.app + + return omni.kit.app.get_app() diff --git a/source/isaaclab/isaaclab/utils/configclass.py b/source/isaaclab/isaaclab/utils/configclass.py index 39ed3dcd5120..eb1c2f39805f 100644 --- a/source/isaaclab/isaaclab/utils/configclass.py +++ b/source/isaaclab/isaaclab/utils/configclass.py @@ -195,7 +195,8 @@ def _field_module_dir(obj: Any, key: str | None = None) -> str | None: for mro_cls in cls.__mro__: if mro_cls is object: continue - own_fields = getattr(mro_cls, "__configclass_own_fields__", None) + # read the class's own snapshot; an undecorated subclass must not inherit its parent's + own_fields = mro_cls.__dict__.get("__configclass_own_fields__") if own_fields is not None: if key in own_fields: cls = mro_cls diff --git a/source/isaaclab/isaaclab/utils/version.py b/source/isaaclab/isaaclab/utils/version.py index 0ca2a4431590..8f9f718b5c92 100644 --- a/source/isaaclab/isaaclab/utils/version.py +++ b/source/isaaclab/isaaclab/utils/version.py @@ -21,7 +21,7 @@ def has_kit() -> bool: Returns True when running inside an Omniverse Kit application (e.g. Isaac Sim). Returns False in kitless mode (e.g. Newton physics backend without Kit). - Not cached with ``lru_cache`` because this may be called before ``AppLauncher`` + Not cached with ``lru_cache`` because this may be called before the Kit launcher finishes starting Kit, which would permanently lock in a ``False`` result. The underlying ``get_app()`` call is cheap once the module is loaded. diff --git a/source/isaaclab/test/app/test_launch_simulation_require_kit.py b/source/isaaclab/test/app/test_launch_simulation_require_kit.py index 5d5cb2772eb3..fa88ee8b9f12 100644 --- a/source/isaaclab/test/app/test_launch_simulation_require_kit.py +++ b/source/isaaclab/test/app/test_launch_simulation_require_kit.py @@ -10,31 +10,34 @@ standalone importer wheel is absent. The override is additive: it can turn a kitless launch into a Kit one, never the reverse. -Kit is never actually started here: availability and ``AppLauncher`` are faked, and reaching -``_ensure_isaac_sim_available`` is the signal that the Kit branch was taken. No Kit/GPU required. +Kit is never actually started here: the Kit launcher named by the config is faked, and constructing it +is the signal that the Kit branch was taken. No Kit/GPU required. """ import argparse import sys import types +import isaaclab_physx.app as physx_app import pytest -import isaaclab.app as isaaclab_app import isaaclab.app.sim_launcher as sim_launcher -import isaaclab.utils as isaaclab_utils import isaaclab.utils.assets as assets_utils -from isaaclab.app import launch_simulation +from isaaclab.app import SimulationLauncher, launch_simulation from isaaclab.physics import PhysicsCfg +from isaaclab.renderers import RendererCfg @pytest.fixture def kit_branch_taken(monkeypatch: pytest.MonkeyPatch): """Report whether ``launch_simulation`` entered its Kit branch, without starting Kit.""" taken: list[bool] = [] - # pretend Kit is already running so the launcher skips constructing AppLauncher - monkeypatch.setattr(isaaclab_utils, "has_kit", lambda: True) - monkeypatch.setattr(sim_launcher, "_ensure_isaac_sim_available", lambda: taken.append(True)) + + class FakeKitLauncher(SimulationLauncher): + def __init__(self, launcher_args): + taken.append(True) + + monkeypatch.setattr(physx_app, "KitLauncher", FakeKitLauncher) return taken @@ -61,27 +64,21 @@ def test_storage_profile_failure_closes_started_kit(monkeypatch: pytest.MonkeyPa """A profile failure after Kit starts must still close the application.""" close_calls = [] - class FakeApp: - def close(self, **kwargs): - close_calls.append(kwargs) - - class FakeAppLauncher: - def __init__(self, _launcher_args): - self.app = FakeApp() + class FakeKitLauncher(SimulationLauncher): + def close(self, exit_code=0): + close_calls.append(exit_code) def reject_profile(): raise RuntimeError("profile rejected") - monkeypatch.setattr(sim_launcher, "_ensure_isaac_sim_available", lambda: None) - monkeypatch.setattr(isaaclab_utils, "has_kit", lambda: False) - monkeypatch.setattr(isaaclab_app, "AppLauncher", FakeAppLauncher) + monkeypatch.setattr(physx_app, "KitLauncher", FakeKitLauncher) monkeypatch.setattr(assets_utils, "configure_storage_profile", reject_profile) with pytest.raises(RuntimeError, match="profile rejected"): with launch_simulation(cfg=PhysicsCfg(), launcher_args={"require_kit": True}): pass - assert close_calls == [{"exit_code": 1}] + assert close_calls == [1] def test_require_kit_launches_kit_for_a_kitless_config(kit_branch_taken): @@ -103,11 +100,10 @@ def test_require_kit_rejects_ovrtx_runtime(monkeypatch: pytest.MonkeyPatch): config_scan = sim_launcher.Scan( resolved_physics_cfg=None, effective_cfg=object(), - visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, + visualizer_intent={"has_kit_visualizer": False}, has_ovrtx=True, has_kit_camera=False, has_kit_physics=False, - has_kitless_physics=True, has_ovphysx_physics=False, needs_kit=False, ) @@ -148,6 +144,29 @@ def test_kitless_ovrtx_registers_before_user_code(monkeypatch: pytest.MonkeyPatc assert calls == ["register", "storage", "user"] +def test_config_named_launcher_starts_and_closes(monkeypatch: pytest.MonkeyPatch): + """A launcher named by a config's ``launcher_type`` starts with the simulation and closes after it.""" + events = [] + + class FakeLauncher(SimulationLauncher): + def __init__(self, launcher_args): + events.append("start") + + def close(self, exit_code=0): + events.append("close") + + class CustomRendererCfg(RendererCfg): + launcher_type = "fake_runtime:FakeLauncher" + + monkeypatch.setitem(sys.modules, "fake_runtime", types.SimpleNamespace(FakeLauncher=FakeLauncher)) + cfg = argparse.Namespace(physics=sim_launcher.NewtonCfg(), renderer=CustomRendererCfg()) + + with launch_simulation(cfg): + events.append("user") + + assert events == ["start", "user", "close"] + + def test_require_kit_false_does_not_suppress_a_kit_config(kit_branch_taken): # '--viz kit' requires Kit on its own; require_kit=False must not override that launcher_args = {"visualizer": ["kit"], "visualizer_explicit": True, "require_kit": False} diff --git a/source/isaaclab/test/app/test_simulation_app_lifecycle.py b/source/isaaclab/test/app/test_simulation_app_lifecycle.py deleted file mode 100644 index f85a2a6e4145..000000000000 --- a/source/isaaclab/test/app/test_simulation_app_lifecycle.py +++ /dev/null @@ -1,162 +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 - -"""Kitless unit tests for the AppLauncher process-lifecycle exit policy.""" - -import signal -import sys -import types - -from isaaclab.app.app_launcher import AppLauncher - - -def _make_lifecycle(close_fn): - app = types.SimpleNamespace(close=close_fn, config={"fast_shutdown": True}) - return AppLauncher._SimulationAppLifecycle(app) - - -def _capture_signal_actions(monkeypatch): - actions = [] - monkeypatch.setattr( - "isaaclab.app.app_launcher.signal.signal", - lambda signum, handler: actions.append(("set_handler", signum, handler)), - ) - monkeypatch.setattr( - "isaaclab.app.app_launcher.signal.raise_signal", - lambda signum: actions.append(("raise", signum)), - ) - return actions - - -def test_abort_signal_closes_once_with_killed_by_signal_status(monkeypatch): - """The first signal closes the app once with the conventional 128 + signum status. - - Passing the status through ``close()`` lets the app perform its full teardown and - exit truthfully instead of the exit code 0 that Kit fast shutdown reports. - """ - codes = [] - lifecycle = _make_lifecycle(lambda exit_code=0: codes.append(exit_code)) - actions = _capture_signal_actions(monkeypatch) - - lifecycle._on_abort_signal(signal.SIGTERM, None) - - # the app must be closed exactly once, with the killed-by-signal status - assert codes == [128 + signal.SIGTERM] - # if close() returns (fast shutdown disabled), the default action must re-raise - assert ("set_handler", signal.SIGTERM, signal.SIG_DFL) in actions - assert ("raise", signal.SIGTERM) in actions - - -def test_abort_signal_reentrant_signal_skips_nested_close(monkeypatch): - """A signal delivered while ``close()`` is running must not re-enter ``close()``. - - Regression test for the infinite recursion where a SIGTERM received during - ``SimulationApp.close()`` re-entered the abort handler, recursing until the - stack overflowed and the process had to be SIGKILL-ed. - """ - close_calls = [] - - def _close(exit_code=0): - close_calls.append(len(close_calls) + 1) - # Simulate further signals delivered while the app is still closing. - if len(close_calls) < 5: - lifecycle._on_abort_signal(signal.SIGTERM, None) - - lifecycle = _make_lifecycle(_close) - actions = _capture_signal_actions(monkeypatch) - - lifecycle._on_abort_signal(signal.SIGTERM, None) - - # the app must be closed exactly once despite the nested signal - assert close_calls == [1] - # both the nested and the outer path fall back to the default action and re-raise - assert actions.count(("set_handler", signal.SIGTERM, signal.SIG_DFL)) >= 1 - assert actions.count(("raise", signal.SIGTERM)) >= 1 - - -def test_abort_signal_falls_back_when_exit_code_unsupported(capfd, monkeypatch): - """A ``close()`` without the ``exit_code`` parameter still closes the app, loudly. - - Guards against silent drift: if a SimulationApp update drops the parameter, the - app must still be closed and the exit-status limitation must be reported. - """ - calls = [] - - def _close(): # simulates a SimulationApp.close() without the exit_code parameter - calls.append(len(calls) + 1) - - lifecycle = _make_lifecycle(_close) - actions = _capture_signal_actions(monkeypatch) - - lifecycle._on_abort_signal(signal.SIGTERM, None) - - assert calls == [1] - assert "does not accept exit_code" in capfd.readouterr().err - # the re-raise still guarantees a truthful killed-by-signal status - assert ("raise", signal.SIGTERM) in actions - - -def test_atexit_close_arms_reentrancy_guard(monkeypatch): - """A signal arriving during the atexit close must take the re-entrant path. - - The atexit callback arms the same guard flag as the signal handler, so a SIGTERM - delivered mid-close falls back to the default signal action instead of starting a - second, nested teardown. - """ - close_calls = [] - - def _close(exit_code=0): - close_calls.append(len(close_calls) + 1) - # Simulate a signal delivered while the atexit close is running. - lifecycle._on_abort_signal(signal.SIGTERM, None) - - lifecycle = _make_lifecycle(_close) - actions = _capture_signal_actions(monkeypatch) - - lifecycle._close_at_exit() - - # the guard must be armed before close, so the mid-close signal must not re-enter close() - assert close_calls == [1] - assert ("set_handler", signal.SIGTERM, signal.SIG_DFL) in actions - assert ("raise", signal.SIGTERM) in actions - - -def test_atexit_close_preserves_pending_failure_status(monkeypatch): - """The atexit close passes a nonzero exit code when an unhandled exception is pending. - - Kit fast shutdown would otherwise replace the interpreter's pending failure - status with a successful exit code 0. - """ - codes = [] - lifecycle = _make_lifecycle(lambda exit_code=0: codes.append(exit_code)) - - # normal interpreter exit: no pending exception, close with exit code 0 - monkeypatch.delattr(sys, "last_exc", raising=False) - lifecycle._close_at_exit() - # unhandled exception pending: close with a failure exit code - lifecycle._closing = False - monkeypatch.setattr(sys, "last_exc", RuntimeError("pending failure"), raising=False) - lifecycle._close_at_exit() - - assert codes == [0, 1] - - -def test_atexit_close_falls_back_when_exit_code_unsupported(capfd): - """A ``close()`` that stops accepting ``exit_code`` still closes the app, with a warning. - - Guards the exit-code path against silent drift: if a SimulationApp update drops the - parameter, the app must still be closed and the exit-status limitation must be - reported instead of failing silently. - """ - calls = [] - - def _close(): # simulates a SimulationApp.close() without the exit_code parameter - calls.append(len(calls) + 1) - - lifecycle = _make_lifecycle(_close) - lifecycle._close_at_exit() - - assert calls == [1] - assert "does not accept exit_code" in capfd.readouterr().err diff --git a/source/isaaclab/test/sensors/test_camera.py b/source/isaaclab/test/sensors/test_camera.py index 1a4a9677fd45..e45782cf0d7c 100644 --- a/source/isaaclab/test/sensors/test_camera.py +++ b/source/isaaclab/test/sensors/test_camera.py @@ -81,8 +81,6 @@ def setup() -> tuple[sim_utils.SimulationContext, CameraCfg, float]: sim = sim_utils.SimulationContext(sim_cfg) # populate scene _populate_scene() - # load stage - sim_utils.update_stage() return sim, camera_cfg, dt @@ -515,7 +513,6 @@ def setup_with_device(device) -> tuple[sim_utils.SimulationContext, CameraCfg, f sim_cfg = sim_utils.SimulationCfg(dt=dt, device=device) sim = sim_utils.SimulationContext(sim_cfg) _populate_scene() - sim_utils.update_stage() return sim, camera_cfg, dt diff --git a/source/isaaclab/test/sensors/test_first_frame_textured_rendering.py b/source/isaaclab/test/sensors/test_first_frame_textured_rendering.py index 8a961ac5d788..0dc96376934e 100644 --- a/source/isaaclab/test/sensors/test_first_frame_textured_rendering.py +++ b/source/isaaclab/test/sensors/test_first_frame_textured_rendering.py @@ -73,8 +73,6 @@ def setup_sim(device): sim = sim_utils.SimulationContext(sim_cfg) # populate scene _populate_scene() - # load stage - sim_utils.update_stage() yield sim, dt # Teardown rep.vp_manager.destroy_hydra_textures("Replicator") diff --git a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py index 88fd3f39d56f..49433ff15bdd 100644 --- a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py @@ -75,8 +75,6 @@ def setup_simulation(): # Ground-plane mesh = make_plane(size=(100, 100), height=0.0, center_zero=True) create_prim_from_mesh("/World/defaultGroundPlane", mesh) - # load stage - sim_utils.update_stage() camera_cfg = MultiMeshRayCasterCameraCfg( prim_path="/World/Camera", @@ -353,7 +351,6 @@ def _create_heterogeneous_clone_scene(sim: sim_utils.SimulationContext, num_envs positions=env_origins, ) sim.set_clone_plan(plan) - sim_utils.update_stage() return torch.as_tensor(env_origins, device=sim.device) diff --git a/source/isaaclab/test/sensors/test_multi_tiled_camera.py b/source/isaaclab/test/sensors/test_multi_tiled_camera.py index f538260478e1..2f579f176d60 100644 --- a/source/isaaclab/test/sensors/test_multi_tiled_camera.py +++ b/source/isaaclab/test/sensors/test_multi_tiled_camera.py @@ -58,8 +58,6 @@ def setup_camera(): sim = sim_utils.SimulationContext(sim_cfg) # populate scene _populate_scene() - # load stage - sim_utils.update_stage() yield camera_cfg, sim, dt # Teardown rep.vp_manager.destroy_hydra_textures("Replicator") diff --git a/source/isaaclab/test/sensors/test_ray_caster_camera.py b/source/isaaclab/test/sensors/test_ray_caster_camera.py index 3887c407d1e7..4039077025d5 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_ray_caster_camera.py @@ -91,8 +91,6 @@ def setup() -> tuple[sim_utils.SimulationContext, RayCasterCameraCfg, float]: # Add lighting for RTX rendering light_cfg = sim_utils.DomeLightCfg(intensity=2000.0) light_cfg.func("/World/Light", light_cfg) - # load stage - sim_utils.update_stage() return sim, camera_cfg, dt diff --git a/source/isaaclab/test/sensors/test_ray_caster_integration.py b/source/isaaclab/test/sensors/test_ray_caster_integration.py index 8f9fa79092c8..47a0d7664d99 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_integration.py +++ b/source/isaaclab/test/sensors/test_ray_caster_integration.py @@ -63,7 +63,6 @@ def _make_sim_and_ground(): sim = sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=_DT)) mesh = make_plane(size=(100, 100), height=0.0, center_zero=True) create_prim_from_mesh(_GROUND_PATH, mesh) - sim_utils.update_stage() return sim @@ -122,7 +121,6 @@ def test_rigid_body_view_path(sim_ground): cube_geom = cast(Any, UsdGeom.Cube.Define(stage, cube_path)) cube_geom.CreateSizeAttr().Set(0.1) UsdPhysics.CollisionAPI.Apply(stage.GetPrimAtPath(cube_path)) - sim_utils.update_stage() sensor = RayCaster(_single_downward_ray_cfg(prim_path)) sim.reset() @@ -317,7 +315,6 @@ def _create_object_body(path: str) -> None: asset_cfgs=(AssetBaseCfg(prim_path="/World/envs/env_[^/]+/Object"),) * 2, ) sim.set_clone_plan(plan) - sim_utils.update_stage() cfg = MultiMeshRayCasterCfg( prim_path="{ENV_REGEX_NS}/Sensor", @@ -450,7 +447,6 @@ def test_update_mesh_transforms_non_identity_offset(sim_ground): cube_geom = cast(Any, UsdGeom.Cube.Define(stage, col_path)) cube_geom.CreateSizeAttr().Set(0.1) UsdPhysics.CollisionAPI.Apply(stage.GetPrimAtPath(col_path)) - sim_utils.update_stage() # Create a sensor prim to mount the MultiMeshRayCaster on sensor_path = "/World/SensorMount" diff --git a/source/isaaclab/test/sensors/test_ray_caster_sensor.py b/source/isaaclab/test/sensors/test_ray_caster_sensor.py index c28579437767..40e9a1f346ab 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_sensor.py +++ b/source/isaaclab/test/sensors/test_ray_caster_sensor.py @@ -40,7 +40,6 @@ def _make_sim_and_ground(): sim = sim_utils.SimulationContext(sim_cfg) mesh = make_plane(size=(100, 100), height=0.0, center_zero=True) create_prim_from_mesh(_GROUND_PATH, mesh) - sim_utils.update_stage() return sim @@ -304,7 +303,6 @@ def test_ray_caster_tracks_physics_body_parent_motion(sim_ground): raise RuntimeError(f"Failed to create collision cube at {cube_path}.") cube.CreateSizeAttr().Set(0.1) UsdPhysics.CollisionAPI.Apply(stage.GetPrimAtPath(cube_path)) - sim_utils.update_stage() sensor = RayCaster(_ray_caster_cfg(parent_path, "world")) sim.reset() diff --git a/source/isaaclab/test/sensors/test_sensor_base.py b/source/isaaclab/test/sensors/test_sensor_base.py index ce0fc65ee0ea..49d8608d3ca8 100644 --- a/source/isaaclab/test/sensors/test_sensor_base.py +++ b/source/isaaclab/test/sensors/test_sensor_base.py @@ -125,8 +125,6 @@ def create_dummy_sensor(request, device): sensor_cfg = DummySensorCfg() - sim_utils.update_stage() - yield sensor_cfg, sim, dt # stop simulation and clean up @@ -319,7 +317,6 @@ def test_rigid_body_ancestor_expr_trims_only_terminal_suffix(create_dummy_sensor sim_utils.create_prim(parent_path, "Xform") sim_utils.create_prim(child_path, "Xform") UsdPhysics.RigidBodyAPI.Apply(sim_utils.get_current_stage().GetPrimAtPath(parent_path)) - sim_utils.update_stage() sensor_cfg.prim_path = "{ENV_REGEX_NS}/Robot/link/link" sensor = DummySensor(cfg=sensor_cfg) diff --git a/source/isaaclab/test/sensors/test_tiled_camera.py b/source/isaaclab/test/sensors/test_tiled_camera.py index 573345314a49..a24cb0dfe52b 100644 --- a/source/isaaclab/test/sensors/test_tiled_camera.py +++ b/source/isaaclab/test/sensors/test_tiled_camera.py @@ -63,8 +63,6 @@ def setup_camera(device) -> tuple[sim_utils.SimulationContext, CameraCfg, float] sim: sim_utils.SimulationContext = sim_utils.SimulationContext(sim_cfg) # populate scene _populate_scene() - # load stage - sim_utils.update_stage() yield sim, camera_cfg, dt # Teardown rep.vp_manager.destroy_hydra_textures("Replicator") diff --git a/source/isaaclab/test/sim/test_physics_manager_lifecycle.py b/source/isaaclab/test/sim/test_physics_manager_lifecycle.py index 160e7f24a2be..c112e0ff393d 100644 --- a/source/isaaclab/test/sim/test_physics_manager_lifecycle.py +++ b/source/isaaclab/test/sim/test_physics_manager_lifecycle.py @@ -327,8 +327,9 @@ def close(self): "visualizer_failed", "visualizer_last", "visualizer_pending", - "backend_failed", + # native backends close newest first "backend_last", + "backend_failed", "stage", "cache", "gc", diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 76d3673acf29..598867f55ec6 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -59,10 +59,6 @@ def __init__(self, num_envs: int = 0): def num_envs(self) -> int: return self._num_envs - @property - def usd_stage(self): - return None - def get_camera_transforms(self): return None @@ -332,10 +328,6 @@ class _DummyViserSceneDataProvider: def num_envs(self) -> int: return 4 - @property - def usd_stage(self): - return None - def get_camera_transforms(self): return {} @@ -600,10 +592,6 @@ class _FakeStage: def GetPrimAtPath(self, path): raise AssertionError(f"default Kit visualizer should not look up camera prims: {path}") - class _FakeProvider: - def get_usd_stage(self): - return _FakeStage() - viewport_window = _FakeViewportWindow() viewport_utility = type( "ViewportUtility", @@ -628,7 +616,7 @@ def get_usd_stage(self): cfg = KitVisualizerCfg() visualizer = kit_visualizer.KitVisualizer(cfg) - visualizer._scene_data_provider = _FakeProvider() + monkeypatch.setattr(SimulationContext, "_instance", SimpleNamespace(stage=_FakeStage())) visualizer._runtime_headless = False visualizer._setup_viewport() diff --git a/source/isaaclab/test/sim/test_simulation_render_config.py b/source/isaaclab/test/sim/test_simulation_render_config.py index 2ca58807a5e5..b5c6b3d6a2d7 100644 --- a/source/isaaclab/test/sim/test_simulation_render_config.py +++ b/source/isaaclab/test/sim/test_simulation_render_config.py @@ -119,6 +119,8 @@ def test_isaac_rtx_global_settings(monkeypatch): utils = importlib.import_module("isaaclab_physx.renderers.isaac_rtx_renderer_utils") settings = _FakeSettings() monkeypatch.setattr(utils, "get_settings_manager", lambda: settings) + # the stubs replace Replicator, so there is no Kit extension to enable + monkeypatch.setattr(utils, "enable_extension", lambda _name: None) global_settings = IsaacRtxRendererGlobalSettingsCfg( enable_translucency=True, enable_reflections=True, diff --git a/source/isaaclab/test/sim/test_simulation_stage_in_memory.py b/source/isaaclab/test/sim/test_simulation_stage_in_memory.py index e11240c3d901..4cad9fffd78d 100644 --- a/source/isaaclab/test/sim/test_simulation_stage_in_memory.py +++ b/source/isaaclab/test/sim/test_simulation_stage_in_memory.py @@ -5,16 +5,10 @@ """Integration tests for simulation context with stage in memory.""" -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app # FIXME (mmittal): Stage in memory requires cameras to be enabled. -simulation_app = AppLauncher(headless=True, enable_cameras=True).app - -"""Rest everything follows.""" +from isaaclab.test.utils import launch_test_simulation +launch_test_simulation(enable_cameras=True) import numpy as np import pytest @@ -22,6 +16,7 @@ from isaaclab_physx.sim.schemas import PhysxArticulationCfg, PhysxRigidBodyCfg import omni.physx +import omni.usd import usdrt import isaaclab.sim as sim_utils @@ -38,7 +33,6 @@ def sim(): """Create a simulation context.""" cfg = SimulationCfg(create_stage_in_memory=True) sim = SimulationContext(cfg=cfg) - sim_utils.update_stage() yield sim omni.physx.get_physx_simulation_interface().detach_stage() sim.stop() @@ -113,7 +107,7 @@ def test_stage_in_memory_with_shapes(sim): assert len(prims) == num_shape_prototypes # verify stage is no longer in memory - assert not sim_utils.is_current_stage_in_memory() + assert omni.usd.get_context().get_stage() is sim_utils.get_current_stage() # verify prims now exist in context stage prims = sim_utils.find_matching_prim_paths(prim_path_regex) @@ -135,7 +129,7 @@ def test_stage_in_memory_with_usds(sim): ] # verify stage is attached to USD context (happens automatically now with create_stage_in_memory) - assert not sim_utils.is_current_stage_in_memory() + assert omni.usd.get_context().get_stage() is sim_utils.get_current_stage() # grab stage and set as current stage via the with statement stage_in_memory = sim.stage @@ -173,7 +167,7 @@ def test_stage_in_memory_with_usds(sim): assert len(prims) == num_robot_prototypes # verify stage is no longer in memory - assert not sim_utils.is_current_stage_in_memory() + assert omni.usd.get_context().get_stage() is sim_utils.get_current_stage() # verify prims now exist in context stage prims = sim_utils.find_matching_prim_paths(prim_path_regex) @@ -192,7 +186,7 @@ def test_stage_in_memory_with_clone_in_fabric(sim): num_clones = 100 # verify stage is attached to USD context (happens automatically now with create_stage_in_memory) - assert not sim_utils.is_current_stage_in_memory() + assert omni.usd.get_context().get_stage() is sim_utils.get_current_stage() # grab stage and set as current stage via the with statement stage_in_memory = sim.stage diff --git a/source/isaaclab/test/sim/test_spawn_from_files.py b/source/isaaclab/test/sim/test_spawn_from_files.py index a3fd18cd44ce..708cdf55e987 100644 --- a/source/isaaclab/test/sim/test_spawn_from_files.py +++ b/source/isaaclab/test/sim/test_spawn_from_files.py @@ -35,8 +35,6 @@ def sim(): dt = 0.1 # Load kit helper sim = SimulationContext(SimulationCfg(dt=dt)) - # Wait for spawning - sim_utils.update_stage() yield sim diff --git a/source/isaaclab/test/sim/test_spawn_lights.py b/source/isaaclab/test/sim/test_spawn_lights.py index ee85b208777c..412480126096 100644 --- a/source/isaaclab/test/sim/test_spawn_lights.py +++ b/source/isaaclab/test/sim/test_spawn_lights.py @@ -33,8 +33,6 @@ def sim(): dt = 0.1 # Load kit helper sim = SimulationContext(SimulationCfg(dt=dt)) - # Wait for spawning - sim_utils.update_stage() # Yield the simulation context for the test yield sim diff --git a/source/isaaclab/test/sim/test_spawn_materials.py b/source/isaaclab/test/sim/test_spawn_materials.py index 47e49e778026..edb0b87c6718 100644 --- a/source/isaaclab/test/sim/test_spawn_materials.py +++ b/source/isaaclab/test/sim/test_spawn_materials.py @@ -29,7 +29,6 @@ def sim(): sim_utils.create_new_stage() dt = 0.1 sim = SimulationContext(SimulationCfg(dt=dt)) - sim_utils.update_stage() yield sim sim.stop() sim.clear_instance() diff --git a/source/isaaclab/test/sim/test_spawn_meshes.py b/source/isaaclab/test/sim/test_spawn_meshes.py index 5b04c7d761fd..9649b4904809 100644 --- a/source/isaaclab/test/sim/test_spawn_meshes.py +++ b/source/isaaclab/test/sim/test_spawn_meshes.py @@ -33,8 +33,6 @@ def sim(): dt = 0.1 # Load kit helper sim = SimulationContext(SimulationCfg(dt=dt)) - # Wait for spawning - sim_utils.update_stage() yield sim # Cleanup sim._disable_app_control_on_stop_handle = True # prevent timeout diff --git a/source/isaaclab/test/sim/test_spawn_sensors.py b/source/isaaclab/test/sim/test_spawn_sensors.py index 6121dc71638c..5bd4922cd610 100644 --- a/source/isaaclab/test/sim/test_spawn_sensors.py +++ b/source/isaaclab/test/sim/test_spawn_sensors.py @@ -31,7 +31,6 @@ def sim(): sim_utils.create_new_stage() dt = 0.1 sim = SimulationContext(SimulationCfg(dt=dt)) - sim_utils.update_stage() yield sim sim.stop() sim.clear_instance() diff --git a/source/isaaclab/test/sim/test_spawn_shapes.py b/source/isaaclab/test/sim/test_spawn_shapes.py index cca010e11192..80560d2462f4 100644 --- a/source/isaaclab/test/sim/test_spawn_shapes.py +++ b/source/isaaclab/test/sim/test_spawn_shapes.py @@ -27,7 +27,6 @@ def sim(): sim_utils.create_new_stage() dt = 0.1 sim = SimulationContext(SimulationCfg(dt=dt)) - sim_utils.update_stage() yield sim sim._disable_app_control_on_stop_handle = True # prevent timeout sim.stop() diff --git a/source/isaaclab/test/sim/test_spawn_wrappers.py b/source/isaaclab/test/sim/test_spawn_wrappers.py index 0c880a271bf2..03ff81d0dbbb 100644 --- a/source/isaaclab/test/sim/test_spawn_wrappers.py +++ b/source/isaaclab/test/sim/test_spawn_wrappers.py @@ -32,7 +32,6 @@ def sim(): sim_utils.create_new_stage() dt = 0.1 sim = SimulationContext(SimulationCfg(dt=dt)) - sim_utils.update_stage() yield sim sim.stop() sim.clear_instance() diff --git a/source/isaaclab/test/sim/test_utils_prims.py b/source/isaaclab/test/sim/test_utils_prims.py index 5ef7bec22b2a..0e5ee171e5e5 100644 --- a/source/isaaclab/test/sim/test_utils_prims.py +++ b/source/isaaclab/test/sim/test_utils_prims.py @@ -33,7 +33,6 @@ def test_setup_teardown(): """Create a blank new stage for each test.""" # Setup: Create a new stage sim_utils.create_new_stage() - sim_utils.update_stage() # Yield for the test yield diff --git a/source/isaaclab/test/sim/test_utils_queries.py b/source/isaaclab/test/sim/test_utils_queries.py index 26945d9268eb..a214ec18e5f9 100644 --- a/source/isaaclab/test/sim/test_utils_queries.py +++ b/source/isaaclab/test/sim/test_utils_queries.py @@ -29,7 +29,6 @@ def test_setup_teardown(): """Create a blank new stage for each test.""" # Setup: Create a new stage sim_utils.create_new_stage() - sim_utils.update_stage() # Yield for the test yield diff --git a/source/isaaclab/test/sim/test_utils_semantics.py b/source/isaaclab/test/sim/test_utils_semantics.py index ac6b71f953cd..dd2da1723857 100644 --- a/source/isaaclab/test/sim/test_utils_semantics.py +++ b/source/isaaclab/test/sim/test_utils_semantics.py @@ -25,7 +25,6 @@ def test_setup_teardown(): """Create a blank new stage for each test.""" # Setup: Create a new stage sim_utils.create_new_stage() - sim_utils.update_stage() # Yield for the test yield diff --git a/source/isaaclab/test/sim/test_utils_stage.py b/source/isaaclab/test/sim/test_utils_stage.py index 8852fbb78c72..a09ea29a08e3 100644 --- a/source/isaaclab/test/sim/test_utils_stage.py +++ b/source/isaaclab/test/sim/test_utils_stage.py @@ -5,14 +5,9 @@ """Tests for stage utilities.""" -"""Launch Isaac Sim Simulator first.""" +from isaaclab.test.utils import launch_test_simulation -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" +launch_test_simulation() import tempfile from pathlib import Path @@ -43,25 +38,6 @@ def test_create_new_stage(): assert root_prim.IsValid() -def test_is_current_stage_in_memory(): - """Test checking if current stage is in memory.""" - # Create a stage - in kitless mode, this creates an in-memory stage - sim_utils.create_new_stage() - is_in_memory = sim_utils.is_current_stage_in_memory() - - # Should return a boolean - assert isinstance(is_in_memory, bool) - # With kitless mode support, create_new_stage() creates an in-memory stage - assert is_in_memory is True - - # Create a stage in memory explicitly - stage = sim_utils.create_new_stage() - with sim_utils.use_stage(stage): - is_in_memory = sim_utils.is_current_stage_in_memory() - assert isinstance(is_in_memory, bool) - assert is_in_memory is True - - def test_save_and_open_stage(): """Test saving and opening a stage.""" with tempfile.TemporaryDirectory() as temp_dir: diff --git a/source/isaaclab/test/sim/test_utils_transforms.py b/source/isaaclab/test/sim/test_utils_transforms.py index 946b07c40552..25517e6047f0 100644 --- a/source/isaaclab/test/sim/test_utils_transforms.py +++ b/source/isaaclab/test/sim/test_utils_transforms.py @@ -31,7 +31,6 @@ def test_setup_teardown(): """Create a blank new stage for each test.""" # Setup: Create a new stage sim_utils.create_new_stage() - sim_utils.update_stage() # Yield for the test yield diff --git a/source/isaaclab/test/sim/test_views_xform_prim.py b/source/isaaclab/test/sim/test_views_xform_prim.py index e674e1550284..089a2e48bc6a 100644 --- a/source/isaaclab/test/sim/test_views_xform_prim.py +++ b/source/isaaclab/test/sim/test_views_xform_prim.py @@ -46,7 +46,6 @@ @pytest.fixture(autouse=True) def test_setup_teardown(): sim_utils.create_new_stage() - sim_utils.update_stage() yield sim_utils.clear_stage() sim_utils.SimulationContext.clear_instance() @@ -305,11 +304,12 @@ def test_compare_get_world_poses_with_isaacsim(): isaacsim_paths = [f"/World/Env_{i}/Object" for i in range(num_prims)] isaaclab_view = FrameView(pattern, device="cpu") + import omni.kit.app # noqa: PLC0415 import omni.usd # noqa: PLC0415 context = omni.usd.get_context() context.attach_stage_with_callback(sim_utils.get_current_stage_id()) - sim_utils.update_stage() + omni.kit.app.get_app().update() for kwargs in ({"reset_xform_properties": False}, {"reset_xform_op_properties": False}, {}): try: diff --git a/source/isaaclab/test/utils/test_configclass.py b/source/isaaclab/test/utils/test_configclass.py index d75cec89da50..7d53871de683 100644 --- a/source/isaaclab/test/utils/test_configclass.py +++ b/source/isaaclab/test/utils/test_configclass.py @@ -670,6 +670,15 @@ class _ChildCfg(_BaseCfg): # a field declared on the subclass resolves against the subclass module assert str(cfg.extra) == "other_pkg.child.child_mod:ChildSymbol" + # a subclass without @configclass, e.g. a scene cfg in a script, still resolves against the declarers + class _PlainCfg(_ChildCfg): + pass + + _PlainCfg.__module__ = "__main__" + plain_cfg = _PlainCfg() + assert str(plain_cfg.class_type) == "test_pkg.parent.base_mod:BaseSymbol" + assert str(plain_cfg.extra) == "other_pkg.child.child_mod:ChildSymbol" + def test_config_update_different_iterable_lengths(): """Iterables are whole replaced, even if their lengths are different.""" diff --git a/source/isaaclab_contrib/changelog.d/minimal-kit-exit-lifecycle.rst b/source/isaaclab_contrib/changelog.d/minimal-kit-exit-lifecycle.rst new file mode 100644 index 000000000000..b6eb5a918060 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/minimal-kit-exit-lifecycle.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed the RLinf Isaac Lab environment factory to start the simulation runtime through + :func:`~isaaclab.app.launch_simulation`, so kitless tasks no longer start Isaac Sim / Kit. diff --git a/source/isaaclab_contrib/isaaclab_contrib/rl/rlinf/extension.py b/source/isaaclab_contrib/isaaclab_contrib/rl/rlinf/extension.py index 7c65d8efc363..b098ccf32326 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/rl/rlinf/extension.py +++ b/source/isaaclab_contrib/isaaclab_contrib/rl/rlinf/extension.py @@ -422,10 +422,10 @@ def _create_generic_env_wrapper(task_id: str) -> type: """Create a generic wrapper class for an IsaacLab task. The wrapper class will load the task configuration at runtime - (after AppLauncher starts) and configure observation mapping accordingly. + (after the simulation runtime starts) and configure observation mapping accordingly. This follows the same pattern as i4h's rlinf_ext: all isaaclab-dependent - imports happen inside _make_env_function, after AppLauncher starts. + imports happen inside _make_env_function, after the simulation runtime starts. Args: task_id: The gymnasium task ID. @@ -472,7 +472,7 @@ def _make_env_function(self) -> collections.abc.Callable: """Create the environment factory function. This function runs in a child process (via ``SubProcIsaacLabEnv``). - All IsaacLab-dependent imports happen here, after ``AppLauncher`` starts. + All IsaacLab-dependent imports happen here, after the simulation runtime starts. Returns: A callable that creates and returns the IsaacLab environment and sim app. @@ -483,16 +483,19 @@ def make_env_isaaclab() -> tuple: Returns: A tuple of ``(env, sim_app)`` where ``env`` is the unwrapped - gymnasium environment and ``sim_app`` is the Isaac Sim application. + gymnasium environment and ``sim_app`` stops the simulation runtime on ``close()``. """ - from isaaclab.app import AppLauncher + import contextlib - sim_app = AppLauncher(headless=True, enable_cameras=True).app import gymnasium as gym + from isaaclab.app import launch_simulation + from isaaclab_tasks.utils import parse_env_cfg isaac_env_cfg = parse_env_cfg(self.isaaclab_env_id, num_envs=self.cfg.init_params.num_envs) + sim_app = contextlib.ExitStack() + sim_app.enter_context(launch_simulation(isaac_env_cfg, {"enable_cameras": True})) env = gym.make(self.isaaclab_env_id, cfg=isaac_env_cfg, render_mode="rgb_array").unwrapped diff --git a/source/isaaclab_contrib/test/sensors/test_visuotactile_sensor.py b/source/isaaclab_contrib/test/sensors/test_visuotactile_sensor.py index 15398d8e9949..b1bbc2cd3272 100644 --- a/source/isaaclab_contrib/test/sensors/test_visuotactile_sensor.py +++ b/source/isaaclab_contrib/test/sensors/test_visuotactile_sensor.py @@ -181,8 +181,6 @@ def setup(sensor_type: str = "cube"): # Get the requested sensor configuration using the factory function sensor_cfg = get_sensor_cfg_by_type(sensor_type) - # load stage - sim_utils.update_stage() return sim, sensor_cfg, dt, robot_cfg, cube_cfg, nut_cfg diff --git a/source/isaaclab_experimental/changelog.d/minimal-kit-exit-lifecycle.rst b/source/isaaclab_experimental/changelog.d/minimal-kit-exit-lifecycle.rst new file mode 100644 index 000000000000..4ca023ca160e --- /dev/null +++ b/source/isaaclab_experimental/changelog.d/minimal-kit-exit-lifecycle.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* Changed ``seed()`` of the Warp environments to no longer seed Replicator; the Replicator event terms seed it + with ``env.cfg.seed``. +* Changed ``render()`` with ``render_mode="rgb_array"`` in the Warp environments to warn and return ``None``, + matching the core environments. Use ``VideoRecorderCfg`` on ``env_cfg.video_recorders`` to capture frames. diff --git a/source/isaaclab_experimental/isaaclab_experimental/envs/direct_rl_env_warp.py b/source/isaaclab_experimental/isaaclab_experimental/envs/direct_rl_env_warp.py index 1cdfb5628a75..7fdd1ffe3562 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/envs/direct_rl_env_warp.py +++ b/source/isaaclab_experimental/isaaclab_experimental/envs/direct_rl_env_warp.py @@ -518,14 +518,6 @@ def seed(seed: int = -1) -> int: Returns: The seed used for random generator. """ - # set seed for replicator - try: - import omni.replicator.core as rep - - rep.set_global_seed(seed) - except ModuleNotFoundError: - pass - # set seed for torch and other libraries return configure_seed(seed) def render(self, recompute: bool = False) -> np.ndarray | None: @@ -534,15 +526,18 @@ def render(self, recompute: bool = False) -> np.ndarray | None: By convention, if mode is: - **human**: Render to the current display and return nothing. Usually for human consumption. - - **rgb_array**: Return an numpy.ndarray with shape (x, y, 3), representing RGB values for an - x-by-y pixel image, suitable for turning into a video. + + .. note:: + ``render_mode="rgb_array"`` is no longer supported. Use + :class:`~isaaclab.envs.utils.video_recorder_cfg.VideoRecorderCfg` on + ``env_cfg.video_recorders`` instead. Args: recompute: Whether to force a render even if the simulator has already rendered the scene. Defaults to False. Returns: - The rendered image as a numpy array if mode is "rgb_array". Otherwise, returns None. + None. Raises: RuntimeError: If mode is set to "rgb_data" and simulation render mode does not support it. @@ -555,40 +550,18 @@ def render(self, recompute: bool = False) -> np.ndarray | None: if not (hasattr(self.sim, "has_rtx_sensors") and self.sim.has_rtx_sensors()) and not recompute: self.sim.render() # decide the rendering mode + if self.render_mode == "rgb_array": + import warnings + + warnings.warn( + "render_mode='rgb_array' is deprecated and will be removed in a future release. " + "Use VideoRecorderCfg on env_cfg.video_recorders to capture frames instead.", + DeprecationWarning, + stacklevel=2, + ) + return None if self.render_mode == "human" or self.render_mode is None: return None - elif self.render_mode == "rgb_array": - # rendering requires a GUI or offscreen rendering (mirrors the stable env) - if not (self.sim.has_gui or self.sim.has_offscreen_render): - render_mode_name = "NO_GUI_OR_RENDERING" - raise RuntimeError( - f"Cannot render '{self.render_mode}' when the simulation render mode is" - f" '{render_mode_name}'. Please set the simulation render mode" - " to:'PARTIAL_RENDERING' or" - " 'FULL_RENDERING'." - ) - # create the annotator if it does not exist - if not hasattr(self, "_rgb_annotator"): - import omni.replicator.core as rep - - # create render product from the main Kit viewport camera - _cam_prim_path = "/OmniverseKit_Persp" - _resolution = (1280, 720) - self._render_product = rep.create.render_product(_cam_prim_path, _resolution) - self._render_resolution = _resolution - # create rgb annotator -- used to read data from the render product - self._rgb_annotator = rep.AnnotatorRegistry.get_annotator("rgb", device="cpu") - self._rgb_annotator.attach([self._render_product]) - # obtain the rgb data - rgb_data = self._rgb_annotator.get_data() - # convert to numpy array - rgb_data = np.frombuffer(rgb_data, dtype=np.uint8).reshape(*rgb_data.shape) - # return the rgb data - # note: initially the renerer is warming up and returns empty data - if rgb_data.size == 0: - return np.zeros((self._render_resolution[1], self._render_resolution[0], 3), dtype=np.uint8) - else: - return rgb_data[:, :, :3] else: raise NotImplementedError( f"Render mode '{self.render_mode}' is not supported. Please use: {self.metadata['render_modes']}." diff --git a/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_env_warp.py b/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_env_warp.py index d29dda362cec..3e94486af610 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_env_warp.py +++ b/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_env_warp.py @@ -596,14 +596,6 @@ def seed(seed: int = -1) -> int: Returns: The seed used for random generator. """ - # set seed for replicator - try: - import omni.replicator.core as rep - - rep.set_global_seed(seed) - except ModuleNotFoundError: - pass - # set seed for torch and other libraries return configure_seed(seed) def close(self): diff --git a/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_rl_env_warp.py b/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_rl_env_warp.py index 26b778aec0a0..b6b56abc09d5 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_rl_env_warp.py +++ b/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_rl_env_warp.py @@ -396,15 +396,18 @@ def render(self, recompute: bool = False) -> np.ndarray | None: By convention, if mode is: - **human**: Render to the current display and return nothing. Usually for human consumption. - - **rgb_array**: Return a numpy.ndarray with shape (x, y, 3), representing RGB values for an - x-by-y pixel image, suitable for turning into a video. + + .. note:: + ``render_mode="rgb_array"`` is no longer supported. Use + :class:`~isaaclab.envs.utils.video_recorder_cfg.VideoRecorderCfg` on + ``env_cfg.video_recorders`` instead. Args: recompute: Whether to force a render even if the simulator has already rendered the scene. Defaults to False. Returns: - The rendered image as a numpy array if mode is "rgb_array". Otherwise, returns None. + None. Raises: RuntimeError: If mode is set to "rgb_data" and simulation render mode does not support it. @@ -417,38 +420,18 @@ def render(self, recompute: bool = False) -> np.ndarray | None: if not self.has_rtx_sensors and not recompute: self.sim.render() # decide the rendering mode + if self.render_mode == "rgb_array": + import warnings + + warnings.warn( + "render_mode='rgb_array' is deprecated and will be removed in a future release. " + "Use VideoRecorderCfg on env_cfg.video_recorders to capture frames instead.", + DeprecationWarning, + stacklevel=2, + ) + return None if self.render_mode == "human" or self.render_mode is None: return None - elif self.render_mode == "rgb_array": - # rendering requires a GUI or offscreen rendering (mirrors the stable env) - if not (self.sim.has_gui or self.sim.has_offscreen_render): - raise RuntimeError( - f"Cannot render '{self.render_mode}' when the simulation render mode does not support" - " rendering. Please set the simulation render mode to 'PARTIAL_RENDERING' or" - " 'FULL_RENDERING'." - ) - # create the annotator if it does not exist - if not hasattr(self, "_rgb_annotator"): - import omni.replicator.core as rep - - # create render product from the main Kit viewport camera - _cam_prim_path = "/OmniverseKit_Persp" - _resolution = (1280, 720) - self._render_product = rep.create.render_product(_cam_prim_path, _resolution) - self._render_resolution = _resolution - # create rgb annotator -- used to read data from the render product - self._rgb_annotator = rep.AnnotatorRegistry.get_annotator("rgb", device="cpu") - self._rgb_annotator.attach([self._render_product]) - # obtain the rgb data - rgb_data = self._rgb_annotator.get_data() - # convert to numpy array - rgb_data = np.frombuffer(rgb_data, dtype=np.uint8).reshape(*rgb_data.shape) - # return the rgb data - # note: initially the renerer is warming up and returns empty data - if rgb_data.size == 0: - return np.zeros((self._render_resolution[1], self._render_resolution[0], 3), dtype=np.uint8) - else: - return rgb_data[:, :, :3] else: raise NotImplementedError( f"Render mode '{self.render_mode}' is not supported. Please use: {self.metadata['render_modes']}." diff --git a/source/isaaclab_mimic/changelog.d/minimal-kit-exit-lifecycle.rst b/source/isaaclab_mimic/changelog.d/minimal-kit-exit-lifecycle.rst new file mode 100644 index 000000000000..f547b3e57f16 --- /dev/null +++ b/source/isaaclab_mimic/changelog.d/minimal-kit-exit-lifecycle.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added ``class_type`` to the environment configs that use a custom environment class, naming that class. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py index 0df362689ec5..76e6d12a5cc5 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py @@ -21,6 +21,8 @@ class RmpFlowAgibotPlaceToy2BoxMimicEnvCfg(RmpFlowAgibotPlaceToy2BoxEnvCfg, Mimi Isaac Lab Mimic environment config class for Agibot Place Toy2Box env. """ + class_type: type | str = "{DIR}.pick_place_mimic_env:PickPlaceRelMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py index b645c4fcf2ab..8c28a7401237 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py @@ -18,6 +18,8 @@ class RmpFlowAgibotPlaceUprightMugMimicEnvCfg(RmpFlowAgibotPlaceUprightMugEnvCfg Isaac Lab Mimic environment config class for Agibot Place Upright Mug env. """ + class_type: type | str = "{DIR}.pick_place_mimic_env:PickPlaceRelMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py index 4c7192a6307f..93f50b09d684 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py @@ -15,6 +15,8 @@ class ExhaustPipeGR1T2MimicEnvCfg(ExhaustPipeGR1T2PinkIKEnvCfg, MimicEnvCfg): """Configuration for GR1T2 Exhaust Pipe Mimic environment.""" + class_type: type | str = "{DIR}.pickplace_gr1t2_mimic_env:PickPlaceGR1T2MimicEnv" + def __post_init__(self): # Calling post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py index 3e35749f9c33..254b35a4af25 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py @@ -15,6 +15,8 @@ class FrankaBinStackIKRelMimicEnvCfg(FrankaBinStackEnvCfg, MimicEnvCfg): Isaac Lab Mimic environment config class for Franka Cube Stack IK Rel env. """ + class_type: type | str = "{DIR}.franka_stack_ik_rel_mimic_env:FrankaCubeStackIKRelMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py index 537541ffae01..738b868b3279 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py @@ -15,6 +15,8 @@ class FrankaCubeStackIKAbsMimicEnvCfg(FrankaCubeStackEnvCfg, MimicEnvCfg): Isaac Lab Mimic environment config class for Franka Cube Stack IK Abs env. """ + class_type: type | str = "{DIR}.franka_stack_ik_abs_mimic_env:FrankaCubeStackIKAbsMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py index 2773e8139013..08c320e579f6 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py @@ -17,6 +17,8 @@ class FrankaCubeStackIKRelBlueprintMimicEnvCfg(FrankaCubeStackBlueprintEnvCfg, M Isaac Lab Mimic environment config class for Franka Cube Stack IK Rel env. """ + class_type: type | str = "{DIR}.franka_stack_ik_rel_mimic_env:FrankaCubeStackIKRelMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py index 6f84e90bf97a..7d06daa41e06 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py @@ -15,6 +15,8 @@ class FrankaCubeStackIKRelMimicEnvCfg(FrankaCubeStackEnvCfg, MimicEnvCfg): Isaac Lab Mimic environment config class for Franka Cube Stack IK Rel env. """ + class_type: type | str = "{DIR}.franka_stack_ik_rel_mimic_env:FrankaCubeStackIKRelMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py index 9b1cb235b6c6..b1babfa97674 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py @@ -17,6 +17,8 @@ class FrankaCubeStackIKRelSkillgenEnvCfg(FrankaCubeStackSkillgenEnvCfg, MimicEnv Isaac Lab Mimic environment config class for Franka Cube Stack IK Rel env. """ + class_type: type | str = "{DIR}.franka_stack_ik_rel_mimic_env:FrankaCubeStackIKRelMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py index f188a9ecffe9..f3bac8826771 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py @@ -17,6 +17,8 @@ class FrankaCubeStackIKRelVisuomotorCosmosMimicEnvCfg(FrankaCubeStackVisuomotorC Isaac Lab Mimic environment config class for Franka Cube Stack IK Rel Visuomotor Cosmos env. """ + class_type: type | str = "{DIR}.franka_stack_ik_rel_mimic_env:FrankaCubeStackIKRelMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py index 050128cf9a93..f46d24126bbe 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py @@ -17,6 +17,8 @@ class FrankaCubeStackIKRelVisuomotorMimicEnvCfg(FrankaCubeStackVisuomotorEnvCfg, Isaac Lab Mimic environment config class for Franka Cube Stack IK Rel Visuomotor env. """ + class_type: type | str = "{DIR}.franka_stack_ik_rel_mimic_env:FrankaCubeStackIKRelMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py index ef94781bc961..6241fdea1199 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py @@ -19,6 +19,8 @@ class RmpFlowGalbotLeftArmGripperCubeStackAbsMimicEnvCfg(RmpFlowGalbotLeftArmCub Isaac Lab Mimic environment config class for Galbot Gripper Cube Stack IK Rel env. """ + class_type: type | str = "{DIR}.galbot_stack_rmp_abs_mimic_env:RmpFlowGalbotCubeStackAbsMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() @@ -144,6 +146,8 @@ class RmpFlowGalbotRightArmSuctionCubeStackAbsMimicEnvCfg(RmpFlowGalbotRightArmC Isaac Lab Mimic environment config class for Galbot Suction Gripper Cube Stack RmpFlow Abs env. """ + class_type: type | str = "{DIR}.galbot_stack_rmp_abs_mimic_env:RmpFlowGalbotCubeStackAbsMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py index 8def41266475..01ed85cea19c 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py @@ -19,6 +19,8 @@ class RmpFlowGalbotLeftArmGripperCubeStackRelMimicEnvCfg(RmpFlowGalbotLeftArmCub Isaac Lab Mimic environment config class for Galbot Gripper Cube Stack IK Rel env. """ + class_type: type | str = "{DIR}.galbot_stack_rmp_rel_mimic_env:RmpFlowGalbotCubeStackRelMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() @@ -142,6 +144,8 @@ class RmpFlowGalbotRightArmSuctionCubeStackRelMimicEnvCfg(RmpFlowGalbotRightArmC Isaac Lab Mimic environment config class for Galbot Suction Gripper Cube Stack RmpFlow Rel env. """ + class_type: type | str = "{DIR}.galbot_stack_rmp_rel_mimic_env:RmpFlowGalbotCubeStackRelMimicEnv" + def __post_init__(self): # post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py index 71ad54c594ab..17bfc63f68be 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py @@ -15,6 +15,8 @@ class LocomanipulationG1MimicEnvCfg(LocomanipulationG1EnvCfg, MimicEnvCfg): """Configuration for G1 Locomanipulation Mimic environment.""" + class_type: type | str = "{DIR}.locomanipulation_g1_mimic_env:LocomanipulationG1MimicEnv" + def __post_init__(self): # Call parent post-init super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py index dc12d03f49e5..deec350e543e 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py @@ -13,6 +13,8 @@ class NutPourGR1T2MimicEnvCfg(NutPourGR1T2PinkIKEnvCfg, MimicEnvCfg): """Configuration for GR1T2 Nut Pouring Mimic environment.""" + class_type: type | str = "{DIR}.pickplace_gr1t2_mimic_env:PickPlaceGR1T2MimicEnv" + def __post_init__(self): # Calling post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py index b220625bdb46..d1daf6d240bf 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py @@ -13,6 +13,8 @@ class PickPlaceGR1T2MimicEnvCfg(PickPlaceGR1T2EnvCfg, MimicEnvCfg): """Configuration for GR1T2 Pick Place Mimic environment.""" + class_type: type | str = "{DIR}.pickplace_gr1t2_mimic_env:PickPlaceGR1T2MimicEnv" + def __post_init__(self): # Calling post init of parents super().__post_init__() diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py index bfae20234e67..ff326c66e41e 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py @@ -15,6 +15,8 @@ class PickPlaceGR1T2WaistEnabledMimicEnvCfg(PickPlaceGR1T2WaistEnabledEnvCfg, MimicEnvCfg): """Configuration for GR1T2 Pick Place Waist Enabled Mimic environment.""" + class_type: type | str = "{DIR}.pickplace_gr1t2_mimic_env:PickPlaceGR1T2MimicEnv" + def __post_init__(self): # Calling post init of parents super().__post_init__() diff --git a/source/isaaclab_newton/changelog.d/minimal-kit-exit-lifecycle.skip b/source/isaaclab_newton/changelog.d/minimal-kit-exit-lifecycle.skip new file mode 100644 index 000000000000..db1d7e02ab13 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/minimal-kit-exit-lifecycle.skip @@ -0,0 +1 @@ +Updated a Newton manager reference to the simulation stage. diff --git a/source/isaaclab_ov/changelog.d/minimal-kit-exit-lifecycle.minor.rst b/source/isaaclab_ov/changelog.d/minimal-kit-exit-lifecycle.minor.rst new file mode 100644 index 000000000000..9a43cbc4ab6d --- /dev/null +++ b/source/isaaclab_ov/changelog.d/minimal-kit-exit-lifecycle.minor.rst @@ -0,0 +1,6 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_ov.app.OvrtxLauncher` and ``launcher_type`` on + :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`, so :func:`~isaaclab.app.launch_simulation` registers the + OVRTX USD schemas through the renderer's launcher. diff --git a/source/isaaclab_ov/isaaclab_ov/app/__init__.py b/source/isaaclab_ov/isaaclab_ov/app/__init__.py new file mode 100644 index 000000000000..4787816105c0 --- /dev/null +++ b/source/isaaclab_ov/isaaclab_ov/app/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Sub-package for starting the OVRTX runtime.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_ov/isaaclab_ov/app/__init__.pyi b/source/isaaclab_ov/isaaclab_ov/app/__init__.pyi new file mode 100644 index 000000000000..11b136fbabd2 --- /dev/null +++ b/source/isaaclab_ov/isaaclab_ov/app/__init__.pyi @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +__all__ = [ + "OvrtxLauncher", +] + +from .ovrtx_launcher import OvrtxLauncher diff --git a/source/isaaclab_ov/isaaclab_ov/app/ovrtx_launcher.py b/source/isaaclab_ov/isaaclab_ov/app/ovrtx_launcher.py new file mode 100644 index 000000000000..4ac9e874988b --- /dev/null +++ b/source/isaaclab_ov/isaaclab_ov/app/ovrtx_launcher.py @@ -0,0 +1,22 @@ +# 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 + +"""Launcher for the kitless OVRTX runtime.""" + +from __future__ import annotations + +from isaaclab.app.sim_launcher import SimulationLauncher + + +class OvrtxLauncher(SimulationLauncher): + """Registers the OVRTX USD schemas before any stage is opened. + + USD discovers schema plugins once per process, so this runs before the scene is built. + """ + + def __init__(self, launcher_args=None): + import ovrtx + + ovrtx.register_schema_paths() diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py index 1d178d3116c6..8b376db9a154 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py @@ -10,7 +10,7 @@ import os import tempfile from dataclasses import MISSING -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar import warp as wp @@ -53,6 +53,9 @@ class OVRTXRendererCfg(RendererCfg): renderer_type: str = "ovrtx" """Type identifier for OVRTX renderer.""" + launcher_type: ClassVar[str] = "isaaclab_ov.app:OvrtxLauncher" + """The launcher that registers the OVRTX runtime before the stage is created.""" + temp_usd_dir: str | None = None """Directory for temporary USD debug dumps written during OVRTX stage preparation. diff --git a/source/isaaclab_physx/changelog.d/minimal-kit-exit-lifecycle.minor.rst b/source/isaaclab_physx/changelog.d/minimal-kit-exit-lifecycle.minor.rst new file mode 100644 index 000000000000..bc35db24ac4e --- /dev/null +++ b/source/isaaclab_physx/changelog.d/minimal-kit-exit-lifecycle.minor.rst @@ -0,0 +1,17 @@ +Added +^^^^^ + +* Added :class:`~isaaclab_physx.app.KitLauncher`, the Isaac Sim / Kit launcher formerly + ``isaaclab.app.AppLauncher``. :func:`~isaaclab.app.launch_simulation` constructs it; scripts do not. +* Added ``launcher_type`` to :class:`~isaaclab_physx.physics.PhysxCfg`, naming the launcher its runtime needs. +* Added :class:`~isaaclab_physx.app.KitStageBackendCfg`, the simulation backend that attaches the stage to + Kit's USD context and closes it with the simulation. +* Added :func:`~isaaclab_physx.app.show_stage_in_viewport`, which replaces ``isaaclab.sim.utils.show_stage_in_viewport``. +* Added ``set_gravity`` to :class:`~isaaclab_physx.physics.PhysxManager` to set the scene-wide gravity. + +Changed +^^^^^^^ + +* Changed the Isaac RTX renderer to enable ``omni.replicator.core`` itself, so scripts no longer load it. +* Changed the ``randomize_visual_color`` and ``randomize_visual_texture_material`` Replicator event terms + to seed Replicator with ``env.cfg.seed`` when set, since the environments' ``seed()`` no longer does. diff --git a/source/isaaclab_physx/isaaclab_physx/app/__init__.py b/source/isaaclab_physx/isaaclab_physx/app/__init__.py new file mode 100644 index 000000000000..9e121e42884f --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/app/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Sub-package for launching and managing the Kit application process.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_physx/isaaclab_physx/app/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/app/__init__.pyi new file mode 100644 index 000000000000..e5a7cec02763 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/app/__init__.pyi @@ -0,0 +1,14 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +__all__ = [ + "KitLauncher", + "KitStageBackend", + "KitStageBackendCfg", + "show_stage_in_viewport", +] + +from .kit_launcher import KitLauncher +from .kit_stage import KitStageBackend, KitStageBackendCfg, show_stage_in_viewport diff --git a/source/isaaclab_physx/isaaclab_physx/app/kit_launcher.py b/source/isaaclab_physx/isaaclab_physx/app/kit_launcher.py new file mode 100644 index 000000000000..d9b5fe818995 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/app/kit_launcher.py @@ -0,0 +1,948 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Sub-package with the utility class to configure the :class:`isaacsim.simulation_app.SimulationApp`. + +The :class:`KitLauncher` parses environment variables and input CLI arguments to launch the simulator in +various different modes. This includes with or without GUI and switching between different Omniverse remote +clients. Some of these require the extensions to be loaded in a specific order, otherwise a segmentation +fault occurs. +""" + +from __future__ import annotations + +import argparse +import atexit +import importlib.metadata +import importlib.util +import logging +import os +import re +import signal +import sys +from typing import Literal + +try: + import isaacsim # noqa: F401 +except ModuleNotFoundError: + isaacsim = None + +SimulationApp = getattr(isaacsim, "SimulationApp", None) + +from isaaclab.app.loading_screen import report_activity +from isaaclab.app.logging_utils import apply_python_logging_level +from isaaclab.app.settings_manager import get_settings_manager +from isaaclab.app.sim_launcher import SimulationLauncher, _parse_visualizer_csv, fuse_kit_args +from isaaclab.paths import ISAACLAB_ROOT +from isaaclab.utils._device import set_cuda_device +from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING + +# import logger +logger = logging.getLogger(__name__) + +_FABRIC_GPU_INTEROP_ENV = "ISAACLAB_FABRIC_USE_GPU_INTEROP" + +_SIM_APP_CONFIG_KEYS = frozenset( + ("headless", "hide_ui", "physics_gpu", "multi_gpu", "limit_cpu_threads", "width", "height") +) +"""Launcher arguments forwarded to the :class:`SimulationApp` config.""" + +# Suppress noisy debug-level logs from third-party libraries +logging.getLogger("websockets").setLevel(logging.WARNING) +logging.getLogger("matplotlib").setLevel(logging.WARNING) +logging.getLogger("h5py").setLevel(logging.WARNING) + + +def _sanitize_sys_argv_for_kit(argv: list[str]) -> list[str]: + """Remove pytest arguments that Kit would otherwise interpret.""" + if "pytest" not in sys.modules: + return argv + + indexes_to_remove: set[int] = set() + for index, argument in enumerate(argv): + if argument == "-m" and index + 1 < len(argv): + marker_expression = argv[index + 1] + if any(marker in marker_expression for marker in ("pytest", "isaacsim_ci", "windows_ci", "arm_ci")): + indexes_to_remove.update((index, index + 1)) + elif ( + (argument.startswith("--config-file=") and "pyproject.toml" in argument) + or argument == "--capture=no" + or re.fullmatch(r"-v+", argument) + ): + indexes_to_remove.add(index) + + return [argument for index, argument in enumerate(argv) if index not in indexes_to_remove] + + +class ExplicitAction(argparse.Action): + """Custom action to track if an argument was explicitly passed by the user.""" + + def __call__(self, parser, namespace, values, option_string=None): + # Set the parameter value + setattr(namespace, self.dest, values) + # Set a flag indicating the parameter was explicitly passed + setattr(namespace, f"{self.dest}_explicit", True) + + +class KitLauncher(SimulationLauncher): + """A utility class to launch Isaac Sim application based on command-line arguments and environment variables. + + The class resolves the simulation app settings that appear through environments variables, + command-line arguments (CLI) or as input keyword arguments. Based on these settings, it launches the + simulation app and configures the extensions to load (as a part of post-launch setup). + + The input arguments provided to the class are given higher priority than the values set + from the corresponding environment variables. This provides flexibility to deal with different + users' preferences. + + .. note:: + Explicitly defined arguments are only given priority when their value is set to something outside + their default configuration. For example, the ``livestream`` argument is -1 by default. It only + overrides the ``LIVESTREAM`` environment variable when ``livestream`` argument is set to a + value >-1. In other words, if ``livestream=-1``, then the value from the environment variable + ``LIVESTREAM`` is used. + + The ``ISAACLAB_FABRIC_USE_GPU_INTEROP`` environment variable optionally overrides the + ``/physics/fabricUseGPUInterop`` Kit setting. Set it to ``1`` or ``0`` to enable or disable the setting. + When unset, Kit's configured default is preserved. + + """ + + @staticmethod + def _ensure_isaaclab_info_stream_handler() -> None: + """Add a stream handler for Isaac Lab INFO records hidden by Kit logging.""" + handler_name = "isaaclab_info_stream" + root_logger = logging.getLogger() + if any(getattr(handler, "name", None) == handler_name for handler in root_logger.handlers): + return + + class _IsaacLabInfoFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return record.levelno == logging.INFO and record.name.startswith("isaaclab") + + handler = logging.StreamHandler(sys.stdout) + handler.name = handler_name + handler.setLevel(logging.INFO) + handler.addFilter(_IsaacLabInfoFilter()) + handler.setFormatter(logging.Formatter("[INFO]: %(message)s")) + root_logger.addHandler(handler) + + def __init__(self, launcher_args: argparse.Namespace | dict | None = None): + """Create a `SimulationApp`_ instance based on the input settings. + + Args: + launcher_args: Launcher arguments, as normalized by :func:`~isaaclab.app.launch_simulation`. + Defaults to None, which is equivalent to passing an empty dictionary. Keys named like + `SimulationApp`_ config fields (e.g. ``width``) are forwarded to it. + + Raises: + SystemExit: If the full Isaac Sim runtime is unavailable. + ValueError: If incompatible or undefined values are assigned to relevant environment values, + such as ``HEADLESS``. + + .. _SimulationApp: https://docs.isaacsim.omniverse.nvidia.com/latest/py/source/extensions/isaacsim.simulation_app/docs/index.html#isaacsim.simulation_app.SimulationApp + """ + from isaaclab.utils import has_kit + + self._app = None + if has_kit(): + # a Kit app already runs in this process; it is owned (and closed) by whoever started it + return + _ensure_isaac_sim_available() + + if launcher_args is None: + launcher_args = {} + elif isinstance(launcher_args, argparse.Namespace): + launcher_args = launcher_args.__dict__ + + # ``launch_simulation`` applied the Python logging level; keep it for after Kit installs its logging bridge. + self._python_logging_level = logging.getLogger().getEffectiveLevel() + + # Define config members that are read from env-vars or keyword args + self._headless: bool # 0: GUI, 1: Headless + self._livestream: Literal[0, 1, 2] # 0: Disabled, 1: WebRTC public, 2: WebRTC private + self._offscreen_render: bool # 0: Disabled, 1: Enabled + self._sim_experience_file: str # Experience file to load + self._video_enabled: bool # Whether --video recording is enabled + self.device: str # resolved device string (e.g. "cuda:0" or "cpu") + self._deferred_cuda_device_id: int | None = None + + # Integrate env-vars and input keyword args into simulation app config + self._config_resolution(launcher_args) + + # PyTorch may already have been imported while constructing simulation configs. Drain + # its deferred CUDA capability checks before Kit changes the set of CUDA devices that + # correspond to Vulkan-capable GPUs. Otherwise a queued check for a device that Kit + # filters out fails during the post-Kit ``set_device`` call (for example, device=1 with + # two CUDA GPUs but only GPU 0 attached to the display). + self._initialize_preloaded_torch_cuda() + + # Create SimulationApp, passing the resolved self._config to it for initialization + self._create_app() + # back the core settings with the running app + import carb + + get_settings_manager().set_backend(carb.settings.get_settings()) + # Isaac Sim's stage helpers load only with their extension, which may be enabled later. + import omni.kit.app + + self._stage_context_hook = ( + omni.kit.app.get_app() + .get_extension_manager() + .subscribe_to_extension_enable( + lambda _: _share_stage_context(), + ext_name="isaacsim.core.experimental.utils", + hook_name="isaaclab stage context", + ) + ) + self._set_deferred_cuda_device() + # Load IsaacSim extensions + self._load_extensions() + + # Re-run path sanitization. Kit and its extensions may have inserted + # additional ``pip_prebundle`` or conflicting extension directories onto + # ``sys.path`` during startup. A second pass ensures pip-installed + # packages still take priority over bundled copies. + from isaaclab import _deprioritize_prebundle_paths + + _deprioritize_prebundle_paths() + + # Hide the stop button in the toolbar + self._hide_stop_button() + + # Hide play button callback if the timeline is stopped + import omni.timeline + + self._hide_play_button_callback = ( + omni.timeline.get_timeline_interface() + .get_timeline_event_stream() + .create_subscription_to_pop_by_type( + int(omni.timeline.TimelineEventType.STOP), lambda e: self._hide_play_button(True) + ) + ) + self._unhide_play_button_callback = ( + omni.timeline.get_timeline_interface() + .get_timeline_event_stream() + .create_subscription_to_pop_by_type( + int(omni.timeline.TimelineEventType.PLAY), lambda e: self._hide_play_button(False) + ) + ) + # the CI runner greps this marker for hang detection; __stderr__ survives + # stdout being redirected to /dev/null during app creation + print("[ISAACLAB] KitLauncher initialization complete", file=sys.__stderr__, flush=True) + # Kit exits 0 from inside close() and from its own SIGINT handler: close with the real + # status at exit, and let Ctrl-C unwind user code. Other signals keep their default action. + atexit.register(lambda: self._app.close(exit_code=1 if getattr(sys, "last_exc", None) is not None else 0)) + signal.signal(signal.SIGINT, signal.default_int_handler) + + def close(self, exit_code: int = 0) -> None: + """Close the Kit app this launcher started; Kit fast shutdown exits with *exit_code*.""" + if self._app is not None: + # let callbacks queued by the closing simulation run before shutdown + self._app.update() + self._app.close(exit_code=exit_code) + + """ + Operations. + """ + + @classmethod + def is_available(cls) -> bool: + """Return whether the full Isaac Sim runtime is importable, i.e. Kit can be launched. + + This reports launchability, not running state: it is ``True`` in any process with a + full Isaac Sim installation, before and after Kit starts. Use + :func:`~isaaclab.utils.version.has_kit` to check whether Kit is currently running. + """ + return SimulationApp is not None + + @staticmethod + def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: + """Utility function to configure KitLauncher arguments with an existing argument parser object. + + This function appends the command-line arguments relevant to the SimulationApp to the input + :class:`argparse.ArgumentParser` instance. This allows overriding the environment variables using + command-line arguments. + + Currently, it adds the following parameters to the argparser object: + + * ``livestream`` (int): If one of {1, 2}, then livestreaming and headless mode is enabled. The values + map the same as that for the ``LIVESTREAM`` environment variable. If :obj:`-1`, then livestreaming is + determined by the ``LIVESTREAM`` environment variable. + Valid options are: + + - ``0``: Disabled + - ``1``: `WebRTC`_ over public network + - ``2``: `WebRTC`_ over local/private network + + * ``device`` (str): The device to run the simulation on. + Valid options are: + + - ``cpu``: Use CPU. + - ``cuda``: Use GPU with device ID ``0``. + - ``cuda:N``: Use GPU, where N is the device ID. For example, "cuda:0". + + * ``experience`` (str): The experience file to load when launching the SimulationApp. If a relative path + is provided, it is resolved relative to the ``apps`` folder in Isaac Sim and Isaac Lab (in that order). + + If provided as an empty string, the experience file is selected from the resolved visualizer and XR + settings. Rendering support is available by default, including in headless execution. + + * ``deterministic`` (bool): Publishes ``/isaaclab/render/deterministic`` for reproducible rendering. + Does not change how the default experience file is chosen. + + * ``kit_args`` (str): Optional command line arguments to be passed to Omniverse Kit directly. + Arguments should be combined into a single string separated by space. + Example usage: --kit_args "--ext-folder=/path/to/ext1 --ext-folder=/path/to/ext2" + A single Kit argument works in both the space-separated and the ``=``-attached form + (e.g. ``--kit_args "--ext-folder=/path/to/ext1"`` or ``--kit_args=--ext-folder=/path/to/ext1``). + Isaac Lab experiences use one renderer GPU by default. Applications that need single-process + multi-GPU rendering can override the ``renderer.multiGpu`` settings through this argument. + + * ``visualizer`` (str): Visualizer backends to enable. + Valid options are: + + - ``rerun``: Use Rerun visualizer. + - ``newton_gl``: Use Newton GL visualizer. + - ``newton_rtx``: Use Newton RTX path-tracer visualizer (experimental). + - ``viser``: Use Viser visualizer. + - ``kit``: Use Omniverse Kit visualizer. + - ``none``: Disable all visualizers explicitly. + - Multiple visualizers can be specified as a comma-delimited list: + ``--viz rerun,newton_gl,viser``. + + .. deprecated:: Use ``newton_gl`` instead of ``newton``. + + * ``max_visible_envs`` (int | None): Optional global override for partial visualization by capping + how many environments are shown in the visualizers. + More partial visualization configuration fields are available in the ``VisualizerCfg`` class. + + .. _`WebRTC`: https://docs.isaacsim.omniverse.nvidia.com/latest/installation/manual_livestream_clients.html#isaac-sim-short-webrtc-streaming-client + + Args: + parser: An argument parser instance to be extended with the KitLauncher specific options. + """ + # argparse rejects an option-like value token after "--kit_args"; fuse the pair before + # anything parses the command line so the space-separated form works on every entry point + sys.argv[1:] = fuse_kit_args(sys.argv[1:]) + + # Add custom arguments to the parser + arg_group = parser.add_argument_group( + "launcher arguments", + description="Arguments for the KitLauncher. For more details, please check the documentation.", + ) + arg_group.add_argument( + "--livestream", + type=int, + default=-1, + choices={0, 1, 2}, + help="Force enable livestreaming. Mapping corresponds to that for the `LIVESTREAM` environment variable.", + ) + arg_group.add_argument( + "--xr", + action="store_true", + default=False, + help="Enable XR mode for VR/AR applications.", + ) + arg_group.add_argument( + "--device", + type=str, + action=ExplicitAction, + default="cuda:0", + help='The device to run the simulation on. Can be "cpu", "cuda", "cuda:N", where N is the device ID', + ) + arg_group.add_argument( + "--visualizer", + "--viz", + type=_parse_visualizer_csv, + action=ExplicitAction, + default=None, + help="Visualizer backends to enable as CSV (e.g., kit,newton,rerun,viser).", + ) + arg_group.add_argument( + "--verbose", # Note: This is read by SimulationApp through sys.argv + action="store_true", + help="Enable verbose-level log output from the SimulationApp.", + ) + arg_group.add_argument( + "--info", # Note: This is read by SimulationApp through sys.argv + action="store_true", + help="Enable info-level log output from the SimulationApp.", + ) + arg_group.add_argument( + "--experience", + type=str, + default="", + help=( + "The experience file to load when launching the SimulationApp. If an empty string is provided," + " the experience file is determined from the resolved visualizer and XR settings. If a relative" + " path is provided," + " it is resolved relative to the `apps` folder in Isaac Sim and Isaac Lab (in that order)." + ), + ) + arg_group.add_argument( + "--deterministic", + action="store_true", + default=False, + help="Request reproducible rendering (see KitLauncher docs).", + ) + arg_group.add_argument( + "--kit_args", + type=str, + default="", + help=( + "Command line arguments for Omniverse Kit as a string separated by a space delimiter." + ' Example usage: --kit_args "--ext-folder=/path/to/ext1 --ext-folder=/path/to/ext2".' + ' A single Kit argument works in both forms: --kit_args "--ext-folder=/path/to/ext1"' + " or --kit_args=--ext-folder=/path/to/ext1." + ), + ) + arg_group.add_argument( + "--anim_recording_enabled", + action="store_true", + help="Enable recording time-sampled USD animations from IsaacLab PhysX simulations.", + ) + arg_group.add_argument( + "--anim_recording_start_time", + type=float, + default=0, + help=( + "Set time that animation recording begins playing. If not set, the recording will start from the" + " beginning." + ), + ) + arg_group.add_argument( + "--anim_recording_stop_time", + type=float, + default=10, + help=( + "Set time that animation recording stops playing. If the process is shutdown before the stop time is" + " exceeded, then the animation is not recorded." + ), + ) + arg_group.add_argument( + "--max_visible_envs", + type=int, + default=argparse.SUPPRESS, + help=("When set, caps the nums of envs shown in the launched visualizers."), + ) + + """ + Internal functions. + """ + + # Set by :meth:`_resolve_xr_settings`. Defaulted here so :meth:`_resolve_headless_settings` + # stays independent of resolver call order and of whether XR was resolved at all. + _xr_auto_start: bool = False + + def _config_resolution(self, launcher_args: dict): + """Resolve the input arguments and environment variables. + + Args: + launcher_args: A dictionary of all input arguments passed to the class object. + """ + self._kit_visualizer = bool(launcher_args.get("kit_visualizer", False)) + self._resolve_livestream_settings(launcher_args) + # XR must be resolved before headless so that XR can prevent + # visualizer-intent-based headless forcing. + self._resolve_xr_settings(launcher_args) + self._resolve_headless_settings(launcher_args) + self._resolve_camera_settings(launcher_args) + self._resolve_viewport_settings(launcher_args) + self._resolve_device_settings(launcher_args) + self._resolve_experience_file(launcher_args) + self._resolve_anim_recording_settings(launcher_args) + self._resolve_kit_args(launcher_args) + + # Prepare final simulation app config + # Remove all values from input keyword args which are not meant for SimulationApp + # Assign all the passed settings to a dictionary for the simulation app + self._sim_app_config = {key: launcher_args[key] for key in _SIM_APP_CONFIG_KEYS & launcher_args.keys()} + + def _resolve_livestream_settings(self, launcher_args: dict): + """Resolve livestream related settings.""" + # the mode (CLI over ``LIVESTREAM``) is resolved and validated by ``launch_simulation`` + self._livestream = int(launcher_args.get("livestream", 0)) + + # Process livestream here before launching kit because some of the extensions only work + # when launched with the kit file. Only one livestream extension can be enabled at a time. + self._livestream_args = [] + if self._livestream == 1: + # WebRTC over a public network advertises the public IP address of a remote instance + public_ip = os.environ.get("PUBLIC_IP", "127.0.0.1") + self._livestream_args.append(f"--/exts/omni.kit.livestream.app/primaryStream/publicIp={public_ip}") + if self._livestream >= 1: + # Signal/stream ports and allowDynamicResize must be set explicitly; without + # them NVST cannot bind its server socket (NVST_R_INTERNAL_ERROR) and any + # subsequent window resize after a client connects triggers NVST_R_BUSY. + self._livestream_args += [ + "--/exts/omni.kit.livestream.app/primaryStream/signalPort=49100", + "--/exts/omni.kit.livestream.app/primaryStream/streamPort=47998", + "--/exts/omni.kit.livestream.app/primaryStream/allowDynamicResize=true", + "--/exts/omni.kit.livestream.app/primaryStream/streamType=webrtc", + "--enable", + "omni.kit.livestream.app", + ] + sys.argv += self._livestream_args + + def _resolve_headless_settings(self, launcher_args: dict): + """Resolve headless related settings.""" + headless_env = int(os.environ.get("HEADLESS", 0)) + if headless_env not in {0, 1}: + raise ValueError(f"Invalid value for environment variable `HEADLESS`: {headless_env}. Expected: 0 or 1.") + # livestreaming always runs headless on the host machine + self._headless = bool(launcher_args.get("headless", False)) or self._livestream > 0 or bool(headless_env) + # only a Kit visualizer opens a window, and XR without an explicit one has no viewport to start from + if not self._headless and (self._xr_auto_start or not self._kit_visualizer): + logger.info("Running headless because '--viz kit' was not requested. Pass it to open a viewport.") + self._headless = True + # Headless needs to be passed to the SimulationApp so we keep it here + launcher_args["headless"] = self._headless + + def _resolve_camera_settings(self, launcher_args: dict): + """Resolve camera related settings.""" + self._enable_cameras = bool(launcher_args.get("enable_cameras", False)) + self._offscreen_render = False + if self._enable_cameras and self._headless: + self._offscreen_render = True + + def _resolve_xr_settings(self, launcher_args: dict): + """Resolve XR related settings.""" + xr_env = int(os.environ.get("XR", 0)) + xr_arg = launcher_args.get("xr", False) + xr_valid_vals = {0, 1} + if xr_env not in xr_valid_vals: + raise ValueError(f"Invalid value for environment variable `XR`: {xr_env} .Expected: {xr_valid_vals} .") + # We allow xr kwarg to supersede XR envvar + if xr_arg is True: + self._xr = xr_arg + else: + self._xr = bool(xr_env) + + # Determine whether XR should auto-inject a KitVisualizer. + # When XR is enabled but no Kit visualizer was explicitly requested via + # CLI, we auto-inject one so that app.update() and forward() are pumped + # each frame -- the XR runtime needs both to receive updated hand/joint + # transforms. + self._xr_auto_start = self._xr and not ( + launcher_args.get("visualizer_explicit", False) and "kit" in (launcher_args.get("visualizer") or ()) + ) + + def _resolve_viewport_settings(self, launcher_args: dict): + """Resolve viewport related settings.""" + self._video_enabled = bool(launcher_args.get("video", False)) + if self._video_enabled and any( + importlib.util.find_spec(package) is None for package in ("moviepy", "imageio_ffmpeg") + ): + raise ModuleNotFoundError( + "Video recording with `--video` requires MoviePy and its imageio-ffmpeg backend, " + "which are not installed by default. " + "Run uv commands with `uv run --extra video ...`, or install MoviePy into the " + 'legacy environment with `./isaaclab.sh -p -m pip install "moviepy>=1.0.3,<2.0.0.dev0"` ' + "(`isaaclab.bat -p -m pip install ...` on Windows), and retry." + ) + # Check if we can disable the viewport to improve performance + # This should only happen if we are running headless and do not require livestreaming or video recording + # This is different from offscreen_render because this only affects the default viewport and + # not other render-products in the scene + self._render_viewport = True + if self._headless and not self._livestream and not self._video_enabled: + self._render_viewport = False + + # hide_ui flag + launcher_args["hide_ui"] = False + if self._headless and not self._livestream: + launcher_args["hide_ui"] = True + + def _resolve_device_settings(self, launcher_args: dict): + """Resolve simulation GPU device related settings.""" + device = launcher_args.get("device", "cuda:0") + distributed = launcher_args.get("distributed", False) + if self._xr and not launcher_args.get("device_explicit", False) and not distributed: + # If no device is specified, default to the CPU device if we are running in XR + device = launcher_args["device"] = "cpu" + + if "cuda" not in device and "cpu" not in device: + raise ValueError( + f"Invalid value for input keyword argument `device`: {device}." + " Expected: a string with the format 'cuda', 'cuda:', or 'cpu'." + ) + device_id = int(device.split(":")[-1]) if "cuda:" in device else 0 + + if distributed: + # ``launch_simulation`` already resolved the per-rank ``device`` + launcher_args["multi_gpu"] = False + # limit CPU threads to minimize thread context switching + # this ensures processes do not take up all available threads and fight for resources + num_cpu_cores = os.cpu_count() + num_threads_per_process = num_cpu_cores // int(os.getenv("WORLD_SIZE", 1)) + # set environment variables to limit CPU threads + os.environ["PXR_WORK_THREAD_LIMIT"] = str(num_threads_per_process) + os.environ["OPENBLAS_NUM_THREADS"] = str(num_threads_per_process) + sys.argv.append(f"--/plugins/carb.tasking.plugin/threadCount={num_threads_per_process}") + + # ``/physics/cudaDevice`` is resolved by CUDA, so the masked index is correct there. + # ``activeGpu`` is deliberately left unset; the renderer device is selected in + # :meth:`_resolve_kit_args` instead. + launcher_args["physics_gpu"] = device_id + + # Defer importing torch until after SimulationApp starts. Importing + # torch can import NumPy/OpenBLAS, whose at-fork handlers can crash + # Kit's platform-info fork during startup. + if "cuda" in device: + self._deferred_cuda_device_id = device_id + + # Store the resolved device string for downstream consumers (e.g. sim_launcher) + self.device = device + + logger.info("Using device: %s", device) + + def _initialize_preloaded_torch_cuda(self) -> None: + """Initialize CUDA before Kit when another import has already loaded PyTorch. + + Importing PyTorch schedules capability checks for every CUDA device it can see. Kit may + subsequently restrict CUDA to the GPUs that its Vulkan backend can use, leaving those + queued checks with stale device indices. Do not import PyTorch here: when it has not + already been loaded, the normal post-Kit initialization path remains preferable. + """ + if self._deferred_cuda_device_id is None: + return + + torch = sys.modules.get("torch") + if torch is not None and not torch.cuda.is_initialized(): + torch.cuda.init() + + def _set_deferred_cuda_device(self) -> None: + """Set the current CUDA device after Kit startup.""" + if self._deferred_cuda_device_id is None: + return + + set_cuda_device(self._deferred_cuda_device_id) + + def _resolve_experience_file(self, launcher_args: dict): + """Resolve experience file related settings.""" + # Check if input keywords contain an 'experience' file setting + # Note: since experience is taken as a separate argument by Simulation App, we store it separately + self._sim_experience_file = launcher_args.get("experience", "") + deterministic_mode = bool(launcher_args.get("deterministic", False)) + + # If nothing is provided resolve the experience file based on the headless flag. + # EXP_PATH is normally set by ``isaacsim.bootstrap_kernel()`` on first import. + # If it is not set (e.g. on aarch64 where the bootstrap early-return triggered + # under certain install layouts), derive it from the installed isaacsim package. + kit_app_exp_path = os.environ.get("EXP_PATH") + if not kit_app_exp_path: + try: + import isaacsim as _isaacsim_for_paths + except ImportError as e: + raise RuntimeError( + "EXP_PATH is not set and the 'isaacsim' package is not importable." + " Install Isaac Sim (`pip install isaacsim` or the binary distribution)" + " before launching KitLauncher." + ) from e + kit_app_exp_path = os.path.join(os.path.dirname(_isaacsim_for_paths.__file__), "apps") + os.environ["EXP_PATH"] = kit_app_exp_path + isaaclab_app_exp_path = str(ISAACLAB_ROOT / "apps") + + if self._sim_experience_file == "": + # check if the headless flag is set + # xr rendering overrides camera rendering settings + if self._enable_cameras and not self._xr: + if self._headless and not self._livestream: + self._sim_experience_file = os.path.join( + isaaclab_app_exp_path, "isaaclab.python.headless.rendering.kit" + ) + else: + self._sim_experience_file = os.path.join(isaaclab_app_exp_path, "isaaclab.python.rendering.kit") + elif self._xr: + if self._headless and not self._livestream: + self._sim_experience_file = os.path.join( + isaaclab_app_exp_path, "isaaclab.python.xr.openxr.headless.kit" + ) + else: + self._sim_experience_file = os.path.join(isaaclab_app_exp_path, "isaaclab.python.xr.openxr.kit") + elif self._headless and not self._livestream: + self._sim_experience_file = os.path.join(isaaclab_app_exp_path, "isaaclab.python.headless.kit") + else: + self._sim_experience_file = os.path.join(isaaclab_app_exp_path, "isaaclab.python.kit") + elif not os.path.isabs(self._sim_experience_file): + option_1_app_exp_path = os.path.join(kit_app_exp_path, self._sim_experience_file) + option_2_app_exp_path = os.path.join(isaaclab_app_exp_path, self._sim_experience_file) + if os.path.exists(option_1_app_exp_path): + self._sim_experience_file = option_1_app_exp_path + elif os.path.exists(option_2_app_exp_path): + self._sim_experience_file = option_2_app_exp_path + else: + raise FileNotFoundError( + f"Invalid value for input keyword argument `experience`: {self._sim_experience_file}." + "\n No such file exists in either the Kit or Isaac Lab experience paths. Checked paths:" + f"\n\t [1]: {option_1_app_exp_path}" + f"\n\t [2]: {option_2_app_exp_path}" + ) + elif not os.path.exists(self._sim_experience_file): + raise FileNotFoundError( + f"Invalid value for input keyword argument `experience`: {self._sim_experience_file}." + " The file does not exist." + ) + + # Resolve the absolute path of the experience file + self._sim_experience_file = os.path.abspath(self._sim_experience_file) + # Detect a known incompatibility between Isaac Lab and Isaac Sim full-app experiences. + if self._livestream in {1, 2} and self._experience_depends_on_isaacsim_exp_full(self._sim_experience_file): + raise ValueError( + "The experience file depends on 'isaacsim.exp.full', which is known to hang or invalidate PhysX " + "tensor views when launched through Isaac Lab with livestreaming enabled. Omit '--experience' so " + "KitLauncher can select an Isaac Lab experience file, or remove the 'isaacsim.exp.full' dependency." + ) + self._deterministic_rendering = bool(deterministic_mode) + logger.info("Loading experience file: %s", self._sim_experience_file) + + @staticmethod + def _experience_depends_on_isaacsim_exp_full(experience_file: str) -> bool: + """Return whether a Kit experience directly depends on ``isaacsim.exp.full``.""" + with open(experience_file, encoding="utf-8") as file: + return re.search(r'^\s*["\']isaacsim\.exp\.full["\']\s*=', file.read(), re.MULTILINE) is not None + + def _resolve_anim_recording_settings(self, launcher_args: dict): + """Resolve animation recording settings; recording needs the ``omni.physx.pvd`` extension.""" + self._anim_recording = None + if not launcher_args.get("anim_recording_enabled", False): + return + if self._headless: + raise ValueError("Animation recording is not supported in headless mode.") + start_time = launcher_args.get("anim_recording_start_time", 0) + stop_time = launcher_args.get("anim_recording_stop_time", 10) + if start_time >= stop_time: + raise ValueError( + f"'anim_recording_start_time' {start_time} must be less than 'anim_recording_stop_time' {stop_time}" + ) + self._anim_recording = (start_time, stop_time) + sys.argv += ["--enable", "omni.physx.pvd"] + + def _requires_all_partitions_spectator_view(self) -> bool: + """Return whether the launch needs an unpartitioned all-environment view.""" + return self._kit_visualizer or self._render_viewport or self._video_enabled or self._livestream > 0 or self._xr + + def _resolve_kit_args(self, launcher_args: dict): + """Resolve additional arguments passed to Kit.""" + self._kit_args = launcher_args.get("kit_args", "").split() + + fabric_gpu_interop = os.environ.get(_FABRIC_GPU_INTEROP_ENV) + if fabric_gpu_interop is not None: + if fabric_gpu_interop not in {"0", "1"}: + raise ValueError( + f"Invalid value for environment variable `{_FABRIC_GPU_INTEROP_ENV}`: {fabric_gpu_interop}." + " Expected: 0 or 1." + ) + argument = f"--/physics/fabricUseGPUInterop={'true' if fabric_gpu_interop == '1' else 'false'}" + setting = argument.partition("=")[0] + if not any(arg.partition("=")[0] == setting for arg in sys.argv + self._kit_args): + self._kit_args.append(argument) + + argument = "--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false" + setting = argument.partition("=")[0] + if not any(arg.partition("=")[0] == setting for arg in sys.argv + self._kit_args): + self._kit_args.append(argument) + + # RTX allocates spectator support during startup, so visual output intent + # must become a Kit argument before SimulationApp is created. + if self._requires_all_partitions_spectator_view(): + argument = f"--{ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING}=true" + setting = argument.partition("=")[0] + if not any(arg.partition("=")[0] == setting for arg in sys.argv + self._kit_args): + self._kit_args.append(argument) + + # Select the renderer by CUDA index; the trailing comma keeps the setting string-typed. + # XR streams a single stereo swapchain that the CloudXR compositor imports, so the + # renderer has to stay on one known device there too -- otherwise the compositor and + # the renderer can end up on different GPUs and the headset receives noise. This only + # applies once a CUDA device has actually been selected: ``--xr`` on its own resolves + # to ``cpu``, where there is no simulation GPU to align to, so Kit's own choice stands. + if launcher_args.get("multi_gpu") is False or (self._xr and "cuda" in self.device): + argument = f"--/renderer/multiGpu/activeCudaGpus={launcher_args['physics_gpu']}," + setting = argument.partition("=")[0] + if not any(arg.partition("=")[0] == setting for arg in sys.argv + self._kit_args): + self._kit_args.append(argument) + + sys.argv += self._kit_args + + def _create_app(self): + """Launch and create the SimulationApp based on the parsed simulation config.""" + # Initialize SimulationApp + # hack sys module to make sure that the SimulationApp is initialized correctly + # this is to avoid the warnings from the simulation app about not ok modules + r = re.compile(".*lab.*") + found_modules = list(filter(r.match, list(sys.modules.keys()))) + # remove Isaac Lab modules from sys.modules + hacked_modules = dict() + for key in found_modules: + hacked_modules[key] = sys.modules[key] + del sys.modules[key] + + # disable sys stdout and stderr to avoid printing the warning messages + # this is mainly done to purge the print statements from the simulation app + if "--verbose" not in sys.argv and "--info" not in sys.argv: + sys.stdout = open(os.devnull, "w") # noqa: SIM115 + + sys.argv = _sanitize_sys_argv_for_kit(sys.argv) + + report_activity("Starting Isaac Sim") + self._app = SimulationApp(self._sim_app_config, experience=self._sim_experience_file) + report_activity(None) + + sys.stdout = sys.__stdout__ + + # add Isaac Lab modules back to sys.modules + for key, value in hacked_modules.items(): + sys.modules[key] = value + # remove the threadCount argument from sys.argv if it was added for distributed training + pattern = r"--/plugins/carb\.tasking\.plugin/threadCount=\d+" + sys.argv = [arg for arg in sys.argv if not re.match(pattern, arg)] + + # remove additional OV args from sys.argv + if len(self._kit_args) > 0: + sys.argv = [arg for arg in sys.argv if arg not in self._kit_args] + if len(self._livestream_args) > 0: + sys.argv = [arg for arg in sys.argv if arg not in self._livestream_args] + + def _load_extensions(self): + """Load correct extensions based on KitLauncher's resolved config member variables.""" + # After SimulationApp starts, Kit installs its Python log bridge at DEBUG level. + # Re-apply the intended Python logging level, then add a scoped stream handler for + # Isaac Lab INFO records that Kit's bridge does not mirror to the console. + apply_python_logging_level(self._python_logging_level) + if self._python_logging_level <= logging.WARNING: + KitLauncher._ensure_isaaclab_info_stream_handler() + # At WARNING, let Isaac Lab INFO records reach the scoped handler while the other + # root handlers stay at WARNING. + logging.getLogger().setLevel(min(self._python_logging_level, logging.INFO)) + settings = get_settings_manager() + + # Publish whether Kit has an interactive GUI (local window, livestream, or XR). + # SimulationContext and renderers consume this setting during their initialization. + settings.set_bool("/isaaclab/has_gui", not self._headless or self._livestream >= 1 or self._xr) + settings.set_bool("/isaaclab/render/offscreen", self._offscreen_render) + settings.set_bool("/isaaclab/xr/enabled", self._xr) + # set setting to indicate XR auto-start mode -- when running headless + # (no Kit GUI) the AR profile must be enabled programmatically so that + # the OpenXR session starts without user interaction + settings.set_bool("/isaaclab/xr/auto_start", self._headless and self._xr) + settings.set_bool("/isaaclab/video/enabled", self._video_enabled) + + # publish the reproducible-rendering intent; rendering backends read this on initialization + settings.set_bool("/isaaclab/render/deterministic", self._deterministic_rendering) + + # use fixed time stepping disabled; custom loop runner from Isaac Sim is used instead + settings.set_bool("/app/player/useFixedTimeStepping", False) + + if self._anim_recording is not None: + settings.set_bool("/isaaclab/anim_recording/enabled", True) + settings.set_float("/isaaclab/anim_recording/start_time", self._anim_recording[0]) + settings.set_float("/isaaclab/anim_recording/stop_time", self._anim_recording[1]) + + def _hide_stop_button(self): + """Hide the stop button in the toolbar. + + For standalone executions, having a stop button is confusing since it invalidates the whole simulation. + Thus, we hide the button so that users don't accidentally click it. + """ + # when we are truly headless, then we can't import the widget toolbar + # thus, we only hide the stop button when we are not headless (i.e. GUI is enabled) + if self._livestream >= 1 or not self._headless: + import omni.kit.widget.toolbar + + # grey out the stop button because we don't want to stop the simulation manually in standalone mode + toolbar = omni.kit.widget.toolbar.get_instance() + play_button_group = toolbar._builtin_tools._play_button_group # type: ignore + if play_button_group is not None: + play_button_group._stop_button.visible = False # type: ignore + play_button_group._stop_button.enabled = False # type: ignore + play_button_group._stop_button = None # type: ignore + + def _hide_play_button(self, flag): + """Hide/Unhide the play button in the toolbar. + + This is used if the timeline is stopped by a GUI action like "save as" to not allow the user to + resume the timeline afterwards. + """ + # when we are truly headless, then we can't import the widget toolbar + # thus, we only hide the play button when we are not headless (i.e. GUI is enabled) + if self._livestream >= 1 or not self._headless: + import omni.kit.widget.toolbar + + toolbar = omni.kit.widget.toolbar.get_instance() + play_button_group = toolbar._builtin_tools._play_button_group # type: ignore + if play_button_group is not None: + play_button_group._play_button.visible = not flag # type: ignore + play_button_group._play_button.enabled = not flag # type: ignore + + +def _share_stage_context() -> None: + """Point Isaac Sim's stage helpers at Isaac Lab's thread-local current-stage context.""" + try: + # Do not enable ``isaacsim.core.experimental.utils`` here. Stage creation is used by + # Newton tests before Newton imports Warp, and enabling Isaac Sim experimental utils can + # make Kit's importer expose the bundled ``omni.warp.core`` package ahead of pip Warp. + from isaacsim.core.experimental.utils import stage as sim_stage + except ImportError: + return + from isaaclab.sim.utils import stage as stage_utils + + # Isaac Sim stage helpers read this singleton context. + sim_stage._context = stage_utils._context + + +def _ensure_isaac_sim_available() -> None: + """Raise ``SystemExit`` with an actionable hint when Isaac Sim / Kit is missing.""" + if KitLauncher.is_available(): + return + + isaaclab_path = os.environ.get("ISAACLAB_PATH") + local_sim = os.path.join(isaaclab_path, "_isaac_sim") if isaaclab_path else None + extra_hint = "" + if local_sim and os.path.isdir(local_sim): + launcher, source = ("isaaclab.bat", f'call "{local_sim}\\setup_conda_env.bat"') + if sys.platform != "win32": + launcher, source = ("./isaaclab.sh", f'source "{local_sim}/setup_conda_env.sh"') + extra_hint = ( + f" Found a local Isaac Sim at {local_sim} but its environment is not active.\n" + f" Either run via `{launcher} ...` (which sources the Isaac Sim env automatically),\n" + f" or in your current shell run:\n" + f" {source}\n" + ) + + try: + installed_version = importlib.metadata.version("isaacsim") + except importlib.metadata.PackageNotFoundError: + installed_version = None + + if installed_version: + logger.error( + f"\n[ERROR] Isaac Sim {installed_version} is installed, but its full runtime is unavailable.\n" + "\n" + " This environment requires Isaac Sim and Omniverse Kit.\n" + " PhysX backend and Kit visualizer require Isaac Sim.\n" + "\n" + " The current Python environment does not expose the SimulationApp API.\n" + f"{extra_hint}" + " Install the full Isaac Sim runtime from the Isaac Lab directory by running:\n" + " uv run isaaclab -i isaacsim\n" + "\n" + " See https://isaac-sim.github.io/IsaacLab/main/source/setup/installation for details.\n" + ) + raise SystemExit(1) + + logger.error( + "\n[ERROR] Isaac Sim is not installed or not found on PYTHONPATH.\n" + "\n" + " This environment requires Isaac Sim and Omniverse Kit.\n" + " PhysX backend and Kit visualizer currently requires Isaac Sim.\n" + "\n" + f"{extra_hint}" + " To fix this, ensure Isaac Sim is installed and available in the current environment.\n" + "\n" + " See https://isaac-sim.github.io/IsaacLab/main/source/setup/installation for details.\n" + ) + raise SystemExit(1) diff --git a/source/isaaclab_physx/isaaclab_physx/app/kit_stage.py b/source/isaaclab_physx/isaaclab_physx/app/kit_stage.py new file mode 100644 index 000000000000..6b8387a50a77 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/app/kit_stage.py @@ -0,0 +1,73 @@ +# 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 + +"""Kit USD-context ownership of the simulation stage.""" + +from __future__ import annotations + +import contextlib +from dataclasses import field + +import omni.kit.app +import omni.usd +from pxr import Usd, UsdUtils + +from isaaclab.sim import BackendCfg +from isaaclab.utils import configclass + + +class KitStageBackend: + """Attach the simulation stage to Kit's USD context, where Kit extensions discover it. + + Kit extensions such as PhysX views, articulations, and the viewport read the stage of the + USD context rather than Isaac Lab's current stage. + """ + + def __init__(self, cfg: KitStageBackendCfg): + context = omni.usd.get_context() + if context.get_stage() is not cfg.stage: + context.attach_stage_with_callback(UsdUtils.StageCache.Get().GetId(cfg.stage).ToLongInt()) + + def close(self) -> None: + """Close the stage of Kit's USD context. + + The simulation closes backends before it clears the stage cache; clearing the cache first + makes Kit fail with "Removal of UsdStage from cache failed" and can hang teardown. + """ + omni.usd.get_context().close_stage() + + +@configclass +class KitStageBackendCfg(BackendCfg): + """Kit USD-context attachment; the stage is borrowed from the active simulation.""" + + class_type: type = KitStageBackend + stage: Usd.Stage = field(kw_only=True, metadata={"copy": False}) + + +def show_stage_in_viewport(usd_path: str) -> None: + """Open a USD file in the Kit viewport and keep the app running until the user closes it. + + Opens the stage through the Kit USD context so it appears in the viewport (or the + livestream client), then spins the Kit update loop until the window is closed or the + loop is interrupted. Must only be called inside a running Kit process with a GUI. + + Args: + usd_path: Path of the USD file to display. + + Raises: + RuntimeError: If the stage cannot be opened. + """ + # A failed open leaves the previously loaded stage in the viewport, which would look like a + # successful preview of the wrong asset, so surface the failure instead of blocking on it. + result = omni.usd.get_context().open_stage(usd_path) + opened = result[0] if isinstance(result, tuple) else result + if opened is False: + raise RuntimeError(f"Failed to open the USD stage in the Kit viewport: {usd_path}") + + app = omni.kit.app.get_app_interface() + with contextlib.suppress(KeyboardInterrupt): + while app.is_running(): + app.update() diff --git a/source/isaaclab_physx/isaaclab_physx/envs/mdp/events.py b/source/isaaclab_physx/isaaclab_physx/envs/mdp/events.py index c0666ebc4844..7467d85cc4fb 100644 --- a/source/isaaclab_physx/isaaclab_physx/envs/mdp/events.py +++ b/source/isaaclab_physx/isaaclab_physx/envs/mdp/events.py @@ -254,6 +254,9 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv) -> None: super().__init__(cfg, env) self._rep = rep = _get_replicator() + # this term draws from Replicator's random streams, so they follow the environment seed + if env.cfg.seed is not None: + rep.set_global_seed(env.cfg.seed) asset_cfg: SceneEntityCfg = cfg.params.get("asset_cfg") mesh_name: str = cfg.params.get("mesh_name", "") # type: ignore @@ -378,6 +381,9 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv) -> None: ) self._rep = rep = _get_replicator() + # this term draws from Replicator's random streams, so they follow the environment seed + if env.cfg.seed is not None: + rep.set_global_seed(env.cfg.seed) asset_cfg: SceneEntityCfg = cfg.params.get("asset_cfg") asset = env.scene[asset_cfg.name] diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 0f5dc77678d8..f05032e67303 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -612,6 +612,21 @@ def close(cls) -> None: def get_physics_sim_view(cls) -> omni.physics.tensors.SimulationView | None: return None if cls.backend is None else cls.backend.simulation_view + @classmethod + def set_gravity(cls, gravity: tuple[float, float, float]) -> None: + """Set the scene-wide gravity vector through the PhysX simulation view [m/s^2]. + + Args: + gravity: World-frame gravity vector [m/s^2]. + + Raises: + RuntimeError: If the PhysX simulation view has not been initialized. + """ + physics_sim_view = cls.get_physics_sim_view() + if physics_sim_view is None: + raise RuntimeError("PhysxManager has not been initialized yet.") + physics_sim_view.set_gravity(carb.Float3(*gravity)) + @classmethod def get_physics_sim_device(cls) -> str: """Get the physics simulation device (Isaac Sim compatibility alias).""" diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager_cfg.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager_cfg.py index 0d1762dfbd82..6ce1dbc73b88 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager_cfg.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import MISSING -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, ClassVar, Literal from isaaclab.physics import PhysicsCfg from isaaclab.sim import BackendCfg @@ -55,6 +55,9 @@ class PhysxCfg(PhysicsCfg): class_type: type[PhysxManager] | str = "{DIR}.physx_manager:PhysxManager" """The class type of the PhysxManager.""" + launcher_type: ClassVar[str] = "isaaclab_physx.app:KitLauncher" + """The launcher that starts the Isaac Sim / Kit runtime this backend runs in.""" + # ------------------------------------------------------------------ # Solver Settings # ------------------------------------------------------------------ diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py b/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py index bbe33ec153a6..f61339c6ab15 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py @@ -45,6 +45,7 @@ class FabricBackend: def __init__(self, cfg: FabricBackendCfg): self.device = cfg.device + self.usd_stage = cfg.stage self.stage = usdrt.Usd.Stage.Attach(UsdUtils.StageCache.Get().GetId(cfg.stage).ToLongInt()) self.hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( self.stage.GetFabricId(), self.stage.GetStageIdAsStageId() @@ -156,7 +157,7 @@ def _bind_geometries(self, provider: SceneDataProvider, paths: tuple[str, ...]) # Foreign physics publishes world points. Author the sink once so Kit's USD refresh agrees. groups = {} for path in paths: - geometry = UsdGeom.Xformable(provider.usd_stage.GetPrimAtPath(path)) + geometry = UsdGeom.Xformable(self.usd_stage.GetPrimAtPath(path)) geometry.ClearXformOpOrder() geometry.SetResetXformStack(True) prim_type = geometry.GetPrim().GetTypeName() diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_cfg.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_cfg.py index 409d99497942..b3efb5f8dff0 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_cfg.py @@ -107,7 +107,7 @@ class IsaacRtxRendererGlobalSettingsCfg: show_all_partitions_by_default: bool | None = None """Optionally show partitioned content in cameras without a partition token. - ``None`` preserves the launch-time setting. :class:`~isaaclab.app.AppLauncher` + ``None`` preserves the launch-time setting. :class:`~isaaclab_physx.app.KitLauncher` enables the all-environment spectator view before RTX startup when the Kit viewport is enabled or Kit visualization, recording, livestreaming, or XR is requested. Environments must be spatially separated when this setting is enabled. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py index 41207312ebd6..d73e21cd1fd6 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py @@ -15,6 +15,7 @@ import isaaclab.sim as sim_utils from isaaclab.app.settings_manager import SettingsManager, get_settings_manager +from isaaclab.sim.utils import enable_extension from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING from .isaac_rtx_renderer_cfg import IsaacRtxRendererGlobalSettingsCfg @@ -116,6 +117,8 @@ def _apply_isaac_rtx_global_settings( antialiasing_mode = getattr(global_settings, "antialiasing_mode", None) if antialiasing_mode is not None: + # Some Kit experiences (e.g. the viewport-only app) do not preload Replicator. + enable_extension("omni.replicator.core") import omni.replicator.core as rep rep.settings.set_render_rtx_realtime(antialiasing=antialiasing_mode) diff --git a/source/isaaclab/test/app/test_app_launcher_argv.py b/source/isaaclab_physx/test/app/test_kit_launcher.py similarity index 70% rename from source/isaaclab/test/app/test_app_launcher_argv.py rename to source/isaaclab_physx/test/app/test_kit_launcher.py index 8496fd3f3212..9ea5c9a7739e 100644 --- a/source/isaaclab/test/app/test_app_launcher_argv.py +++ b/source/isaaclab_physx/test/app/test_kit_launcher.py @@ -3,24 +3,38 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Kitless tests for how :class:`AppLauncher` resolves arguments, environment variables, and Kit settings.""" +"""Kitless tests for how :class:`KitLauncher` resolves arguments, environment variables, and Kit settings.""" import argparse import logging import sys from types import SimpleNamespace +import isaaclab_physx.app as app_module +import isaaclab_physx.app.kit_launcher as kit_launcher_module import pytest +from isaaclab_physx.app.kit_launcher import KitLauncher, _sanitize_sys_argv_for_kit -import isaaclab.app as app_module -import isaaclab.app.app_launcher as app_launcher_module import isaaclab.app.sim_launcher as sim_launcher import isaaclab.utils as utils_module -from isaaclab.app.app_launcher import AppLauncher, _sanitize_sys_argv_for_kit -from isaaclab.app.sim_launcher import Scan, _ensure_livestream_kit_visualizer, _get_kit_runtime_sources +from isaaclab.app import SimulationLauncher, add_launcher_args +from isaaclab.app.sim_launcher import Scan, _get_kit_runtime_sources, _normalize_launcher_args from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING +def _launcher_after_output_resolution(**state) -> KitLauncher: + """Return a bare launcher with the attributes the output resolvers set, all requesting no visual output.""" + launcher = KitLauncher.__new__(KitLauncher) + launcher._kit_visualizer = False + launcher._render_viewport = False + launcher._video_enabled = False + launcher._livestream = 0 + launcher._xr = False + for name, value in state.items(): + setattr(launcher, name, value) + return launcher + + def test_sanitize_sys_argv_removes_trailing_pytest_verbosity(monkeypatch): """Remove a pytest verbosity flag even when it is the final argument.""" monkeypatch.setitem(sys.modules, "pytest", object()) @@ -50,17 +64,14 @@ def test_sanitize_sys_argv_removes_pytest_marker_pair(monkeypatch): def _resolve_devices_and_kit_args(launcher_args: dict, monkeypatch, *, xr: bool = False) -> tuple[dict, list[str]]: - """Resolve device settings and Kit arguments without constructing an ``AppLauncher``. + """Resolve device settings and Kit arguments without constructing an ``KitLauncher``. ``_resolve_kit_args`` extends ``sys.argv``, so the caller's argv is isolated. """ monkeypatch.setattr(sys, "argv", ["script.py"]) - launcher = AppLauncher.__new__(AppLauncher) - launcher.device_id = 0 - launcher._deferred_cuda_device_id = None - launcher._xr = xr - AppLauncher._resolve_device_settings(launcher, launcher_args) - AppLauncher._resolve_kit_args(launcher, launcher_args) + launcher = _launcher_after_output_resolution(_deferred_cuda_device_id=None, _xr=xr) + KitLauncher._resolve_device_settings(launcher, launcher_args) + KitLauncher._resolve_kit_args(launcher, launcher_args) return launcher_args, launcher._kit_args @@ -78,10 +89,14 @@ def _resolve_devices_and_kit_args(launcher_args: dict, monkeypatch, *, xr: bool id="explicit-kit-arg", ), pytest.param({"device": "cuda:1"}, [], id="multi-gpu"), + # ``launch_simulation`` resolved the per-rank device before the launcher runs + pytest.param({"device": "cuda:1", "distributed": True}, ["--/renderer/multiGpu/activeCudaGpus=1,"], id="rank"), ], ) def test_devices_selected_by_cuda_index(launcher_args, expected_renderer_args, monkeypatch): """Select physics and single-GPU rendering devices by CUDA index.""" + for name in ("PXR_WORK_THREAD_LIMIT", "OPENBLAS_NUM_THREADS"): + monkeypatch.setenv(name, "0") args, kit_args = _resolve_devices_and_kit_args(launcher_args, monkeypatch) renderer_args = [arg for arg in kit_args if arg.startswith("--/renderer/multiGpu/activeCudaGpus=")] @@ -123,22 +138,8 @@ def test_xr_pins_the_renderer_only_for_a_cuda_device( ("launcher_state", "expected_enabled"), [ pytest.param({}, False, id="headless-training"), - pytest.param({"_cfg_has_kit_visualizer": True}, True, id="config-kit-visualizer"), - pytest.param( - {"_cli_visualizer_explicit": True, "_cli_visualizer_types": ["kit"]}, - True, - id="cli-kit-visualizer", - ), + pytest.param({"_kit_visualizer": True}, True, id="kit-visualizer"), pytest.param({"_render_viewport": True}, True, id="viewport"), - pytest.param( - { - "_cfg_has_kit_visualizer": True, - "_cli_visualizer_explicit": True, - "_cli_visualizer_types": ["rerun"], - }, - False, - id="cli-non-kit-overrides-config", - ), pytest.param({"_video_enabled": True}, True, id="video"), pytest.param({"_livestream": 1}, True, id="livestream"), pytest.param({"_xr": True}, True, id="xr"), @@ -147,17 +148,7 @@ def test_xr_pins_the_renderer_only_for_a_cuda_device( def test_spectator_view_follows_visual_output_intent(launcher_state, expected_enabled, monkeypatch): """Enable all-partitions spectator mode only for visual output paths.""" monkeypatch.setattr(sys, "argv", ["script.py"]) - launcher = AppLauncher.__new__(AppLauncher) - launcher._cli_visualizer_explicit = False - launcher._cli_visualizer_types = [] - launcher._cfg_has_kit_visualizer = False - launcher._render_viewport = False - launcher._video_enabled = False - launcher._livestream = 0 - launcher._xr = False - launcher.device = "cpu" - for name, value in launcher_state.items(): - setattr(launcher, name, value) + launcher = _launcher_after_output_resolution(device="cpu", **launcher_state) launcher._resolve_kit_args({}) @@ -168,9 +159,8 @@ def test_spectator_view_follows_visual_output_intent(launcher_state, expected_en def test_explicit_spectator_setting_overrides_visualizer_default(monkeypatch): """Preserve an explicit Kit setting when a Kit visualizer is requested.""" monkeypatch.setattr(sys, "argv", ["script.py"]) - launcher = AppLauncher.__new__(AppLauncher) - launcher._cli_visualizer_explicit = True - launcher._cli_visualizer_types = ["kit"] + launcher = KitLauncher.__new__(KitLauncher) + launcher._kit_visualizer = True launcher._xr = False launcher.device = "cpu" explicit_arg = f"--{ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING}=false" @@ -209,22 +199,23 @@ def test_explicit_spectator_setting_overrides_visualizer_default(monkeypatch): ) def test_fuse_kit_args(argv: list[str], expected: list[str]): """Fuse only ``--kit_args`` pairs whose value argparse would mistake for an option.""" - assert AppLauncher._fuse_kit_args(argv) == expected + assert sim_launcher.fuse_kit_args(argv) == expected def test_add_app_launcher_args_registers_every_launcher_option(): - """Every launcher config key except the removed render flags is a command-line option.""" + """Every launcher option is a command-line option.""" parser = argparse.ArgumentParser() - AppLauncher.add_app_launcher_args(parser) + add_launcher_args(parser) - for name in AppLauncher._APPLAUNCHER_CFG_INFO.keys() - {"headless", "enable_cameras"}: + names = ("livestream", "xr", "device", "visualizer", "experience", "deterministic", "kit_args", "max_visible_envs") + for name in names: assert parser._option_string_actions[f"--{name}"] def test_visualizer_alias_parsing(): """Test that --viz alias maps to visualizer values, resolving the deprecated ``newton`` name.""" parser = argparse.ArgumentParser() - AppLauncher.add_app_launcher_args(parser) + add_launcher_args(parser) with pytest.warns(DeprecationWarning, match="--viz 'newton' is deprecated"): args = parser.parse_args(["--viz", "kit,newton"]) @@ -232,11 +223,11 @@ def test_visualizer_alias_parsing(): assert args.visualizer_explicit is True -@pytest.mark.parametrize("deprecated_arg", ["--headless", "--enable_cameras"]) +@pytest.mark.parametrize("deprecated_arg", ["--headless", "--enable_cameras", "--cpu"]) def test_deprecated_render_flags_are_rejected(deprecated_arg: str): """Test that removed render flags are rejected by the parser.""" parser = argparse.ArgumentParser() - AppLauncher.add_app_launcher_args(parser) + add_launcher_args(parser) with pytest.raises(SystemExit): parser.parse_args([deprecated_arg]) @@ -254,7 +245,7 @@ def test_help_on_parser_with_required_positionals(monkeypatch: pytest.MonkeyPatc parser.add_argument("input") parser.add_argument("output") - AppLauncher.add_app_launcher_args(parser) + add_launcher_args(parser) # the probe's own usage line must not leak to stderr ahead of the real help output assert capsys.readouterr().err == "" @@ -264,13 +255,13 @@ def test_help_on_parser_with_required_positionals(monkeypatch: pytest.MonkeyPatc with pytest.raises(SystemExit) as excinfo: parser.parse_args(["--help"]) assert excinfo.value.code == 0 - assert "app_launcher arguments" in capsys.readouterr().out + assert "launcher arguments" in capsys.readouterr().out @pytest.mark.parametrize("value", ["none", "None"]) def test_visualizer_none_parsing(value: str): parser = argparse.ArgumentParser() - AppLauncher.add_app_launcher_args(parser) + add_launcher_args(parser) args = parser.parse_args(["--viz", value]) assert args.visualizer is None assert args.visualizer_explicit is True @@ -294,12 +285,13 @@ def test_livestream_request_resolves_headless_livestream_launch( monkeypatch.delenv("XR", raising=False) if isinstance(launcher_args, list): parser = argparse.ArgumentParser() - AppLauncher.add_app_launcher_args(parser) + add_launcher_args(parser) launcher_args = vars(parser.parse_args(launcher_args)) launcher_args["device"] = "cpu" - launcher = AppLauncher.__new__(AppLauncher) + launcher = KitLauncher.__new__(KitLauncher) monkeypatch.setattr(launcher, "_resolve_experience_file", lambda _launcher_args: None) + _normalize_launcher_args(launcher_args) launcher._config_resolution(launcher_args) assert launcher._livestream == 1 @@ -311,15 +303,14 @@ def _resolve_kit_args( monkeypatch: pytest.MonkeyPatch, launcher_args: dict, argv: list[str] | None = None ) -> tuple[list[str], list[str]]: monkeypatch.setattr(sys, "argv", ["pytest", *(argv or [])]) - launcher = AppLauncher.__new__(AppLauncher) - launcher._xr = False + launcher = _launcher_after_output_resolution() launcher._resolve_kit_args(launcher_args) return sys.argv[1:], launcher._kit_args @pytest.mark.parametrize("env_value, expected", [("0", "false"), ("1", "true")]) def test_fabric_gpu_interop_env_adds_override(monkeypatch: pytest.MonkeyPatch, env_value: str, expected: str): - monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, env_value) + monkeypatch.setenv(kit_launcher_module._FABRIC_GPU_INTEROP_ENV, env_value) kit_args, _ = _resolve_kit_args(monkeypatch, {}) @@ -330,14 +321,14 @@ def test_fabric_gpu_interop_env_adds_override(monkeypatch: pytest.MonkeyPatch, e def test_fabric_gpu_interop_env_rejects_invalid_value(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "false") + monkeypatch.setenv(kit_launcher_module._FABRIC_GPU_INTEROP_ENV, "false") with pytest.raises(ValueError, match="Expected: 0 or 1"): _resolve_kit_args(monkeypatch, {}) def test_explicit_kit_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "0") + monkeypatch.setenv(kit_launcher_module._FABRIC_GPU_INTEROP_ENV, "0") explicit_arg = "--/physics/fabricUseGPUInterop=true" kit_args, resolved_args = _resolve_kit_args(monkeypatch, {}, [explicit_arg]) @@ -350,7 +341,7 @@ def test_explicit_kit_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): def test_explicit_kit_args_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "0") + monkeypatch.setenv(kit_launcher_module._FABRIC_GPU_INTEROP_ENV, "0") explicit_arg = "--/physics/fabricUseGPUInterop=true" kit_args, resolved_args = _resolve_kit_args(monkeypatch, {"kit_args": explicit_arg}) @@ -393,16 +384,24 @@ def test_make_physics_cfg_builds_core_vbd(): def test_livestream_injects_kit_visualizer_when_missing(): args = argparse.Namespace(livestream=2, visualizer=None, visualizer_explicit=False) - _ensure_livestream_kit_visualizer(args) + _normalize_launcher_args(args) assert args.visualizer == ["kit"] + assert args.visualizer_explicit is True def test_livestream_rejects_disabled_visualizers(): args = argparse.Namespace(livestream=2, visualizer=None, visualizer_explicit=True) with pytest.raises(ValueError, match="Livestreaming requires the Kit visualizer"): - _ensure_livestream_kit_visualizer(args) + _normalize_launcher_args(args) + + +def test_livestream_rejects_invalid_environment_value(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("LIVESTREAM", "3") + + with pytest.raises(ValueError, match="Invalid livestream mode: 3"): + _normalize_launcher_args({}) def test_explicit_experience_requires_isaac_sim_runtime(): @@ -410,11 +409,10 @@ def test_explicit_experience_requires_isaac_sim_runtime(): scan = Scan( resolved_physics_cfg=None, effective_cfg=object(), - visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, + visualizer_intent={"has_kit_visualizer": False}, has_ovrtx=False, has_kit_camera=False, has_kit_physics=False, - has_kitless_physics=True, has_ovphysx_physics=False, needs_kit=False, ) @@ -423,24 +421,44 @@ def test_explicit_experience_requires_isaac_sim_runtime(): assert _get_kit_runtime_sources(scan, args) +def _resolve_headless_for_case( + monkeypatch: pytest.MonkeyPatch, launcher_args: dict, cfg_has_kit: bool = False, headless_env: int = 0 +) -> tuple[bool, dict]: + """Resolve headless mode the way ``launch_simulation`` and the Kit launcher do together, without Kit.""" + monkeypatch.setenv("HEADLESS", str(headless_env)) + # XR and LIVESTREAM are read from the environment too, and are routinely exported by people + # working on these features -- pin them so the parametrization is what decides. + monkeypatch.delenv("XR", raising=False) + monkeypatch.delenv("LIVESTREAM", raising=False) + _normalize_launcher_args(launcher_args) + config_scan = SimpleNamespace(visualizer_intent={"has_kit_visualizer": cfg_has_kit}) + launcher_args["kit_visualizer"] = sim_launcher._has_kit_visualizer(config_scan, launcher_args) + launcher = KitLauncher.__new__(KitLauncher) + launcher._kit_visualizer = launcher_args["kit_visualizer"] + launcher._livestream = launcher_args["livestream"] + launcher._resolve_xr_settings(launcher_args) + launcher._resolve_headless_settings(launcher_args) + return launcher._headless, launcher_args + + _XR_KIT = {"xr": True, "visualizer": ["kit"], "visualizer_explicit": True} @pytest.mark.parametrize( - "launcher_args, headless_env, livestream, expected_headless, expected_has_window", + "launcher_args, headless_env, livestream, expected_headless", [ # XR with no CLI visualizer: headless even though the task config asks for a window. - ({"xr": True}, 0, 0, True, False), + ({"xr": True}, 0, 0, True), # An explicit '--viz kit' is the only way to get a viewport alongside XR. - (_XR_KIT, 0, 0, False, True), - ({"xr": True, "visualizer": ["none"], "visualizer_explicit": True}, 0, 0, True, False), + (_XR_KIT, 0, 0, False), + ({"xr": True, "visualizer": ["none"], "visualizer_explicit": True}, 0, 0, True), # ...but an explicit '--viz kit' cannot override HEADLESS=1. - (_XR_KIT, 1, 0, True, False), - # Livestreaming forces headless yet still presents a window to the remote client. - (_XR_KIT, 0, 1, True, True), - ({}, 0, 1, True, True), + (_XR_KIT, 1, 0, True), + # Livestreaming forces headless. + (_XR_KIT, 0, 1, True), + ({}, 0, 1, True), # Without XR or livestream the task config still decides. - ({}, 0, 0, False, True), + ({}, 0, 0, False), ], ) def test_xr_without_explicit_windowed_visualizer_forces_headless( @@ -448,7 +466,6 @@ def test_xr_without_explicit_windowed_visualizer_forces_headless( headless_env: int, livestream: int, expected_headless: bool, - expected_has_window: bool, monkeypatch: pytest.MonkeyPatch, ): """Test that enabling XR runs headless unless a windowed visualizer is explicitly requested. @@ -458,55 +475,33 @@ def test_xr_without_explicit_windowed_visualizer_forces_headless( explicitly requested. Resolution is exercised directly to avoid launching Isaac Sim; what the resolved state then publishes is asserted by :func:`test_load_extensions_publishes_has_gui_setting`. - - ``has_window`` is asserted alongside because it is deliberately *not* the negation of - headless: livestreaming is headless but windowed. """ - monkeypatch.setenv("HEADLESS", str(headless_env)) - # XR is read from the environment too, and is routinely exported by people working on this - # feature -- pin it so the parametrization is what decides. - monkeypatch.delenv("XR", raising=False) - launcher = AppLauncher.__new__(AppLauncher) - launcher._livestream = livestream - args = { - "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, - **launcher_args, - } + args = {**launcher_args, "livestream": livestream} - launcher._resolve_visualizer_settings(args) - launcher._resolve_xr_settings(args) - launcher._resolve_headless_settings(args, livestream_arg=-1, livestream_env=0) + headless, _ = _resolve_headless_for_case(monkeypatch, args, cfg_has_kit=True, headless_env=headless_env) - assert launcher._headless is expected_headless - assert launcher.has_window is expected_has_window + assert headless is expected_headless def test_launch_simulation_preserves_failure_exit_code(monkeypatch: pytest.MonkeyPatch): close_args = {} - class _FakeApp: - def close(self, *, exit_code: int = 0) -> None: + class _FakeKitLauncher(SimulationLauncher): + def close(self, exit_code: int = 0) -> None: close_args["exit_code"] = exit_code - class _FakeAppLauncher: - def __init__(self, _launcher_args): - self.app = _FakeApp() - scan = sim_launcher.Scan( resolved_physics_cfg=None, effective_cfg=object(), - visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, + visualizer_intent={"has_kit_visualizer": False}, has_ovrtx=False, has_kit_camera=False, has_kit_physics=True, - has_kitless_physics=False, has_ovphysx_physics=False, needs_kit=True, ) monkeypatch.setattr(sim_launcher, "scan", lambda cfg, physics: scan) - monkeypatch.setattr(sim_launcher, "_ensure_isaac_sim_available", lambda: None) - monkeypatch.setattr(app_module, "AppLauncher", _FakeAppLauncher) - monkeypatch.setattr(utils_module, "has_kit", lambda: False) + monkeypatch.setattr(app_module, "KitLauncher", _FakeKitLauncher) with pytest.raises(RuntimeError, match="sentinel"): with sim_launcher.launch_simulation(object(), argparse.Namespace()): @@ -519,23 +514,17 @@ def test_launch_simulation_auto_enables_kit_camera_without_launcher_args(monkeyp monkeypatch.delenv("LIVESTREAM", raising=False) received_args = {} - class _FakeApp: - def close(self) -> None: - pass - - class _FakeAppLauncher: + class _FakeKitLauncher(SimulationLauncher): def __init__(self, launcher_args): received_args.update(launcher_args) - self.app = _FakeApp() scan = sim_launcher.Scan( resolved_physics_cfg=None, effective_cfg=object(), - visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, + visualizer_intent={"has_kit_visualizer": False}, has_ovrtx=False, has_kit_camera=True, has_kit_physics=False, - has_kitless_physics=False, has_ovphysx_physics=False, needs_kit=True, ) @@ -545,9 +534,7 @@ def _scan(_cfg, launcher_args): return scan monkeypatch.setattr(sim_launcher, "scan", _scan) - monkeypatch.setattr(sim_launcher, "_ensure_isaac_sim_available", lambda: None) - monkeypatch.setattr(app_module, "AppLauncher", _FakeAppLauncher) - monkeypatch.setattr(utils_module, "has_kit", lambda: False) + monkeypatch.setattr(app_module, "KitLauncher", _FakeKitLauncher) with sim_launcher.launch_simulation(object()): pass @@ -558,8 +545,8 @@ def _scan(_cfg, launcher_args): def test_deferred_cuda_device_synchronizes_torch_and_warp(monkeypatch: pytest.MonkeyPatch): """The post-Kit device hook must synchronize both CUDA runtimes.""" devices = [] - monkeypatch.setattr(app_launcher_module, "set_cuda_device", devices.append) - launcher = AppLauncher.__new__(AppLauncher) + monkeypatch.setattr(kit_launcher_module, "set_cuda_device", devices.append) + launcher = KitLauncher.__new__(KitLauncher) launcher._deferred_cuda_device_id = 2 launcher._set_deferred_cuda_device() @@ -577,7 +564,7 @@ def test_preloaded_torch_cuda_is_initialized_before_kit(monkeypatch: pytest.Monk ) ) monkeypatch.setitem(sys.modules, "torch", torch) - launcher = AppLauncher.__new__(AppLauncher) + launcher = KitLauncher.__new__(KitLauncher) launcher._deferred_cuda_device_id = 0 launcher._initialize_preloaded_torch_cuda() @@ -588,7 +575,7 @@ def test_preloaded_torch_cuda_is_initialized_before_kit(monkeypatch: pytest.Monk def test_cuda_initialization_remains_deferred_when_torch_is_not_loaded(monkeypatch: pytest.MonkeyPatch): """Do not import PyTorch solely to initialize CUDA before Kit.""" monkeypatch.delitem(sys.modules, "torch", raising=False) - launcher = AppLauncher.__new__(AppLauncher) + launcher = KitLauncher.__new__(KitLauncher) launcher._deferred_cuda_device_id = 0 launcher._initialize_preloaded_torch_cuda() @@ -597,12 +584,12 @@ def test_cuda_initialization_remains_deferred_when_torch_is_not_loaded(monkeypat def test_limit_cpu_threads_forwarded_to_simulation_app(monkeypatch: pytest.MonkeyPatch): - """A SimulationApp thread limit must survive AppLauncher config resolution.""" + """A SimulationApp thread limit must survive KitLauncher config resolution.""" monkeypatch.setenv("HEADLESS", "0") monkeypatch.setenv("LIVESTREAM", "0") monkeypatch.setenv("XR", "0") - launcher = AppLauncher.__new__(AppLauncher) + launcher = KitLauncher.__new__(KitLauncher) monkeypatch.setattr(launcher, "_resolve_experience_file", lambda _launcher_args: None) launcher._config_resolution({"headless": True, "device": "cpu", "limit_cpu_threads": 1}) @@ -627,7 +614,7 @@ def set_bool(self, path: str, value: bool) -> None: @pytest.mark.parametrize("deterministic", [True, False]) def test_load_extensions_publishes_deterministic_setting(monkeypatch: pytest.MonkeyPatch, deterministic: bool): """Publish ``/isaaclab/render/deterministic`` from ``_load_extensions``.""" - launcher = AppLauncher.__new__(AppLauncher) + launcher = KitLauncher.__new__(KitLauncher) launcher._deterministic_rendering = deterministic launcher._python_logging_level = logging.ERROR launcher._headless = True @@ -637,11 +624,11 @@ def test_load_extensions_publishes_deterministic_setting(monkeypatch: pytest.Mon launcher._render_viewport = False launcher._xr = False launcher._video_enabled = False + launcher._anim_recording = None settings = _DummySettings() - monkeypatch.setattr(app_launcher_module, "initialize_carb_settings", lambda: None) - monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) - monkeypatch.setattr(app_launcher_module, "apply_python_logging_level", lambda _level: None) + monkeypatch.setattr(kit_launcher_module, "get_settings_manager", lambda: settings) + monkeypatch.setattr(kit_launcher_module, "apply_python_logging_level", lambda _level: None) launcher._load_extensions() @@ -674,7 +661,7 @@ def test_load_extensions_publishes_has_gui_setting( Asserted at the publication site rather than by recomputing the expression, so that changing what is published fails here instead of silently agreeing with a restated formula. """ - launcher = AppLauncher.__new__(AppLauncher) + launcher = KitLauncher.__new__(KitLauncher) launcher._deterministic_rendering = False launcher._python_logging_level = logging.ERROR launcher._headless = headless @@ -684,11 +671,11 @@ def test_load_extensions_publishes_has_gui_setting( launcher._render_viewport = False launcher._xr = xr launcher._video_enabled = False + launcher._anim_recording = None settings = _DummySettings() - monkeypatch.setattr(app_launcher_module, "initialize_carb_settings", lambda: None) - monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) - monkeypatch.setattr(app_launcher_module, "apply_python_logging_level", lambda _level: None) + monkeypatch.setattr(kit_launcher_module, "get_settings_manager", lambda: settings) + monkeypatch.setattr(kit_launcher_module, "apply_python_logging_level", lambda _level: None) launcher._load_extensions() @@ -696,12 +683,11 @@ def test_load_extensions_publishes_has_gui_setting( assert settings.values["/isaaclab/xr/auto_start"] is expected_xr_auto_start -def test_set_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): +def test_sync_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): settings = _DummySettings() - monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) + monkeypatch.setattr(sim_launcher, "get_settings_manager", lambda: settings) - launcher = AppLauncher.__new__(AppLauncher) - launcher._set_visualizer_settings({"visualizer": ["viser", "rerun"], "max_visible_envs": 0}) + sim_launcher._sync_visualizer_cli_settings({"visualizer": ["viser", "rerun"], "max_visible_envs": 0}) assert settings.values == { "/isaaclab/visualizer/types": "viser rerun", @@ -711,45 +697,31 @@ def test_set_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): } -def test_set_visualizer_settings_rejects_negative_max_visible_envs( +def test_sync_visualizer_settings_rejects_negative_max_visible_envs( monkeypatch: pytest.MonkeyPatch, ): def _unexpected_settings_manager(): raise AssertionError("settings manager should not be queried for invalid values") - monkeypatch.setattr(app_launcher_module, "get_settings_manager", _unexpected_settings_manager) + monkeypatch.setattr(sim_launcher, "get_settings_manager", _unexpected_settings_manager) - launcher = AppLauncher.__new__(AppLauncher) with pytest.raises(ValueError, match="Invalid value for --max_visible_envs: -5"): - launcher._set_visualizer_settings({"visualizer": ["viser"], "max_visible_envs": -5}) - - -def test_set_visualizer_settings_suppresses_settings_manager_errors(monkeypatch: pytest.MonkeyPatch): - def _raise_settings_error(): - raise RuntimeError("settings unavailable") - - monkeypatch.setattr(app_launcher_module, "get_settings_manager", _raise_settings_error) - - launcher = AppLauncher.__new__(AppLauncher) - launcher._set_visualizer_settings({"visualizer": ["viser"], "max_visible_envs": 3}) + sim_launcher._sync_visualizer_cli_settings({"visualizer": ["viser"], "max_visible_envs": -5}) def test_parse_visualizer_csv_rejects_spaces_between_entries(): with pytest.raises(argparse.ArgumentTypeError, match="spaces are not allowed"): - app_launcher_module.AppLauncher._parse_visualizer_csv("kit, newton_gl") + sim_launcher._parse_visualizer_csv("kit, newton_gl") -def test_resolve_visualizer_settings_rejects_none_with_others(): - launcher = AppLauncher.__new__(AppLauncher) +def test_normalize_visualizers_rejects_none_with_others(): with pytest.raises(ValueError, match="'none' cannot be combined"): - launcher._resolve_visualizer_settings( - {"visualizer": ["none", "kit"], "visualizer_explicit": True}, - ) + _normalize_launcher_args({"visualizer": ["none", "kit"], "visualizer_explicit": True}) def test_visualizer_csv_does_not_swallow_hydra_overrides(): parser = argparse.ArgumentParser(add_help=False) - app_launcher_module.AppLauncher.add_app_launcher_args(parser) + sim_launcher.add_launcher_args(parser) args, hydra_args = parser.parse_known_args( ["--visualizer", "kit,newton_gl,rerun,viser", "presets=newton_mjwarp", "env.episode_length=10"] @@ -759,39 +731,20 @@ def test_visualizer_csv_does_not_swallow_hydra_overrides(): assert hydra_args == ["presets=newton_mjwarp", "env.episode_length=10"] -def _resolve_headless_for_case(monkeypatch: pytest.MonkeyPatch, launcher_args: dict) -> tuple[bool, AppLauncher]: - monkeypatch.setenv("HEADLESS", "0") - launcher = AppLauncher.__new__(AppLauncher) - launcher._livestream = 0 - launcher._resolve_visualizer_settings(launcher_args) - launcher._resolve_headless_settings(launcher_args, livestream_arg=-1, livestream_env=0) - return launcher._headless, launcher - - def test_matrix_cli_kit_newton_gl_with_custom_kit_cfg_intent_non_headless(monkeypatch: pytest.MonkeyPatch): - headless, launcher = _resolve_headless_for_case( - monkeypatch, - { - "visualizer": ["kit", "newton_gl"], - "visualizer_explicit": True, - "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, - }, + headless, args = _resolve_headless_for_case( + monkeypatch, {"visualizer": ["kit", "newton_gl"], "visualizer_explicit": True}, cfg_has_kit=True ) assert headless is False - assert launcher._cli_visualizer_types == ["kit", "newton_gl"] + assert args["visualizer"] == ["kit", "newton_gl"] def test_matrix_cli_rerun_with_custom_kit_cfg_intent_headless(monkeypatch: pytest.MonkeyPatch): - headless, launcher = _resolve_headless_for_case( - monkeypatch, - { - "visualizer": ["rerun"], - "visualizer_explicit": True, - "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, - }, + headless, args = _resolve_headless_for_case( + monkeypatch, {"visualizer": ["rerun"], "visualizer_explicit": True}, cfg_has_kit=True ) assert headless is True - assert launcher._cli_visualizer_types == ["rerun"] + assert args["visualizer"] == ["rerun"] def test_matrix_empty_dict_resolves_headless(monkeypatch: pytest.MonkeyPatch): @@ -802,61 +755,40 @@ def test_matrix_empty_dict_resolves_headless(monkeypatch: pytest.MonkeyPatch): def test_matrix_viz_kit_dict_resolves_windowed(monkeypatch: pytest.MonkeyPatch): # a Kit viewport only exists when the launcher is told to create it - headless, launcher = _resolve_headless_for_case(monkeypatch, {"visualizer": ["kit"]}) + headless, args = _resolve_headless_for_case(monkeypatch, {"visualizer": ["kit"]}) assert headless is False - assert launcher._cli_visualizer_types == ["kit"] + assert args["visualizer"] == ["kit"] @pytest.mark.parametrize("visualizer", [None, ["none"]]) def test_matrix_viz_none_disables_all_and_headless(monkeypatch: pytest.MonkeyPatch, visualizer): - headless, launcher = _resolve_headless_for_case( - monkeypatch, - { - "visualizer": visualizer, - "visualizer_explicit": True, - "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, - }, + headless, args = _resolve_headless_for_case( + monkeypatch, {"visualizer": visualizer, "visualizer_explicit": True}, cfg_has_kit=True ) assert headless is True - assert launcher._cli_visualizer_disable_all is True - assert launcher._cli_visualizer_types == [] + assert args["visualizer_disable_all"] is True + assert args["visualizer"] is None def test_matrix_headless_flag_deprecated_takes_precedence(monkeypatch: pytest.MonkeyPatch): - headless, launcher = _resolve_headless_for_case( - monkeypatch, - { - "headless": True, - "headless_explicit": True, - "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, - }, + headless, args = _resolve_headless_for_case( + monkeypatch, {"headless": True, "headless_explicit": True}, cfg_has_kit=True ) assert headless is True - assert launcher._cli_visualizer_types == [] + assert args["visualizer"] is None def test_no_cli_and_non_kit_cfg_visualizers_defaults_headless(monkeypatch: pytest.MonkeyPatch): - headless, _ = _resolve_headless_for_case( - monkeypatch, - {"visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": False}}, - ) + headless, _ = _resolve_headless_for_case(monkeypatch, {}, cfg_has_kit=False) assert headless is True -def test_invalid_visualizer_intent_rejected(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("HEADLESS", "0") - launcher = AppLauncher.__new__(AppLauncher) - with pytest.raises(ValueError, match="visualizer_intent"): - launcher._resolve_visualizer_settings({"visualizer_intent": {"has_any_visualizers": "yes"}}) - - def _new_launcher_for_experience_check(): - launcher = AppLauncher.__new__(AppLauncher) + launcher = KitLauncher.__new__(KitLauncher) launcher._enable_cameras = False launcher._headless = False launcher._xr = False launcher._deterministic_rendering = False - launcher.is_isaac_sim_version_5 = lambda: False return launcher @@ -886,30 +818,29 @@ def test_allows_isaacsim_full_streaming_experience_when_livestream_disabled(tmp_ def test_constructor_reports_missing_isaac_sim(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(app_launcher_module, "SimulationApp", None) + monkeypatch.setattr(kit_launcher_module, "SimulationApp", None) - with pytest.raises(ImportError, match="requires the full Isaac Sim runtime"): - AppLauncher() + monkeypatch.setattr(utils_module, "has_kit", lambda: False) + with pytest.raises(SystemExit): + KitLauncher() def test_is_available_reflects_simulation_app_presence(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(app_launcher_module, "SimulationApp", None) - assert AppLauncher.is_available() is False + monkeypatch.setattr(kit_launcher_module, "SimulationApp", None) + assert KitLauncher.is_available() is False - monkeypatch.setattr(app_launcher_module, "SimulationApp", object()) - assert AppLauncher.is_available() is True + monkeypatch.setattr(kit_launcher_module, "SimulationApp", object()) + assert KitLauncher.is_available() is True -def test_has_gui_reads_published_setting(): - from isaaclab.app.settings_manager import get_settings_manager +def test_close_pumps_the_app_before_closing(): + """Callbacks queued by the closing simulation run before Kit shuts down.""" + calls = [] + launcher = KitLauncher.__new__(KitLauncher) + launcher._app = SimpleNamespace( + update=lambda: calls.append("update"), close=lambda exit_code: calls.append(exit_code) + ) - settings = get_settings_manager() - original = settings.get("/isaaclab/has_gui") - try: - settings.set_bool("/isaaclab/has_gui", True) - assert AppLauncher.has_gui() is True + launcher.close(exit_code=3) - settings.set_bool("/isaaclab/has_gui", False) - assert AppLauncher.has_gui() is False - finally: - settings.set_bool("/isaaclab/has_gui", bool(original)) + assert calls == ["update", 3] diff --git a/source/isaaclab/test/app/test_app_launcher_exit_status.py b/source/isaaclab_physx/test/app/test_kit_launcher_exit_status.py similarity index 62% rename from source/isaaclab/test/app/test_app_launcher_exit_status.py rename to source/isaaclab_physx/test/app/test_kit_launcher_exit_status.py index e39c8d39bba7..3d96d337bfbf 100644 --- a/source/isaaclab/test/app/test_app_launcher_exit_status.py +++ b/source/isaaclab_physx/test/app/test_kit_launcher_exit_status.py @@ -3,9 +3,9 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Verify that a real AppLauncher process reports a truthful exit status. +"""Verify that a process running Kit reports a truthful exit status. -Each test launches a headless CPU :class:`~isaaclab.app.AppLauncher` in a child +Each test launches headless CPU Kit through :func:`~isaaclab.app.launch_simulation` in a child process and asserts on how the process ends: killed by a signal, or failed with an unhandled exception. Kit fast shutdown previously replaced both outcomes with a successful exit code 0. @@ -25,20 +25,20 @@ _READY_MARKER = "SIGTERM_TEST_READY" -def _idle_after_app_launcher_initialization() -> None: - from isaaclab.app import AppLauncher +def _idle_after_kit_launch() -> None: + from isaaclab.test.utils import launch_test_simulation - AppLauncher(headless=True, device="cpu") + launch_test_simulation(device="cpu") print(_READY_MARKER, flush=True) while True: time.sleep(0.5) -def _raise_after_app_launcher_initialization() -> None: - from isaaclab.app import AppLauncher +def _raise_after_kit_launch() -> None: + from isaaclab.test.utils import launch_test_simulation - AppLauncher(headless=True, device="cpu") - raise RuntimeError("intentional AppLauncher failure") + launch_test_simulation(device="cpu") + raise RuntimeError("intentional Kit process failure") def _wait_for_ready_marker(proc: subprocess.Popen, timeout: float) -> None: @@ -70,20 +70,12 @@ def _drain_stdout() -> None: reader.join(timeout=30) stderr = proc.stderr.read() reason = "did not become ready in time" if not settled else "exited before becoming ready" - pytest.fail( - f"AppLauncher child {reason}.\n--- child stdout ---\n{''.join(lines)}\n--- child stderr ---\n{stderr}" - ) + pytest.fail(f"Kit child {reason}.\n--- child stdout ---\n{''.join(lines)}\n--- child stderr ---\n{stderr}") @pytest.mark.integration def test_sigterm_reports_killed_by_signal_status(): - """Verify that SIGTERM tears the app down once and the process dies by SIGTERM. - - Regression test for two defects: a signal received during ``SimulationApp.close()`` - re-entered the abort handler and recursed until the stack overflowed, and the - graceful close terminated the process with exit code 0 under Kit fast shutdown, so - a SIGTERM-ed distributed worker was recorded as successful. - """ + """Verify that a SIGTERM-ed process dies by SIGTERM instead of exiting successfully.""" proc = subprocess.Popen( [sys.executable, __file__, _TRIGGER_WAIT_FOR_SIGTERM], stdout=subprocess.PIPE, @@ -100,17 +92,12 @@ def test_sigterm_reports_killed_by_signal_status(): if proc.poll() is None: proc.kill() - # the process must report the termination truthfully: exit status 128 + SIGTERM when - # fast shutdown exits inside close(exit_code=...), or death by SIGTERM when close() - # returns and the handler re-raises. Either way, never a successful exit. - assert proc.returncode in (128 + signal.SIGTERM, -signal.SIGTERM), f"returncode={proc.returncode}\n{stderr}" - # the teardown must not recurse through the abort handler - assert "_on_abort_signal" not in stderr + assert proc.returncode == -signal.SIGTERM, f"returncode={proc.returncode}\n{stderr}" @pytest.mark.integration def test_unhandled_exception_exits_with_failure(): - """Verify that AppLauncher shutdown does not replace an exception's exit status with zero.""" + """Verify that Kit shutdown does not replace an exception's exit status with zero.""" result = subprocess.run( [sys.executable, __file__, _TRIGGER_UNHANDLED_EXCEPTION], capture_output=True, @@ -119,13 +106,13 @@ def test_unhandled_exception_exits_with_failure(): ) assert result.returncode == 1, result.stdout + result.stderr - assert "RuntimeError: intentional AppLauncher failure" in result.stderr + assert "RuntimeError: intentional Kit process failure" in result.stderr if __name__ == "__main__": if _TRIGGER_WAIT_FOR_SIGTERM in sys.argv: # detach from pytest's process group so only the explicit SIGTERM reaches us os.setpgrp() - _idle_after_app_launcher_initialization() + _idle_after_kit_launch() elif _TRIGGER_UNHANDLED_EXCEPTION in sys.argv: - _raise_after_app_launcher_initialization() + _raise_after_kit_launch() diff --git a/source/isaaclab/test/app/test_non_headless_launch.py b/source/isaaclab_physx/test/app/test_non_headless_launch.py similarity index 83% rename from source/isaaclab/test/app/test_non_headless_launch.py rename to source/isaaclab_physx/test/app/test_non_headless_launch.py index 2187819b882c..a408fe6ced40 100644 --- a/source/isaaclab/test/app/test_non_headless_launch.py +++ b/source/isaaclab_physx/test/app/test_non_headless_launch.py @@ -7,19 +7,11 @@ This script checks that the ``isaaclab.python.kit`` experience launches and steps the simulation without hanging. """ -"""Launch Isaac Sim Simulator first.""" - - import pytest -from isaaclab.app import AppLauncher - -# launch omniverse app -app_launcher = AppLauncher(experience="isaaclab.python.kit", headless=True) -simulation_app = app_launcher.app - -"""Rest everything follows.""" +from isaaclab.test.utils import launch_test_simulation +launch_test_simulation(experience="isaaclab.python.kit") import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg @@ -45,7 +37,7 @@ def run_simulator( count = 0 # Simulate physics - while simulation_app.is_running() and count < 100: + while sim.is_headless_or_exist_active_visualizer() and count < 100: # perform step sim.step() count += 1 diff --git a/source/isaaclab_physx/test/sensors/test_pva.py b/source/isaaclab_physx/test/sensors/test_pva.py index 55055a2a974f..7407a686446d 100644 --- a/source/isaaclab_physx/test/sensors/test_pva.py +++ b/source/isaaclab_physx/test/sensors/test_pva.py @@ -136,7 +136,7 @@ class MySceneCfg(InteractiveSceneCfg): # A non-physics imu_link Xform is recreated in the test fixture (see setup_sim). pva_pendulum_imu_link: PvaCfg = PvaCfg( prim_path="{ENV_REGEX_NS}/pendulum/Geometry/world/link_1/imu_link", - debug_vis=not app_launcher._headless, + debug_vis=False, visualizer_cfg=replace(RED_ARROW_X_MARKER_CFG, prim_path="/Visuals/Acceleration/imu_link"), ) pva_pendulum_base: PvaCfg = PvaCfg( @@ -145,7 +145,7 @@ class MySceneCfg(InteractiveSceneCfg): pos=PEND_POS_OFFSET, rot=PEND_ROT_OFFSET, ), - debug_vis=not app_launcher._headless, + debug_vis=False, visualizer_cfg=replace(GREEN_ARROW_X_MARKER_CFG, prim_path="/Visuals/Acceleration/base"), ) diff --git a/source/isaaclab_physx/test/sim/test_spawn_materials.py b/source/isaaclab_physx/test/sim/test_spawn_materials.py index ca5e790504ef..d299b14e5cf1 100644 --- a/source/isaaclab_physx/test/sim/test_spawn_materials.py +++ b/source/isaaclab_physx/test/sim/test_spawn_materials.py @@ -31,7 +31,6 @@ def sim(): sim_utils.create_new_stage() dt = 0.1 sim = SimulationContext(SimulationCfg(dt=dt)) - sim_utils.update_stage() yield sim sim.stop() sim.clear_instance() diff --git a/source/isaaclab_physx/test/sim/test_spawn_meshes.py b/source/isaaclab_physx/test/sim/test_spawn_meshes.py index bcfbc7880ffb..454c2d322e26 100644 --- a/source/isaaclab_physx/test/sim/test_spawn_meshes.py +++ b/source/isaaclab_physx/test/sim/test_spawn_meshes.py @@ -32,8 +32,6 @@ def sim(): dt = 0.1 # Load kit helper sim = SimulationContext(SimulationCfg(dt=dt)) - # Wait for spawning - sim_utils.update_stage() yield sim # Cleanup sim._disable_app_control_on_stop_handle = True # prevent timeout diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py index 6102126ba8ca..ee158cd20258 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -41,7 +41,6 @@ @pytest.fixture(autouse=True) def test_setup_teardown(): sim_utils.create_new_stage() - sim_utils.update_stage() yield sim_utils.clear_stage() sim_utils.SimulationContext.clear_instance() diff --git a/source/isaaclab_rl/changelog.d/minimal-kit-exit-lifecycle.skip b/source/isaaclab_rl/changelog.d/minimal-kit-exit-lifecycle.skip new file mode 100644 index 000000000000..4e85c3ff61a6 --- /dev/null +++ b/source/isaaclab_rl/changelog.d/minimal-kit-exit-lifecycle.skip @@ -0,0 +1 @@ +Updated references from AppLauncher to the Kit launcher. diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/export_common.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/export_common.py index 8b033552c1dd..5e87aaba3a8a 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/export_common.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/export_common.py @@ -20,7 +20,8 @@ # or environment modules that compile decorated helpers at import time. torch.jit._state.disable() -from isaaclab.app import AppLauncher, launch_simulation + +from isaaclab.app import add_launcher_args, launch_simulation from isaaclab_tasks.utils import get_checkpoint_path, resolve_task_config, setup_preset_cli @@ -92,7 +93,7 @@ def add_common_export_args(parser: argparse.ArgumentParser, *, agent_default: st default=False, help="Disable LEAPP graph visualization during compile_graph().", ) - AppLauncher.add_app_launcher_args(parser) + add_launcher_args(parser) parser.add_argument("--limit_cpu_threads", type=int, default=argparse.SUPPRESS, help=argparse.SUPPRESS) diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/common.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/common.py index 96f39c1353b7..a22692838f53 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/common.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/common.py @@ -319,7 +319,7 @@ def apply_env_overrides(args_cli: argparse.Namespace, env_cfg: Any, *, apply_dev env_cfg.export_io_descriptors = True else: logger.warning("IO descriptors are only supported for manager-based RL environments; none are exported.") - # --deterministic is an AppLauncher flag, so it only reaches carb settings on its own. Record the + # --deterministic is a Kit launcher flag, so it only reaches carb settings on its own. Record the # request on the resolved physics config; each backend translates and validates it at startup. request_determinism(args_cli, env_cfg) @@ -493,7 +493,7 @@ def _renderer_name(env_cfg: Any) -> str | None: Only configs whose class declares ``renderer_cfg`` are read. Probing every attribute with :func:`getattr` instead would resolve lazily evaluated config values, notably the ``ResolvableString`` class handles, which imports Kit modules before - :class:`~isaaclab.app.AppLauncher` starts and breaks the Isaac Sim runtime. + :class:`~isaaclab_physx.app.KitLauncher` starts and breaks the Isaac Sim runtime. """ for container in (env_cfg, getattr(env_cfg, "scene", None)): for value in vars(container).values() if container is not None else (): diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/dispatch.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/dispatch.py index 3a74de72770c..36d882f66731 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/dispatch.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/dispatch.py @@ -128,14 +128,14 @@ def _normalize_argv(argv: list[str] | None) -> list[str]: """Return the command line to dispatch with space-separated Kit arguments fused. The backends parse this explicit list rather than ``sys.argv``, so the fusing that - :meth:`~isaaclab.app.AppLauncher.add_app_launcher_args` applies to ``sys.argv`` never reaches it. + :func:`~isaaclab.app.add_launcher_args` applies to ``sys.argv`` never reaches it. """ # imported here so that importing this module stays lightweight - from isaaclab.app import AppLauncher + from isaaclab.app.sim_launcher import fuse_kit_args if argv is None: argv = sys.argv[1:] - return AppLauncher._fuse_kit_args(argv) + return fuse_kit_args(argv) def _resolve_default_library(argv: list[str], backends: dict[str, str]) -> str | None: diff --git a/source/isaaclab_rl/test/export/test_sb3_leapp_export.py b/source/isaaclab_rl/test/export/test_sb3_leapp_export.py index 72f177e76121..c16ef3c940f0 100644 --- a/source/isaaclab_rl/test/export/test_sb3_leapp_export.py +++ b/source/isaaclab_rl/test/export/test_sb3_leapp_export.py @@ -31,12 +31,7 @@ def test_sb3_export_args_use_common_defaults(monkeypatch): export_module = _load_export_module() export_common = importlib.import_module("isaaclab_rl.entrypoints.backends.export_common") - class _AppLauncher: - @staticmethod - def add_app_launcher_args(parser): - return None - - monkeypatch.setattr(export_common, "AppLauncher", _AppLauncher) + monkeypatch.setattr(export_common, "add_launcher_args", lambda parser: None) monkeypatch.setattr( export_common, "setup_preset_cli", diff --git a/source/isaaclab_tasks/changelog.d/minimal-kit-exit-lifecycle.rst b/source/isaaclab_tasks/changelog.d/minimal-kit-exit-lifecycle.rst new file mode 100644 index 000000000000..1787a019c2da --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/minimal-kit-exit-lifecycle.rst @@ -0,0 +1,12 @@ +Added +^^^^^ + +* Added ``class_type`` to the environment configs that use a custom environment class, naming that class. + +Changed +^^^^^^^ + +* Removed the ``carb`` imports from the factory, AutoMate, deploy, and NIST tasks; factory and AutoMate set + gravity through the physics manager. +* Changed the stack task's ``randomize_visual_texture_material`` event to seed Replicator with ``env.cfg.seed`` + when set, since the environments' ``seed()`` no longer does. diff --git a/source/isaaclab_tasks/isaaclab_tasks/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/__init__.py index 19ec4b1f023f..d410e8f3eef4 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/__init__.py @@ -33,7 +33,7 @@ from .utils import import_packages -# Guard: AppLauncher._create_app() temporarily removes all "lab" modules from +# Guard: KitLauncher._create_app() temporarily removes all "lab" modules from # sys.modules while creating SimulationApp. If Kit re-imports this package # during that window, __init__ runs again and re-registers every gym env. # We stash a flag on builtins because it is never evicted from sys.modules. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env.py index 668090e2f62f..945eb89c29f2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env.py @@ -9,7 +9,6 @@ import torch import warp as wp -import isaaclab.sim as sim_utils from isaaclab.envs import DirectRLEnv from isaaclab.utils import index_fill_ from isaaclab.utils.assets import retrieve_file_path @@ -818,11 +817,8 @@ def randomize_held_initial_state(self, env_ids, pre_grasp): def randomize_initial_state(self, env_ids): """Randomize initial state and perform any episode-level randomization.""" - import carb - # Disable gravity. - physics_sim_view = sim_utils.SimulationContext.instance().physics_sim_view - physics_sim_view.set_gravity(carb.Float3(0.0, 0.0, 0.0)) + self.sim.physics_manager.set_gravity((0.0, 0.0, 0.0)) self.randomize_fixed_initial_state(env_ids) @@ -877,6 +873,4 @@ def randomize_initial_state(self, env_ids): # Set initial gains for the episode. self._set_gains(self.default_gains) - import carb - - physics_sim_view.set_gravity(carb.Float3(*self.cfg.sim.gravity)) + self.sim.physics_manager.set_gravity(tuple(self.cfg.sim.gravity)) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env.py index 86f478f884ae..cd6644a5912b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env.py @@ -10,7 +10,6 @@ import torch import warp as wp -import isaaclab.sim as sim_utils from isaaclab.envs import DirectRLEnv from isaaclab.utils import index_fill_ from isaaclab.utils.assets import retrieve_file_path @@ -689,11 +688,8 @@ def close_gripper(self, env_ids): def randomize_initial_state(self, env_ids): """Randomize initial state and perform any episode-level randomization.""" - import carb - # Disable gravity. - physics_sim_view = sim_utils.SimulationContext.instance().physics_sim_view - physics_sim_view.set_gravity(carb.Float3(0.0, 0.0, 0.0)) + self.sim.physics_manager.set_gravity((0.0, 0.0, 0.0)) self.randomize_fixed_initial_state(env_ids) @@ -732,9 +728,7 @@ def randomize_initial_state(self, env_ids): # Set initial gains for the episode. self._set_gains(self.default_gains) - import carb - - physics_sim_view.set_gravity(carb.Float3(*self.cfg.sim.gravity)) + self.sim.physics_manager.set_gravity(tuple(self.cfg.sim.gravity)) def _disassemble_plug_from_socket(self): """Lift plug from socket till disassembly and then randomize end-effector pose.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/deploy/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/deploy/mdp/rewards.py index c776168e5b48..70aff751defb 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/deploy/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/deploy/mdp/rewards.py @@ -7,6 +7,7 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING import torch @@ -21,6 +22,8 @@ from .events import randomize_gear_type +logger = logging.getLogger(__name__) + class keypoint_command_error(ManagerTermBase): """Compute keypoint distance between current and desired poses from command. @@ -465,9 +468,7 @@ def __call__( env.extras["log"]["ee_grasp_kp_error/weight_scale"] = weight_scale self._step_count += 1 - import carb - - carb.log_info( + logger.info( f"[ee_grasp_kp_error] step={self._step_count}" f" | mean_kp_error={mean_error_scalar:.5f}" f" | pct_active={pct_active:.3f}" @@ -555,9 +556,7 @@ def __call__( env.extras["log"]["ee_grasp_kp_error_exp/weight_scale"] = weight_scale self._step_count += 1 - import carb - - carb.log_info( + logger.info( f"[ee_grasp_kp_error_exp] step={self._step_count}" f" | mean_kp_error={mean_error_scalar:.5f}" f" | pct_active={pct_active:.3f}" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env.py index 068a497f6f46..5b92dd510d5e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env.py @@ -6,9 +6,6 @@ import numpy as np import torch -import carb - -import isaaclab.sim as sim_utils from isaaclab.envs import DirectRLEnv from isaaclab.utils import index_fill_ from isaaclab.utils import math as torch_utils @@ -596,8 +593,7 @@ def step_sim_no_action(self): def randomize_initial_state(self, env_ids): """Randomize initial state and perform any episode-level randomization.""" # Disable gravity. - physics_sim_view = sim_utils.SimulationContext.instance().physics_sim_view - physics_sim_view.set_gravity(carb.Float3(0.0, 0.0, 0.0)) + self.sim.physics_manager.set_gravity((0.0, 0.0, 0.0)) # (1.) Randomize fixed asset pose. fixed_pose = self._fixed_asset.data.default_root_pose.torch.clone()[env_ids] @@ -803,4 +799,4 @@ def randomize_initial_state(self, env_ids): self.task_prop_gains = self.default_gains self.task_deriv_gains = factory_utils.get_deriv_gains(self.default_gains) - physics_sim_view.set_gravity(carb.Float3(*self.cfg.sim.gravity)) + self.sim.physics_manager.set_gravity(tuple(self.cfg.sim.gravity)) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py index bfb0fdc2e2bf..1dc988351a67 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py @@ -541,6 +541,8 @@ class ResetDatasetCurriculumCfg: class FrankaPourResetDatasetEnvCfg(ManagerBasedRLEnvCfg): """Registered Franka Pour task using an externally generated reset dataset.""" + class_type: type | str = "{DIR}.pour_env:FrankaPourEnv" + scene: PourSceneCfg = PourSceneCfg(num_envs=2, env_spacing=2.5, replicate_physics=True) observations: ResetDatasetObservationsCfg = ResetDatasetObservationsCfg() actions: ActionsCfg = ActionsCfg() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env_cfg.py index 2787c2bb2641..8156013aee81 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env_cfg.py @@ -466,6 +466,8 @@ class CurriculumCfg: class MultitaskManipulationEnvCfg(ManagerBasedRLEnvCfg): """Manager-based heterogeneous manipulation training environment.""" + class_type: type | str = "{DIR}.multitask_env:MultitaskManipulationEnv" + scene: InteractiveSceneCfg = _make_scene_cfg() observations: ObservationsCfg = ObservationsCfg() actions: ActionsCfg = ActionsCfg() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/utils/mesh_ops.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/utils/mesh_ops.py index c520c20f9be5..109588587902 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/utils/mesh_ops.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/utils/mesh_ops.py @@ -278,8 +278,7 @@ def create_primitive_mesh(prim) -> trimesh.Trimesh: def prim_to_trimesh(prim, relative_to_world=False) -> trimesh.Trimesh: import trimesh - import omni - from pxr import UsdGeom + from pxr import Usd, UsdGeom if prim.GetTypeName() == "Mesh": mesh = UsdGeom.Mesh(prim) @@ -290,14 +289,14 @@ def prim_to_trimesh(prim, relative_to_world=False) -> trimesh.Trimesh: mesh_tm = create_primitive_mesh(prim) if relative_to_world: - tf = np.array(omni.usd.get_world_transform_matrix(prim)).T + tf = np.array(UsdGeom.Xformable(prim).ComputeLocalToWorldTransform(Usd.TimeCode.Default())).T mesh_tm.apply_transform(tf) return mesh_tm def prim_to_warp_mesh(prim, device, relative_to_world=False) -> wp.Mesh: - from pxr import UsdGeom + from pxr import Usd, UsdGeom if prim.GetTypeName() == "Mesh": mesh_prim = UsdGeom.Mesh(prim) @@ -309,9 +308,7 @@ def prim_to_warp_mesh(prim, device, relative_to_world=False) -> wp.Mesh: indices = mesh.faces.astype(np.int32) if relative_to_world: - import omni - - tf = np.array(omni.usd.get_world_transform_matrix(prim)).T + tf = np.array(UsdGeom.Xformable(prim).ComputeLocalToWorldTransform(Usd.TimeCode.Default())).T points = (points @ tf[:3, :3].T) + tf[:3, 3] wp_mesh = convert_to_warp_mesh(points, indices, device=device) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/stack_events.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/stack_events.py index f467119eeb0e..30369f1279d7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/stack_events.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/mdp/stack_events.py @@ -320,6 +320,9 @@ def randomize_visual_texture_material( # we import the module here since we may not always need the replicator import omni.replicator.core as rep + if env.cfg.seed is not None: + rep.set_global_seed(env.cfg.seed) + # check to make sure replicate_physics is set to False, else raise error # note: We add an explicit check here since texture randomization can happen outside of 'prestartup' mode # and the event manager doesn't check in that case. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/ur10_particle_push/ur10_particle_push_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/ur10_particle_push/ur10_particle_push_env_cfg.py index 0d06c194699f..9bb52587126e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/ur10_particle_push/ur10_particle_push_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/ur10_particle_push/ur10_particle_push_env_cfg.py @@ -586,6 +586,8 @@ class CurriculumCfg: class UR10ParticlePushEnvCfg(ManagerBasedRLEnvCfg): """Manager-based, relative-joint-control UR10 particle-pushing task.""" + class_type: type | str = "{DIR}.ur10_particle_push_env:UR10ParticlePushEnv" + decimation = 2 # One approach and sweep comfortably fits within this horizon. episode_length_s = 12.0 diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py b/source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py index 0df4272e97a6..bf0bed61cbb0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/preset_cli.py @@ -22,7 +22,7 @@ No argparse arguments are registered for the typed selectors -- their discoverability lives in the ``argument_group`` description, so the parsed Namespace gains no preset attributes and cannot shadow -:class:`~isaaclab.app.AppLauncher` SimulationApp config keys (``renderer`` +:class:`~isaaclab_physx.app.KitLauncher` SimulationApp config keys (``renderer`` notably). Typical script setup:: @@ -43,7 +43,7 @@ remaining = list_intersection(remaining, external_callback_function()) sys.argv = [sys.argv[0]] + remaining -``setup_preset_cli`` does NOT add AppLauncher flags itself -- callers add them +``setup_preset_cli`` does NOT add launcher flags itself -- callers add them explicitly via :func:`isaaclab.app.add_launcher_args` before calling. """ @@ -64,7 +64,7 @@ def setup_preset_cli( ) -> tuple[argparse.Namespace, list[str]]: """Register the preset-selection help description and parse argv. - Must be called *after* AppLauncher flags and script-specific arguments are + Must be called *after* launcher flags and script-specific arguments are registered on ``parser`` -- otherwise those unknown tokens land in ``parse_known_args``'s remainder. @@ -111,7 +111,7 @@ def setup_preset_cli( parser.formatter_class = _PresetHelpFormatter # Help-only group: no add_argument() calls means no preset attributes on - # the Namespace, so AppLauncher can't accidentally forward one (notably + # the Namespace, so the Kit launcher can't accidentally forward one (notably # ``renderer``) into SimulationApp config. parser.add_argument_group("preset selection", description=_DescriptionBuilder.build(actual_variants)) @@ -135,7 +135,7 @@ def enumerate_task_presets(task_name: str) -> dict[PresetTarget, list[str]] | No using the same logic that the CLI help-text renderer uses, so the returned view matches what ``--task= --help`` shows at the command line. - This function is safe to call after :class:`~isaaclab.app.AppLauncher` has + This function is safe to call after :class:`~isaaclab_physx.app.KitLauncher` has booted (i.e. inside a running Isaac Sim session). Args: @@ -291,7 +291,7 @@ def _enumerate_variants(task_name: str) -> dict[PresetTarget, set[str]]: """Load env_cfg for *task_name* and bucket its variants by target. Uses the same walker hydra's resolver runs so help and resolve see one - view of the cfg tree. The env_cfg load is safe before AppLauncher boots + view of the cfg tree. The env_cfg load is safe before the simulation runtime starts because ``test_env_cfg_no_forbidden_imports`` blocks Kit-only imports at the top level of cfg modules. Exceptions from the loader propagate verbatim -- they surface as the natural error, not a buried help string. diff --git a/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py b/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py index f8ab1836f334..5759653d7a5f 100644 --- a/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py +++ b/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py @@ -13,7 +13,7 @@ - Multi-node: WORLD_SIZE > local GPU count (local_rank still maps correctly) - JAX_LOCAL_RANK: added to local_rank for JAX distributed training - Non-distributed: no device override applied -- launch_simulation device propagation from AppLauncher +- launch_simulation device propagation from the Kit launcher No actual GPUs required — ``torch.cuda.device_count`` and the shared CUDA device-selection helper are mocked throughout. @@ -109,6 +109,8 @@ def test_normal_multi_gpu_rank3(self, mock_count, mock_set_device): sim_launcher._resolve_distributed_device(env_cfg, args) assert env_cfg.sim.device == "cuda:3" + # the Kit launcher reads the resolved device from the launcher args + assert args.device == "cuda:3" mock_set_device.assert_called_once_with("cuda:3") @patch.object(sim_launcher, "set_cuda_device") @@ -280,42 +282,32 @@ def test_multi_node_rank3_sees_4_gpus(self, mock_count, mock_set_device): # --------------------------------------------------------------------------- -# launch_simulation integration — verify device propagation from AppLauncher +# launch_simulation integration — verify device propagation from the Kit launcher # --------------------------------------------------------------------------- class TestLaunchSimulationDevicePropagation: - """Verify that launch_simulation propagates AppLauncher.device to env_cfg.""" + """Verify that launch_simulation propagates the Kit launcher's device to env_cfg.""" - def test_kit_path_propagates_applauncher_device(self, monkeypatch): - """When Kit is needed, AppLauncher.device should be written to env_cfg.sim.device.""" + def test_kit_path_propagates_launcher_device(self, monkeypatch): + """When Kit is needed, the Kit launcher's device should be written to env_cfg.sim.device.""" - class _FakeAppLauncher: - is_available = staticmethod(lambda: True) + class _FakeKitLauncher: + device = "cuda:3" # Simulate resolved device def __init__(self, launcher_args): - self.device = "cuda:3" # Simulate resolved device - self.app = types.SimpleNamespace(close=lambda: None) + pass + + def close(self, exit_code=0): + pass - # Mock has_kit to return False so AppLauncher gets created - mock_isaaclab_utils = types.ModuleType("isaaclab.utils") - mock_isaaclab_utils.has_kit = lambda: False - monkeypatch.setitem(sys.modules, "isaaclab.utils", mock_isaaclab_utils) monkeypatch.setitem( sys.modules, "isaaclab.utils.assets", types.SimpleNamespace(configure_storage_profile=lambda: None), ) - monkeypatch.setitem( - sys.modules, - "isaaclab.app", - types.SimpleNamespace(AppLauncher=_FakeAppLauncher), - ) - monkeypatch.setattr( - "importlib.util.find_spec", - lambda name: object() if name == "omni.kit" else None, - ) + monkeypatch.setitem(sys.modules, "isaaclab_physx.app", types.SimpleNamespace(KitLauncher=_FakeKitLauncher)) # Mock _resolve_distributed_device to avoid torch.cuda calls monkeypatch.setattr( sim_launcher, diff --git a/source/isaaclab_tasks/test/core/test_preset_cli.py b/source/isaaclab_tasks/test/core/test_preset_cli.py index 80c78bd3774e..cbacf7a85654 100644 --- a/source/isaaclab_tasks/test/core/test_preset_cli.py +++ b/source/isaaclab_tasks/test/core/test_preset_cli.py @@ -78,9 +78,9 @@ def test_setup_preset_cli_namespace_carries_no_preset_attributes(monkeypatch): """Preset tokens are never registered with argparse, so the parsed Namespace gains no ``physics`` / ``renderer`` / ``presets`` attribute. - This is the bug-class-level guarantee against AppLauncher's name-based - forwarding (``set(_SIM_APP_CFG_TYPES) & set(vars(args))``, - ``app_launcher.py:681``): an attribute that doesn't exist can't collide. + This is the bug-class-level guarantee against the Kit launcher's name-based + forwarding (``_SIM_APP_CONFIG_KEYS & launcher_args.keys()``, + ``kit_launcher.py``): an attribute that doesn't exist can't collide. """ monkeypatch.setattr( "sys.argv", @@ -91,7 +91,7 @@ def test_setup_preset_cli_namespace_carries_no_preset_attributes(monkeypatch): args, _ = setup_preset_cli(_make_parser()) for attr in ("physics", "renderer", "presets"): assert not hasattr(args, attr), ( - f"setup_preset_cli wrote ``args.{attr}`` to the namespace -- AppLauncher's name-based" + f"setup_preset_cli wrote ``args.{attr}`` to the namespace -- the Kit launcher's name-based" " forwarding can then push it into SimulationApp config. Drop the argparse registration" " for preset selectors and use Hydra-style tokens instead." ) diff --git a/source/isaaclab_tasks/test/core/test_preset_kit_decision.py b/source/isaaclab_tasks/test/core/test_preset_kit_decision.py index 2a3ae90befb2..5d9a125df4f4 100644 --- a/source/isaaclab_tasks/test/core/test_preset_kit_decision.py +++ b/source/isaaclab_tasks/test/core/test_preset_kit_decision.py @@ -17,7 +17,9 @@ import pytest from isaaclab_physx.physics import PhysxCfg +from isaaclab.envs import ManagerBasedEnvCfg from isaaclab.physics import PhysicsCfg, PhysxAutoCfg +from isaaclab.utils.string import string_to_callable import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils import resolve_task_config @@ -125,6 +127,17 @@ def test_registered_task_physx_presets_keep_auto_selection_explicit(): assert fields.get("isaacsim_physx") == fields["physx"], location +def test_registered_manager_based_configs_name_their_env_class(): + """A manager-based task's ``class_type`` constructs the env class its registration names.""" + + for task_id, task_spec in gym.registry.items(): + if not task_id.startswith(("Isaac-", "IsaacContrib-")) or "env_cfg_entry_point" not in task_spec.kwargs: + continue + env_cfg = load_cfg_from_registry(task_id, "env_cfg_entry_point") + if isinstance(env_cfg, ManagerBasedEnvCfg): + assert string_to_callable(str(env_cfg.class_type)) is string_to_callable(task_spec.entry_point), task_id + + def test_physx_and_isaacsim_physx_presets_conflict(): """``physx`` and ``isaacsim_physx`` are distinct choices even when both use PhysxCfg.""" with pytest.raises(ValueError, match="Conflicting global presets"): diff --git a/source/isaaclab_tasks/test/core/test_runtime_compatibility.py b/source/isaaclab_tasks/test/core/test_runtime_compatibility.py index 194c622b3fe9..17fb821d0e8f 100644 --- a/source/isaaclab_tasks/test/core/test_runtime_compatibility.py +++ b/source/isaaclab_tasks/test/core/test_runtime_compatibility.py @@ -14,6 +14,7 @@ import argparse import sys +import isaaclab_physx.app as physx_app import pytest from isaaclab_newton.physics import NewtonCfg from isaaclab_ov.physics import OvPhysxCfg @@ -21,9 +22,7 @@ from isaaclab_physx.physics import PhysxCfg from isaaclab_physx.renderers import IsaacRtxRendererCfg -import isaaclab.app.sim_launcher as sim_launcher_module -import isaaclab.utils as isaaclab_utils -from isaaclab.app import scan +from isaaclab.app import SimulationLauncher, scan from isaaclab.app.sim_launcher import _get_kit_runtime_sources, _validate_runtime, launch_simulation from isaaclab.physics import PhysxAutoCfg @@ -344,8 +343,7 @@ def test_livestream_rtx_injects_kit_before_auto_rtx_resolution(monkeypatch: pyte """Livestreaming should make ``presets=newton_mjwarp,rtx`` choose Isaac RTX.""" env_cfg = _resolve_with_presets("newton_mjwarp,rtx") launcher_args = argparse.Namespace(livestream=2, visualizer=None, visualizer_explicit=False) - monkeypatch.setattr(sim_launcher_module, "_ensure_isaac_sim_available", lambda: None) - monkeypatch.setattr(isaaclab_utils, "has_kit", lambda: True) + monkeypatch.setattr(physx_app, "KitLauncher", SimulationLauncher) with launch_simulation(env_cfg, launcher_args) as physics_cfg: assert type(physics_cfg).__name__ == "NewtonCfg" diff --git a/source/isaaclab_tasks/test/core/test_sim_launcher_visualizer_intent.py b/source/isaaclab_tasks/test/core/test_sim_launcher_visualizer_intent.py index b88de290532d..28a0d80d847d 100644 --- a/source/isaaclab_tasks/test/core/test_sim_launcher_visualizer_intent.py +++ b/source/isaaclab_tasks/test/core/test_sim_launcher_visualizer_intent.py @@ -11,7 +11,13 @@ import sys import types +import isaaclab_physx.app as physx_app +import pytest +from isaaclab_newton.physics import NewtonCfg +from isaaclab_ov.physics import OvPhysxCfg + import isaaclab.app.sim_launcher as sim_launcher +from isaaclab.app import SimulationLauncher, get_settings_manager def _force_kitless(monkeypatch): @@ -41,17 +47,19 @@ def __init__(self, sim_cfg): self.sim = sim_cfg -def test_launch_simulation_passes_visualizer_intent_to_applauncher(monkeypatch): - """Ensure canonical launcher path forwards visualizer intent upstream.""" +def test_launch_simulation_passes_kit_visualizer_to_kit_launcher(monkeypatch): + """Ensure canonical launcher path forwards the resolved Kit visualizer request.""" captured: dict[str, object] = {} - class _FakeAppLauncher: - is_available = staticmethod(lambda: True) + class _FakeKitLauncher: + device = None def __init__(self, launcher_args): captured["launcher_args"] = launcher_args captured["closed"] = False - self.app = types.SimpleNamespace(close=lambda: captured.update({"closed": True})) + + def close(self, exit_code=0): + captured["closed"] = True monkeypatch.setitem(sys.modules, "isaaclab.utils", types.SimpleNamespace(has_kit=lambda: False)) monkeypatch.setitem( @@ -59,8 +67,7 @@ def __init__(self, launcher_args): "isaaclab.utils.assets", types.SimpleNamespace(configure_storage_profile=lambda: None), ) - monkeypatch.setitem(sys.modules, "isaaclab.app", types.SimpleNamespace(AppLauncher=_FakeAppLauncher)) - monkeypatch.setattr("importlib.util.find_spec", lambda name: object() if name == "omni.kit" else None) + monkeypatch.setitem(sys.modules, "isaaclab_physx.app", types.SimpleNamespace(KitLauncher=_FakeKitLauncher)) env_cfg = _DummyEnvCfg(_DummySimCfg([_DummyVizCfg("kit"), _DummyVizCfg("newton")])) launcher_args = argparse.Namespace() @@ -70,29 +77,70 @@ def __init__(self, launcher_args): forwarded_args = captured["launcher_args"] assert isinstance(forwarded_args, argparse.Namespace) - assert getattr(forwarded_args, "visualizer_intent") == { - "has_any_visualizers": True, - "has_kit_visualizer": True, - "has_kit_streaming_view": False, - } + assert forwarded_args.kit_visualizer is True assert captured["closed"] is True +@pytest.fixture +def kit_launcher_args(monkeypatch) -> list: + """Record the launcher args of each Kit launch, without starting Kit.""" + launches = [] + + class _FakeKitLauncher(SimulationLauncher): + def __init__(self, launcher_args): + launches.append(launcher_args) + + monkeypatch.setattr(physx_app, "KitLauncher", _FakeKitLauncher) + return launches + + +def test_caller_visualizer_intent_requests_kit_visualizer(kit_launcher_args): + """A caller's ``visualizer_intent`` requests the Kit visualizer even without a visualizer config.""" + with sim_launcher.launch_simulation(None, {"require_kit": True, "visualizer_intent": {"has_kit_visualizer": True}}): + pass + + assert kit_launcher_args[0]["kit_visualizer"] is True + + +@pytest.mark.parametrize("physics_cfg", [NewtonCfg(), OvPhysxCfg()], ids=["newton", "ovphysx"]) +def test_cli_visualizer_selection_overrides_config_kit_visualizer(kit_launcher_args, physics_cfg): + """``--viz rerun`` drops a configured Kit visualizer, so the run stays kitless.""" + cfg = argparse.Namespace(physics=physics_cfg, visualizer_cfgs=[_DummyVizCfg("kit")]) + launcher_args = argparse.Namespace(visualizer=["rerun"], visualizer_explicit=True) + + with sim_launcher.launch_simulation(cfg, launcher_args): + pass + + assert kit_launcher_args == [] + assert launcher_args.kit_visualizer is False + + +@pytest.mark.parametrize("require_kit", [False, True], ids=["kitless", "kit"]) +def test_launch_simulation_writes_max_visible_envs_without_visualizer(kit_launcher_args, require_kit): + """``--max_visible_envs`` reaches the settings with or without Kit, even when no visualizer is selected.""" + settings = get_settings_manager() + settings.set_int("/isaaclab/visualizer/max_visible_envs", -1) + + with sim_launcher.launch_simulation(NewtonCfg(), {"require_kit": require_kit, "max_visible_envs": 3}): + pass + + assert len(kit_launcher_args) == int(require_kit) + assert settings.get("/isaaclab/visualizer/max_visible_envs") == 3 + settings.set_int("/isaaclab/visualizer/max_visible_envs", -1) + + def test_launch_simulation_kitless_viz_none_sets_disable_all(monkeypatch): """Kitless mode should persist explicit disable-all semantics for --viz none.""" captured = {"types": None, "explicit": None, "disable_all": None} - class _FakeAppLauncher: - is_available = staticmethod(lambda: True) - - @staticmethod - def sync_visualizer_cli_settings_to_carb(launcher_args: dict) -> None: - captured["types"] = " ".join(launcher_args["visualizer"]) if launcher_args.get("visualizer") else "" - captured["explicit"] = launcher_args["visualizer_explicit"] - captured["disable_all"] = launcher_args["visualizer_disable_all"] + def fake_sync(launcher_args: argparse.Namespace) -> None: + launcher_args = vars(launcher_args) + captured["types"] = " ".join(launcher_args["visualizer"]) if launcher_args.get("visualizer") else "" + captured["explicit"] = launcher_args["visualizer_explicit"] + captured["disable_all"] = launcher_args["visualizer_disable_all"] _force_kitless(monkeypatch) - monkeypatch.setitem(sys.modules, "isaaclab.app", types.SimpleNamespace(AppLauncher=_FakeAppLauncher)) + monkeypatch.setattr(sim_launcher, "_sync_visualizer_cli_settings", fake_sync) env_cfg = _DummyEnvCfg(_DummySimCfg(None)) launcher_args = argparse.Namespace(visualizer=None, visualizer_explicit=True) diff --git a/source/isaaclab_teleop/changelog.d/minimal-kit-exit-lifecycle.skip b/source/isaaclab_teleop/changelog.d/minimal-kit-exit-lifecycle.skip new file mode 100644 index 000000000000..4e85c3ff61a6 --- /dev/null +++ b/source/isaaclab_teleop/changelog.d/minimal-kit-exit-lifecycle.skip @@ -0,0 +1 @@ +Updated references from AppLauncher to the Kit launcher. diff --git a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py index f3a9924244c0..b14428e665ea 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py +++ b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py @@ -537,7 +537,7 @@ def _poll_buttons(self) -> None: def _enable_teleop_bridge() -> None: """Enable the XR teleop bridge extension and configure carb settings. - Must be called after the Omniverse AppLauncher has started. + Must be called after the Kit launcher has started. """ import carb.settings import omni.kit.app diff --git a/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py b/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py index 16e7c241b8f0..b953561972a5 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py +++ b/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py @@ -961,7 +961,7 @@ def _on_xr_enabled_changed(self, item, event_type): def _on_pre_shutdown(self, _event): """Called when Kit is closing; tear down the session but leave the pipeline intact so the main loop can exit via its own control flow - (``simulation_app.is_running()`` will go ``False``). + (the Kit app stops running). Full resource cleanup happens later when the context manager's ``__exit__`` calls :meth:`stop`. @@ -1427,7 +1427,7 @@ def _ensure_xr_ar_profile_enabled(cls) -> None: tick, which is why handle acquisition may be deferred by one frame. Headless mode is detected via the ``/isaaclab/xr/auto_start`` carb - setting which the :class:`~isaaclab.app.AppLauncher` stores after + setting which the :class:`~isaaclab_physx.app.KitLauncher` stores after resolving the headless state from visualizer intent. In non-headless mode this is a no-op because Kit's profile system manages AR activation through the UI. diff --git a/source/isaaclab_visualizers/changelog.d/minimal-kit-exit-lifecycle.skip b/source/isaaclab_visualizers/changelog.d/minimal-kit-exit-lifecycle.skip new file mode 100644 index 000000000000..4e85c3ff61a6 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/minimal-kit-exit-lifecycle.skip @@ -0,0 +1 @@ +Updated references from AppLauncher to the Kit launcher. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index ae1916120a10..2ef4f921874c 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -131,9 +131,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: return scene_data_provider = self._set_scene_data_provider(scene_data_provider) - usd_stage = scene_data_provider.usd_stage - if usd_stage is None: - raise RuntimeError("[KitVisualizer] USD stage not available from scene_data_provider.") + usd_stage = SimulationContext.instance().stage num_envs = scene_data_provider.num_envs self._ensure_simulation_app() @@ -276,7 +274,7 @@ def render_rgb_array(self) -> np.ndarray: # captured frame contains real rendered output, not empty/blank data. if self._rgb_annotator is None: self._rgb_render_product = rep.create.render_product(camera_path, (w, h)) - self._apply_render_product_background(self._scene_data_provider.usd_stage, self._rgb_render_product.path) + self._apply_render_product_background(SimulationContext.instance().stage, self._rgb_render_product.path) self._rgb_annotator = rep.AnnotatorRegistry.get_annotator("rgb", device="cpu") self._rgb_annotator.attach([self._rgb_render_product]) elif self._runtime_headless and self._rgb_render_product is not None: @@ -668,7 +666,7 @@ def _setup_streaming_view(self, num_envs: int) -> None: if not cameras_enabled: logger.info( "[KitVisualizer] Streaming view skipped: camera rendering is not enabled " - "(cameras_enabled=False). Construct AppLauncher with enable_cameras=True, " + "(cameras_enabled=False). Launch with enable_cameras=True (--enable_cameras), " "or use launch_simulation() which enables cameras automatically when " "streaming_view=True is set on the KitVisualizerCfg." ) @@ -791,7 +789,7 @@ def _update_owned_camera_poses(self) -> None: if self._camera_sensor is None or not self._camera_is_owned: return target_positions = prim_world_positions( - self._scene_data_provider.get_usd_stage(), + SimulationContext.instance().stage, self.cfg.streaming_cam_target_prim_path, self._camera_env_indices, scene=self._scene_data_provider.get_interactive_scene(), @@ -991,9 +989,7 @@ def _set_usd_camera_pose(self, camera_path: str, position, target) -> bool: ``True`` when authored values changed, otherwise ``False``. """ # TODO: Remove this USD-side pose path once Fabric-backed camera transforms propagate reliably to Kit. - usd_stage = self._scene_data_provider.usd_stage if self._scene_data_provider else None - if usd_stage is None: - return False + usd_stage = SimulationContext.instance().stage eye = torch.as_tensor(position, dtype=torch.float32, device="cpu").reshape(1, 3) lookat = torch.as_tensor(target, dtype=torch.float32, device="cpu").reshape(1, 3) @@ -1057,9 +1053,7 @@ def _set_active_camera_path(self, camera_path: str) -> bool: """ if self._viewport_api is None: return False - usd_stage = self._scene_data_provider.usd_stage if self._scene_data_provider else None - if usd_stage is None: - return False + usd_stage = SimulationContext.instance().stage camera_prim = usd_stage.GetPrimAtPath(camera_path) if not camera_prim.IsValid(): return False @@ -1093,9 +1087,7 @@ def _refresh_partial_viz_point_instancers_if_needed(self) -> None: """Re-apply ``invisibleIds`` for env-scaled `/Visuals` instancers (handles lazy marker creation).""" if self._resolved_visible_env_ids is None or self._scene_data_provider is None: return - usd_stage = self._scene_data_provider.usd_stage - if usd_stage is None: - return + usd_stage = SimulationContext.instance().stage num_envs = self._scene_data_provider.num_envs if num_envs <= 0: return @@ -1144,9 +1136,7 @@ def _point_instancer_instance_count(pi: UsdGeom.PointInstancer) -> int | None: def _restore_env_visibility(self) -> None: """Restore environment visibilities and PointInstancer ``invisibleIds`` from partial viz.""" - usd_stage = self._scene_data_provider.usd_stage if self._scene_data_provider else None - if usd_stage is None: - return + usd_stage = SimulationContext.instance().stage for env_path, prev in self._hidden_env_visibilities.items(): prim = usd_stage.GetPrimAtPath(env_path) if not prim.IsValid(): diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index acb4b123070a..0897e3d19869 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -1758,8 +1758,10 @@ def _update_owned_camera_poses(self) -> None: """Update generated camera poses from env origins or follow prims.""" if self._camera_sensor is None or not self._camera_is_owned: return + from isaaclab.sim import SimulationContext + target_positions = prim_world_positions( - self._scene_data_provider.get_usd_stage(), + SimulationContext.instance().stage, self.cfg.streaming_cam_target_prim_path, self._camera_env_indices, scene=self._scene_data_provider.get_interactive_scene(), diff --git a/tools/conftest.py b/tools/conftest.py index ef87419bc877..71c6d2022285 100644 --- a/tools/conftest.py +++ b/tools/conftest.py @@ -47,10 +47,10 @@ def pytest_ignore_collect(collection_path, config): """ STARTUP_DEADLINE = 120 -"""Seconds to wait for AppLauncher init or pytest collection before declaring a +"""Seconds to wait for Kit launch or pytest collection before declaring a startup hang. -AppLauncher prints ``[ISAACLAB] AppLauncher initialization complete`` to +The Kit launcher prints ``[ISAACLAB] KitLauncher initialization complete`` to ``sys.__stderr__`` (never suppressed) when Kit finishes initializing, and pytest prints ``collected N items`` to stdout after collection (``N workers [M items]`` under ``pytest-xdist``, once every worker has collected). If none appears @@ -353,7 +353,7 @@ def capture_test_output_with_timeout(cmd, timeout, env, startup_deadline=0, repo timeout: Maximum wall-clock seconds before the process is killed. env: Environment variables for the subprocess. startup_deadline: If > 0, the process is killed early when neither - ``AppLauncher initialization complete`` (stderr) nor ``collected`` + ``KitLauncher initialization complete`` (stderr) nor ``collected`` (stdout) appears within this many seconds. report_file: Path to the JUnit XML report file. When set, the process is given only :data:`SHUTDOWN_GRACE_PERIOD` seconds to exit after @@ -406,7 +406,7 @@ def capture_test_output_with_timeout(cmd, timeout, env, startup_deadline=0, repo elapsed = time.time() - start_time if not started and ( - b"AppLauncher initialization complete" in stderr_data + b"KitLauncher initialization complete" in stderr_data or b"collected " in stdout_data or b" workers [" in stdout_data ): diff --git a/tools/hang_dump.py b/tools/hang_dump.py index 9fef9b8b4b95..e037b884b380 100644 --- a/tools/hang_dump.py +++ b/tools/hang_dump.py @@ -12,10 +12,8 @@ Two constraints decide which signal that can be: -* ``SIGTERM`` and ``SIGABRT`` are unusable. :class:`~isaaclab.app.AppLauncher` binds both to a handler that - calls ``SimulationApp.close()``, which is itself what a shutdown hang is stuck inside, so sending either - re-enters the hang rather than reporting it. Binding ``SIGABRT`` also displaces ``faulthandler``'s own - handler for it. +* ``SIGTERM`` and ``SIGABRT`` are unusable. A Kit process keeps their default actions, so either ends the + process (``SIGABRT`` through the carb crash reporter) instead of reporting where it hangs. * A Python-level :mod:`signal` handler would not run anyway. Those execute between bytecodes, and a thread wedged in a native Kit, CUDA, or renderer call never returns to the interpreter loop -- the same reason ``isaaclab.cli.multigpu`` escalates to ``SIGKILL`` when reaping stragglers. diff --git a/tools/run_all_tests.py b/tools/run_all_tests.py index c45cad306f19..c8bd55034d53 100644 --- a/tools/run_all_tests.py +++ b/tools/run_all_tests.py @@ -339,7 +339,7 @@ def warm_start_app(): [ sys.executable, "-c", - "from isaaclab.app import AppLauncher; app_launcher = AppLauncher(headless=True); app_launcher.app.close()", + "from isaaclab.test.utils import launch_test_simulation; launch_test_simulation()", ], capture_output=True, ) @@ -355,10 +355,7 @@ def warm_start_app(): [ sys.executable, "-c", - ( - "from isaaclab.app import AppLauncher; app_launcher = AppLauncher(headless=True," - " enable_cameras=True); app_launcher.app.close()" - ), + ("from isaaclab.test.utils import launch_test_simulation; launch_test_simulation(enable_cameras=True)"), ], capture_output=True, )