Source code for isaaclab.app.sim_launcher

# 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

"""Utilities for detecting and launching the appropriate simulation backend.

The flow is intentionally simple: walk the config tree **once** to collect its
signals into a :class:`Scan`, resolve the Kit runtime sources from that scan and
the launcher inputs, then validate and launch.
"""

from __future__ import annotations

import argparse
import importlib.metadata
import logging
import os
import sys
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any

from isaaclab_newton.physics import NewtonCfg, VBDSolverCfg
from isaaclab_ov.physics import OvPhysxCfg
from isaaclab_ov.renderers import OVRTXRendererCfg
from isaaclab_physx.physics import PhysxCfg
from isaaclab_physx.renderers import IsaacRtxRendererCfg

from isaaclab.app.logging_utils import apply_python_logging_level, resolve_python_logging_level
from isaaclab.physics.physics_manager_cfg import PhysicsCfg, PhysxAutoCfg, _resolve_physx_auto_cfg
from isaaclab.renderers.renderer_cfg import RendererCfg
from isaaclab.sensors.camera.camera_cfg import CameraCfg
from isaaclab.utils._device import set_cuda_device

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")


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.
    """
    from isaaclab.app import AppLauncher

    AppLauncher.add_app_launcher_args(parser)


[docs] def make_physics_cfg(physics_cfg_str: str) -> PhysicsCfg: """Build a physics config for the requested backend. Args: physics_cfg_str: Backend selector: ``"physx"``, ``"isaacsim_physx"``, ``"newton_mjwarp"``, ``"newton_vbd"``, or ``"ovphysx"``. The ``"physx"`` selector is automatic: it resolves to Isaac Sim PhysX when Kit is required, and to OvPhysX otherwise. Returns: A new physics config instance for the requested backend. Raises: ValueError: If *physics_cfg_str* does not name a known backend. """ if physics_cfg_str == "physx": return PhysxAutoCfg(isaacsim_physx=PhysxCfg(), ovphysx=OvPhysxCfg()) if physics_cfg_str == "isaacsim_physx": return PhysxCfg() if physics_cfg_str == "newton_mjwarp": return NewtonCfg() if physics_cfg_str == "newton_vbd": return NewtonCfg(solver_cfg=VBDSolverCfg()) if physics_cfg_str == "ovphysx": return OvPhysxCfg() raise ValueError( f"Invalid physics config: {physics_cfg_str!r} " "(expected 'physx', 'isaacsim_physx', 'newton_mjwarp', 'newton_vbd', or 'ovphysx')." )
""" Node Predicates. """ def _is_ovrtx_renderer(node) -> bool: """True when the node is an OVRTX renderer config.""" return isinstance(node, RendererCfg) and getattr(node, "renderer_type", None) == "ovrtx" def _is_auto_rtx_renderer(node) -> bool: """True when the node is an automatic RTX renderer placeholder.""" return getattr(node, "renderer_type", None) == "auto_rtx" def _is_auto_physx_physics(node) -> bool: """True when the node is an automatic PhysX placeholder.""" return isinstance(node, PhysxAutoCfg) def _is_kit_camera(node) -> bool: """True for a CameraCfg whose renderer requires Kit (not Newton).""" if not isinstance(node, CameraCfg): return False renderer_cfg = getattr(node, "renderer_cfg", None) if renderer_cfg is None: return True if _is_auto_rtx_renderer(renderer_cfg): # ``auto_rtx`` is resolved after the initial scan once physics and # visualizer intent are known; ie. it may become OVRTX for a kitless run. return False if not isinstance(renderer_cfg, RendererCfg): raise TypeError( f"CameraCfg.renderer_cfg must be a concrete RendererCfg or None, got {type(renderer_cfg).__name__}." ) return renderer_cfg.renderer_type in ("default", "isaac_rtx") """ Launcher Argument Helpers. """ def _get_arg(launcher_args: argparse.Namespace | dict | None, key: str, default: Any = None) -> Any: """Read *key* from launcher args, whether a namespace, dict, or ``None``.""" if isinstance(launcher_args, argparse.Namespace): return getattr(launcher_args, key, default) if isinstance(launcher_args, dict): return launcher_args.get(key, default) return default def _set_arg(launcher_args: argparse.Namespace | dict | None, key: str, value: Any) -> None: """Write *key* on launcher args when it is a namespace or dict.""" if isinstance(launcher_args, argparse.Namespace): setattr(launcher_args, key, value) elif isinstance(launcher_args, dict): 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 _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 visualizer_explicit = bool(_get_arg(launcher_args, "visualizer_explicit", False)) 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 = [] 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) def _get_visualizer_intent(cfg) -> dict[str, bool]: """Compute upstream visualizer intent from ``cfg.sim.visualizer_cfgs``.""" # 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} cfgs = visualizer_cfgs if isinstance(visualizer_cfgs, list) else [visualizer_cfgs] cfgs = [c for c in cfgs if c is not None] 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 ), } """ The Single Scan. """
[docs] @dataclass class Scan: """Signals gathered from one walk of the config tree (see :func:`scan`). Every field starts as a plain snapshot computed during that single walk. Automatic PhysX configurations and RTX placeholders are also recorded so launch-time resolution can update the physics- and renderer-related fields without traversing the config tree again. ``needs_kit`` is the headline launch decision after automatic selections are resolved: a Kit-renderer camera or Isaac Sim PhysX requires Kit (the launcher additionally forces Kit when ``--visualizer kit`` is requested). """ resolved_physics_cfg: PhysicsCfg | None # first physics config in walk order (post --physics override) effective_cfg: Any # the input config, or its replacement when the config itself was an overridden physics config visualizer_intent: dict[str, bool] 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
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
[docs] def scan(cfg, launcher_args: argparse.Namespace | dict | None = None) -> Scan: """Walk *cfg* once, collecting all launch signals and applying ``--physics``. When the ``physics`` key is present in *launcher_args*, every physics config is replaced by the requested backend (see :func:`make_physics_cfg`): nested configs in place, a root config via :attr:`Scan.effective_cfg` (it cannot be mutated in place). Automatic PhysX configurations and RTX renderer placeholders (``renderer_type="auto_rtx"``) are also resolved at this stage using the full *launcher_args* context. The walk mutates *cfg* in place, and resolving a placeholder consumes it, so a second walk of the same config observes the same signals and reaches the same launch decision. """ # Livestreaming implies a Kit visualizer; make that visible to auto RTX resolution. _ensure_livestream_kit_visualizer(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 = False 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) visited: set[int] = set() 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): has_auto_rtx = True auto_rtx_locations.append((parent, key, isinstance(parent, CameraCfg) and key == "renderer_cfg")) if id(node) in visited: return visited.add(id(node)) if isinstance(node, PhysicsCfg): if physics_str: node = make_physics_cfg(physics_str) if parent is not None: setattr(parent, key, node) else: effective_cfg = node physics_cfgs.append(node) if _is_auto_physx_physics(node): has_auto_physx = True auto_physx_locations.append((node, parent, key, len(physics_cfgs) == 1)) return else: concrete_physics_cfgs.append(node) elif _is_ovrtx_renderer(node): has_ovrtx = True elif _is_kit_camera(node): has_kit_camera = True try: children = vars(node) except TypeError: return for name, child in children.items(): if child is None or isinstance(child, (int, float, str, bool)): continue visit(child, node, name) visit(cfg, None, None) has_physics = bool(physics_cfgs) config_scan = Scan( resolved_physics_cfg=physics_cfgs[0] if physics_cfgs else None, effective_cfg=effective_cfg, visualizer_intent=_get_visualizer_intent(cfg), 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, ) _refresh_physics_scan_flags(config_scan, concrete_physics_cfgs, has_physics) # RTX selection depends on the resolved physics backend. if has_auto_physx: use_isaac_sim = bool(_get_kit_runtime_sources(config_scan, launcher_args)) 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) if parent is None: effective_cfg = physics_cfg config_scan.effective_cfg = physics_cfg else: setattr(parent, key, physics_cfg) if is_first_physics: config_scan.resolved_physics_cfg = physics_cfg _refresh_physics_scan_flags(config_scan, concrete_physics_cfgs, has_physics) # Resolve recorded auto RTX renderer placeholders. if not has_auto_rtx: return config_scan use_isaac_sim = bool(_get_kit_runtime_sources(config_scan, launcher_args)) renderer_factory = IsaacRtxRendererCfg if use_isaac_sim else OVRTXRendererCfg # Resolve every auto RTX placeholder in place, tracking camera renderers that may require Kit. has_auto_camera = False 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()) has_auto_camera = has_auto_camera or location[2] # Update the scan with the resolved auto RTX renderer type and Kit-camera status. if use_isaac_sim: config_scan.has_kit_camera = config_scan.has_kit_camera or has_auto_camera else: config_scan.has_ovrtx = True _refresh_physics_scan_flags(config_scan, concrete_physics_cfgs, has_physics) return config_scan
""" Launch Decisions (derived purely from a scan). """ 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"] def _get_kit_runtime_sources(config_scan: Scan, launcher_args: argparse.Namespace | dict | None) -> tuple[str, ...]: """Return the config and launcher components that require Isaac Sim / Kit.""" kit_sources = [] if config_scan.has_kit_physics: kit_sources.append("Isaac Sim PhysX physics (`PhysxCfg`)") if config_scan.has_kit_camera: kit_sources.append('a Kit-based renderer (`IsaacRtxRendererCfg`, `renderer_type="isaac_rtx"`)') if _has_kit_visualizer(config_scan, launcher_args): kit_sources.append('the Kit visualizer (`--visualizer kit` / `visualizer_type="kit"`)') if _get_arg(launcher_args, "experience", ""): kit_sources.append("an explicit Kit experience") if _get_livestream_mode(launcher_args) > 0: kit_sources.append("livestreaming") if _get_arg(launcher_args, "require_kit", False): kit_sources.append("the caller's explicit Kit requirement") if config_scan.needs_kit and not kit_sources: kit_sources.append("the default Isaac Sim / Kit runtime") return tuple(kit_sources) def _format_runtime_sources(sources: tuple[str, ...]) -> str: """Format runtime sources as a readable list.""" if len(sources) < 3: return " and ".join(sources) return f"{', '.join(sources[:-1])}, and {sources[-1]}" def _validate_runtime(scan: Scan, kit_sources: tuple[str, ...]) -> None: """Raise if the scan and resolved Kit sources form an unsupported combination. OVRTX is kitless and cannot share a process with Kit-based runtimes (PhysX physics or any other Kit source); OvPhysX physics is subject to the same process boundary. """ if scan.has_ovphysx_physics and kit_sources: # both would initialize Carbonite in one process; without this guard OvPhysX fails # deep inside its own library with "Failed to initialize Carbonite and load PhysX plugins" raise ValueError( "Invalid backend combination: OvPhysX physics (`OvPhysxCfg`) is kitless and cannot be used together " f"with Isaac Sim / Kit ({_format_runtime_sources(kit_sources)}).\n" "\n" "To fix this, pick one of the following supported combinations:\n" " * Keep OvPhysX physics and switch to a kitless renderer/visualizer:\n" " use `OvPhysxCfg` with `OVRTXRendererCfg`\n" " (and use `--visualizer newton`, `--visualizer rerun`, or `--visualizer viser`, or omit\n" " the visualizer argument for headless execution.)\n" " * Keep Isaac Sim / Kit and switch to a Kit-compatible physics backend:\n" " use `PhysxCfg` with `IsaacRtxRendererCfg`\n" ) if not scan.has_ovrtx or not kit_sources: return raise ValueError( "Invalid backend combination: the OVRTX renderer (`OVRTXRendererCfg`," ' `renderer_type="ovrtx"`) is a kitless renderer and cannot be used together' f" with Isaac Sim / Kit ({_format_runtime_sources(kit_sources)}).\n" "\n" "To fix this, pick one of the following supported combinations:\n" " * Keep Isaac Sim / Kit and switch the renderer:\n" " use `IsaacRtxRendererCfg`, the Kit-compatible renderer\n" " * Keep the OVRTX renderer and switch to a kitless physics backend\n" " (and avoid `--visualizer kit`):\n" " use `NewtonCfg` or `OvPhysxCfg` with `OVRTXRendererCfg`\n" ) def _resolve_distributed_device(cfg, launcher_args: argparse.Namespace | dict | None) -> None: """Set ``cfg.sim.device`` for distributed training, mirroring AppLauncher's fallback. 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. """ if not _get_arg(launcher_args, "distributed", False): return import torch local_rank = int(os.getenv("LOCAL_RANK", "0")) + int(os.getenv("JAX_LOCAL_RANK", "0")) num_visible_gpus = torch.cuda.device_count() # Compare against the local device count (not WORLD_SIZE) so multi-node runs work. device_str = f"cuda:{local_rank}" if local_rank < num_visible_gpus else "cuda:0" sim_cfg = getattr(cfg, "sim", None) if sim_cfg is not None: sim_cfg.device = device_str set_cuda_device(device_str) logger.info( "Distributed device resolved to %s (local_rank=%d, visible_gpus=%d)", device_str, local_rank, num_visible_gpus, )
[docs] @contextmanager def launch_simulation( cfg, launcher_args: argparse.Namespace | dict | None = None, ) -> Generator[PhysicsCfg | None, None, None]: """Context manager that launches the appropriate simulation runtime for *cfg*. 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 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 pick the backend from the command line:: with launch_simulation(PhysicsCfg(), args_cli) as physics_cfg: sim = SimulationContext(SimulationCfg(physics=physics_cfg)) Callers that do not need the value simply omit ``as``. Args: cfg: Config tree to scan for backend, renderer, and sensor requirements. launcher_args: Parsed launcher arguments, typically the script's ``args_cli``. Besides the arguments added by :func:`add_launcher_args`, the following keys are read when a script contributes them: * ``physics``: Backend selector applied to every physics config in *cfg*, see :func:`make_physics_cfg`. * ``require_kit``: Whether the caller needs Kit for a reason *cfg* cannot express, e.g. 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``. """ if launcher_args is None: launcher_args = {} # The single walk: collect every signal, apply the --physics override, and # resolve the automatic PhysX and RTX placeholders. 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) needs_kit = bool(kit_sources) _set_arg(launcher_args, "visualizer_intent", config_scan.visualizer_intent) # 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)) 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): logger.info( "Auto-enabling camera rendering because the scene contains Kit camera sensors " "or a Kit visualizer with streaming_view=True." ) _set_arg(launcher_args, "enable_cameras", True) # Resolve distributed device early, before AppLauncher 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 isaaclab.utils import has_kit if not has_kit(): from isaaclab.app 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 isaaclab.app 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} ) exit_code = 0 try: # The import stays after the Kit launch decision. With no selected profile this is a # no-op; with one, it installs process-wide OmniClient routing before user code runs. from isaaclab.utils.assets import configure_storage_profile configure_storage_profile() yield physics_cfg except Exception: exit_code = 1 import traceback 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 isaaclab.app 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)