diff --git a/.gitignore b/.gitignore
index 69061763a..e61027a6b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -208,3 +208,9 @@ scripts/benchmark/rl/reports/*
.worktrees/
# Local gym project workspace
/gym_project/
+.debug_engine/
+
+# Local Gradio UI dependencies, generated Articraft records, and bytecode
+/embodichain/gen_sim/gradio_ui/.articraft/
+/embodichain/gen_sim/gradio_ui/.debug_engine/
+/embodichain/gen_sim/gradio_ui/__pycache__/
diff --git a/embodichain/__main__.py b/embodichain/__main__.py
index 975e7649d..0e73bd076 100644
--- a/embodichain/__main__.py
+++ b/embodichain/__main__.py
@@ -96,11 +96,6 @@ class Command:
target="embodichain.workspace_cache_cli:main",
help="Inspect and clean workspace analyzer caches.",
),
- Command(
- name="analyze-workspace",
- target="embodichain.lab.scripts.analyze_workspace:cli",
- help="Analyze a robot's reachable workspace from a URDF/USD asset.",
- ),
)
diff --git a/embodichain/gen_sim/.env.example b/embodichain/gen_sim/.env.example
new file mode 100644
index 000000000..6544d0710
--- /dev/null
+++ b/embodichain/gen_sim/.env.example
@@ -0,0 +1,39 @@
+# Shared GenSim configuration
+# Copy this file to .env and set deployment-specific values. Values exported
+# by the shell, container, or CI environment take precedence over this file.
+
+# Common OpenAI-compatible LLM endpoint used by Scene Engine.
+OPENAI_API_KEY=""
+OPENAI_MODEL=""
+OPENAI_BASE_URL=""
+SCENE_ENGINE_OPENAI_DEFAULT_QUERY="{}"
+OPENAI_MAX_ATTEMPTS=3
+
+# Scene Engine services.
+SCENE_ENGINE_IMAGE_SEGMENTATION_BASE_URL=""
+SCENE_ENGINE_IMAGE_SEGMENTATION_TIMEOUT_S=30
+SCENE_ENGINE_IMAGE_SEGMENTATION_MAX_ATTEMPTS=3
+SCENE_ENGINE_IMAGE_SEGMENTATION_HEALTH_PATH="/health"
+SCENE_ENGINE_IMAGE_SEGMENTATION_SINGLE_OBJECT_PATH="/predict"
+SCENE_ENGINE_GEOMETRY_GENERATION_BASE_URL=""
+SCENE_ENGINE_GEOMETRY_GENERATION_TIMEOUT_S=600
+SCENE_ENGINE_GEOMETRY_GENERATION_MAX_ATTEMPTS=3
+SCENE_ENGINE_GEOMETRY_GENERATION_HEALTH_PATH="/health"
+SCENE_ENGINE_GEOMETRY_GENERATION_OBJECTS_PATH="/generate_multiple_objects"
+
+# Gradio application and local workbench settings. The EmbodiChain repository
+# root is derived automatically from the installed source tree.
+GRADIO_SERVER_NAME="0.0.0.0"
+GRADIO_SERVER_PORT=7860
+SCENE_ENGINE_VISER_PORT=8080
+ARTICRAFT_VISER_PORT=8081
+ARTICRAFT_ROOT=""
+ARTICRAFT_REPOSITORY_URL="https://github.com/mattzh72/articraft.git"
+ARTICRAFT_CONDA_ENV="articraft"
+ARTICRAFT_OUTPUT_ROOT=""
+
+# Optional SimReady endpoint. These values are mapped to OPENAI_* only for
+# SimReady subprocesses, leaving Scene Engine settings unchanged.
+SIMREADY_OPENAI_API_KEY=""
+SIMREADY_OPENAI_MODEL=""
+SIMREADY_OPENAI_BASE_URL=""
diff --git a/embodichain/gen_sim/env.py b/embodichain/gen_sim/env.py
new file mode 100644
index 000000000..44b772568
--- /dev/null
+++ b/embodichain/gen_sim/env.py
@@ -0,0 +1,105 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Load the shared GenSim environment configuration."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import MutableMapping
+
+__all__ = ["find_gen_sim_env_file", "get_embodichain_root", "load_gen_sim_env"]
+
+
+def get_embodichain_root() -> Path:
+ """Return the repository containing the installed GenSim source tree.
+
+ The Gradio app always launches its local tools from this directory. It is
+ derived from this module instead of a machine-specific dotenv value, so a
+ checkout continues to work after it is moved or cloned elsewhere.
+ """
+ return Path(__file__).resolve().parents[2]
+
+
+def find_gen_sim_env_file() -> Path:
+ """Return the configured shared ``.env`` file path.
+
+ ``EMBODICHAIN_ENV_FILE`` is useful for deployments that keep secrets outside
+ the source tree. Otherwise GenSim uses ``embodichain/gen_sim/.env``. The
+ repository-root ``.env`` remains a backward-compatible fallback.
+ """
+ configured_path = os.environ.get("EMBODICHAIN_ENV_FILE")
+ if configured_path:
+ return Path(configured_path).expanduser().resolve()
+ default_path = Path(__file__).resolve().parent / ".env"
+ if default_path.is_file():
+ return default_path
+
+
+def load_gen_sim_env(env: MutableMapping[str, str] | None = None) -> Path | None:
+ """Load missing variables from the shared GenSim ``.env`` file.
+
+ Existing process environment variables are never overwritten so container,
+ CI, and shell-provided settings retain precedence over the local file.
+
+ Args:
+ env: Environment mapping to populate. Defaults to :data:`os.environ`.
+
+ Returns:
+ The loaded path, or ``None`` when no local ``.env`` file exists.
+
+ Raises:
+ ValueError: If the file contains an invalid ``KEY=VALUE`` entry.
+ """
+ target_env = os.environ if env is None else env
+ env_path = find_gen_sim_env_file()
+ if not env_path.is_file():
+ return None
+
+ for line_number, raw_line in enumerate(
+ env_path.read_text(encoding="utf-8").splitlines(), start=1
+ ):
+ parsed = _parse_env_line(raw_line)
+ if parsed is None:
+ continue
+ key, value = parsed
+ if not key.isidentifier():
+ raise ValueError(
+ f"Invalid environment variable name at {env_path}:{line_number}: {key!r}"
+ )
+ target_env.setdefault(key, value)
+ return env_path
+
+
+def _parse_env_line(line: str) -> tuple[str, str] | None:
+ """Parse one conventional dotenv line without requiring a third-party package."""
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#"):
+ return None
+ if stripped.startswith("export "):
+ stripped = stripped.removeprefix("export ").lstrip()
+ if "=" not in stripped:
+ raise ValueError(f"Expected KEY=VALUE entry, got: {line!r}")
+
+ key, value = stripped.split("=", maxsplit=1)
+ key = key.strip()
+ value = value.strip()
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
+ value = value[1:-1]
+ elif " #" in value:
+ value = value.split(" #", maxsplit=1)[0].rstrip()
+ return key, value
diff --git a/embodichain/gen_sim/gradio_ui/app_articraft.py b/embodichain/gen_sim/gradio_ui/app_articraft.py
new file mode 100644
index 000000000..a7fd81f9e
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_articraft.py
@@ -0,0 +1,1138 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Codex-backed Articraft generation for the Asset engine.
+
+The integration uses Articraft's external-agent workflow: Articraft owns
+record creation and validation while Codex authors the generated model. All
+mutable run data is kept under ``ARTICRAFT_OUTPUT_ROOT``.
+"""
+
+from __future__ import annotations
+
+import atexit
+import os
+import queue
+import json
+import shutil
+import html
+import signal
+import socket
+import subprocess
+import sys
+import threading
+import time
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+import gradio as gr
+
+from app_env import (
+ ARTICRAFT_CONDA_ENV,
+ ARTICRAFT_OUTPUT_ROOT,
+ ARTICRAFT_REPOSITORY_URL,
+ ARTICRAFT_ROOT,
+ ARTICRAFT_VISER_PORT,
+)
+from app_processes import (
+ read_process_output,
+ register_managed_process,
+ start_pipeline,
+ terminate_process_group,
+)
+
+__all__ = [
+ "build_articraft_panel",
+ "configure_articraft_environment",
+ "generate_articraft_asset",
+ "reset_articraft_asset",
+ "stop_articraft_viser_preview",
+]
+
+_VISER_START_TIMEOUT_SECONDS = 15.0
+_VISER_STOP_TIMEOUT_SECONDS = 5.0
+_ARTICRAFT_PYTHON_VERSION = "3.12"
+_CONDA_ENVIRONMENT_SETUP_TIMEOUT_SECONDS = 1_200
+_articraft_environment_lock = threading.Lock()
+_articraft_generation_lock = threading.Lock()
+_articraft_generation_process: subprocess.Popen[str] | None = None
+_articraft_generation_token: str | None = None
+_ARTICRAFT_IDLE_PREVIEW = (
+ "
"
+ "The interactive Viser articulation preview will appear here after generation."
+ "
"
+)
+
+
+def _begin_articraft_generation() -> str:
+ """Invalidate the previous generation and return a new ownership token."""
+ global _articraft_generation_process, _articraft_generation_token
+ with _articraft_generation_lock:
+ previous_process = _articraft_generation_process
+ _articraft_generation_process = None
+ token = uuid.uuid4().hex
+ _articraft_generation_token = token
+ if previous_process is not None:
+ terminate_process_group(previous_process)
+ return token
+
+
+def _articraft_generation_is_active(
+ token: str, process: subprocess.Popen[str] | None = None
+) -> bool:
+ with _articraft_generation_lock:
+ return _articraft_generation_token == token and (
+ process is None or _articraft_generation_process is process
+ )
+
+
+def _set_articraft_generation_process(
+ token: str, process: subprocess.Popen[str]
+) -> bool:
+ global _articraft_generation_process
+ with _articraft_generation_lock:
+ if _articraft_generation_token != token:
+ return False
+ _articraft_generation_process = process
+ return True
+
+
+def _finish_articraft_generation_process(
+ token: str, process: subprocess.Popen[str]
+) -> None:
+ global _articraft_generation_process
+ with _articraft_generation_lock:
+ if (
+ _articraft_generation_token == token
+ and _articraft_generation_process is process
+ ):
+ _articraft_generation_process = None
+
+
+def _run_articraft_generation_check(
+ command: list[str], *, token: str, timeout: int
+) -> subprocess.CompletedProcess[str] | None:
+ """Run one Articraft CLI gate so Reset can stop its whole process group."""
+ process = register_managed_process(
+ subprocess.Popen(
+ command,
+ cwd=ARTICRAFT_ROOT,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ start_new_session=True,
+ env=os.environ.copy(),
+ )
+ )
+ if not _set_articraft_generation_process(token, process):
+ terminate_process_group(process)
+ return None
+ try:
+ stdout, _ = process.communicate(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ terminate_process_group(process)
+ raise
+ finally:
+ _finish_articraft_generation_process(token, process)
+ if not _articraft_generation_is_active(token):
+ return None
+ return subprocess.CompletedProcess(command, process.returncode, stdout)
+
+
+def reset_articraft_asset():
+ """Clear Articraft inputs/results and stop its command and Viser processes."""
+ global _articraft_generation_process, _articraft_generation_token
+ with _articraft_generation_lock:
+ process = _articraft_generation_process
+ _articraft_generation_process = None
+ _articraft_generation_token = None
+ if process is not None:
+ terminate_process_group(process)
+ stop_articraft_viser_preview()
+ return (
+ "**Environment:** not checked.",
+ "",
+ None,
+ None,
+ "",
+ "**Status:** waiting for a description.",
+ "",
+ _ARTICRAFT_IDLE_PREVIEW,
+ )
+
+
+def _command_path(name: str) -> str | None:
+ """Resolve commands even when Gradio did not inherit an interactive PATH."""
+ configured = os.environ.get(f"{name.upper()}_EXE")
+ return configured or shutil.which(name)
+
+
+def _conda_path() -> str | None:
+ configured = os.environ.get("CONDA_EXE")
+ if configured and Path(configured).is_file():
+ return configured
+ return _command_path("conda")
+
+
+def _conda_command(*args: str) -> list[str]:
+ conda = _conda_path()
+ if not conda:
+ raise RuntimeError("Conda was not found. Set CONDA_EXE before starting Gradio.")
+ return [conda, "run", "--no-capture-output", "-n", ARTICRAFT_CONDA_ENV, *args]
+
+
+def _articraft_cli_command(*args: str) -> list[str]:
+ """Run the CLI from the checked-out source without installing it with pip."""
+ return _conda_command("python", "-m", "cli.main", *args)
+
+
+def _articraft_conda_environment_exists() -> bool:
+ """Check only for the named Conda environment, not package installation."""
+ conda = _conda_path()
+ if not conda:
+ return False
+ try:
+ result = subprocess.run(
+ [conda, "env", "list", "--json"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ timeout=30,
+ check=False,
+ )
+ if result.returncode:
+ return False
+ environments = json.loads(result.stdout or "{}").get("envs", [])
+ return any(Path(path).name == ARTICRAFT_CONDA_ENV for path in environments)
+ except (OSError, json.JSONDecodeError, TypeError):
+ return False
+
+
+def _ensure_articraft_conda_environment() -> tuple[bool, str]:
+ """Create and populate the Articraft Conda environment when it is absent.
+
+ Articraft currently supports Python 3.11 and 3.12, while the Gradio process
+ can use a different interpreter. The setup therefore creates an isolated
+ Python 3.12 environment and installs the checked-out project's runtime
+ dependencies into it.
+
+ Returns:
+ Whether the environment is ready and a status message suitable for the
+ Gradio configuration panel.
+ """
+ conda = _conda_path()
+ if not conda:
+ return False, "Conda is not on PATH. Set CONDA_EXE to the conda executable."
+
+ with _articraft_environment_lock:
+ if _articraft_conda_environment_exists():
+ return True, f"Conda environment: {ARTICRAFT_CONDA_ENV} (already exists)"
+
+ create_command = [
+ conda,
+ "create",
+ "--yes",
+ "--name",
+ ARTICRAFT_CONDA_ENV,
+ f"python={_ARTICRAFT_PYTHON_VERSION}",
+ "pip",
+ ]
+ try:
+ created = subprocess.run(
+ create_command,
+ cwd=ARTICRAFT_ROOT,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ timeout=_CONDA_ENVIRONMENT_SETUP_TIMEOUT_SECONDS,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ return False, f"Unable to create Conda environment: {exc}"
+ if created.returncode and not _articraft_conda_environment_exists():
+ return False, (
+ "Conda environment creation failed: "
+ f"{_short_output(created, limit=3000)}"
+ )
+
+ for install_args, description in (
+ (
+ ["python", "-m", "pip", "install", "--upgrade", "pip"],
+ "upgrade pip",
+ ),
+ (["python", "-m", "pip", "install", "."], "install Articraft dependencies"),
+ ):
+ install_command = [
+ conda,
+ "run",
+ "--no-capture-output",
+ "--name",
+ ARTICRAFT_CONDA_ENV,
+ *install_args,
+ ]
+ try:
+ installed = subprocess.run(
+ install_command,
+ cwd=ARTICRAFT_ROOT,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ timeout=_CONDA_ENVIRONMENT_SETUP_TIMEOUT_SECONDS,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ return False, f"Unable to {description}: {exc}"
+ if installed.returncode:
+ return (
+ False,
+ f"Unable to {description}: {_short_output(installed, limit=3000)}",
+ )
+
+ return True, (
+ f"Created Conda environment: {ARTICRAFT_CONDA_ENV} "
+ f"(Python {_ARTICRAFT_PYTHON_VERSION})"
+ )
+
+
+def _run_check(
+ command: list[str], *, timeout: int = 45
+) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ command,
+ cwd=ARTICRAFT_ROOT,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ timeout=timeout,
+ check=False,
+ )
+
+
+def _short_output(
+ result: subprocess.CompletedProcess[str], *, limit: int = 1800
+) -> str:
+ output = (result.stdout or "").strip()
+ return output[-limit:] if len(output) > limit else (output or "(no output)")
+
+
+def _check_requirements() -> tuple[list[str], list[str], str | None]:
+ """Return diagnostics and the Codex executable, without creating an asset."""
+ errors: list[str] = []
+ details: list[str] = []
+ if not (
+ ARTICRAFT_ROOT.is_dir()
+ and (ARTICRAFT_ROOT / ".git").exists()
+ and (ARTICRAFT_ROOT / "pyproject.toml").is_file()
+ ):
+ errors.append(f".articraft checkout is not ready: {ARTICRAFT_ROOT}")
+ if not _conda_path():
+ errors.append("Conda is not on PATH. Set CONDA_EXE to the conda executable.")
+ elif not _articraft_conda_environment_exists():
+ errors.append(f"Conda environment not found: {ARTICRAFT_CONDA_ENV}")
+ else:
+ details.append(f"Conda environment: {ARTICRAFT_CONDA_ENV}")
+
+ codex = _command_path("codex")
+ if not codex:
+ errors.append("Codex CLI is not on PATH. Install it or set CODEX_EXE.")
+ elif not errors:
+ try:
+ result = _run_check([codex, "--version"])
+ if result.returncode:
+ errors.append(f"Codex CLI check failed: {_short_output(result)}")
+ else:
+ details.append(f"Codex: {_short_output(result, limit=120)}")
+ except Exception as exc:
+ errors.append(f"Codex CLI check failed: {exc}")
+
+ if not errors:
+ details.append(f".articraft checkout: {ARTICRAFT_ROOT}")
+ return details, errors, codex
+
+
+def _prepare_articraft_checkout() -> tuple[bool, str]:
+ """Clone the configured checkout when absent, without overwriting a directory."""
+ if ARTICRAFT_ROOT.exists():
+ if (ARTICRAFT_ROOT / ".git").exists() and (
+ ARTICRAFT_ROOT / "pyproject.toml"
+ ).is_file():
+ return True, f".articraft checkout: {ARTICRAFT_ROOT}"
+ return (
+ False,
+ f"{ARTICRAFT_ROOT} exists but is not an Articraft Git checkout; it was left untouched.",
+ )
+
+ git = _command_path("git")
+ if not git:
+ return False, "Git is not on PATH, so .articraft cannot be cloned."
+ try:
+ ARTICRAFT_ROOT.parent.mkdir(parents=True, exist_ok=True)
+ clone = subprocess.run(
+ [git, "clone", ARTICRAFT_REPOSITORY_URL, str(ARTICRAFT_ROOT)],
+ cwd=ARTICRAFT_ROOT.parent,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ timeout=300,
+ check=False,
+ )
+ except Exception as exc:
+ return False, f"Unable to clone Articraft: {exc}"
+ if clone.returncode:
+ return False, f"Articraft clone failed: {_short_output(clone, limit=3000)}"
+ return True, f"Cloned .articraft from {ARTICRAFT_REPOSITORY_URL}"
+
+
+def configure_articraft_environment() -> str:
+ """Clone the checkout, prepare its Conda environment, and verify Codex."""
+ checkout_ready, checkout_message = _prepare_articraft_checkout()
+ if not checkout_ready:
+ return "**Articulation is not ready.**\n\n- " + checkout_message
+ environment_ready, environment_message = _ensure_articraft_conda_environment()
+ if not environment_ready:
+ return "**Articulation is not ready.**\n\n- " + environment_message
+ try:
+ for directory in (
+ ARTICRAFT_OUTPUT_ROOT,
+ ARTICRAFT_OUTPUT_ROOT / "runs",
+ ARTICRAFT_OUTPUT_ROOT / "exports",
+ ):
+ directory.mkdir(parents=True, exist_ok=True)
+ except Exception as exc:
+ return f"**Unable to prepare the shared Articulation output folder:** `{exc}`"
+ details, errors, _ = _check_requirements()
+ if errors:
+ return "**Articulation is not ready.**\n\n" + "\n".join(
+ f"- {error}" for error in errors
+ )
+ details.insert(0, checkout_message)
+ details.insert(1, environment_message)
+ details.extend(
+ (
+ f"Shared output: `{ARTICRAFT_OUTPUT_ROOT}`",
+ "Generation runs the `.articraft` checkout directly with `conda run`; no `pip install -e .` is required.",
+ )
+ )
+ return "**Articulation is ready.**\n\n" + "\n".join(
+ f"- {detail}" for detail in details
+ )
+
+
+def _record_id() -> str:
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
+ # Articraft validates external IDs against the required ``rec_`` prefix.
+ return f"rec_ui_articraft_{timestamp}_{uuid.uuid4().hex[:8]}"
+
+
+def _copy_reference_image(value: Any, run_root: Path) -> Path | None:
+ if not value:
+ return None
+ source = Path(str(value))
+ if not source.is_file():
+ raise ValueError(
+ "The reference image is no longer available; please upload it again."
+ )
+ suffix = source.suffix.lower() or ".png"
+ if suffix not in {".png", ".jpg", ".jpeg", ".webp"}:
+ raise ValueError("Reference image must be PNG, JPG, JPEG, or WEBP.")
+ target = run_root / f"reference{suffix}"
+ shutil.copy2(source, target)
+ return target
+
+
+def _active_model_path(record_dir: Path) -> Path:
+ candidates = sorted(record_dir.glob("revisions/*/model.py"))
+ if len(candidates) != 1:
+ raise FileNotFoundError(
+ f"Expected one active model.py in {record_dir}, found {len(candidates)}."
+ )
+ return candidates[0]
+
+
+def _make_result_bundle(record_id: str) -> tuple[Path, Path]:
+ materialized = (
+ ARTICRAFT_OUTPUT_ROOT / "data" / "cache" / "record_materialization" / record_id
+ )
+ if not (materialized / "model.urdf").is_file():
+ raise FileNotFoundError(
+ "Articraft completed without a compiled model.urdf output."
+ )
+ exports_root = ARTICRAFT_OUTPUT_ROOT / "exports"
+ exports_root.mkdir(parents=True, exist_ok=True)
+ archive = Path(
+ shutil.make_archive(
+ (exports_root / record_id).as_posix(),
+ "zip",
+ root_dir=materialized,
+ )
+ )
+ return materialized, archive
+
+
+def _articraft_viser_iframe(record_id: str) -> str:
+ """Embed the Articulation Viser service through the Gradio page hostname."""
+ srcdoc = (
+ ""
+ )
+ escaped_record_id = html.escape(record_id)
+ return (
+ "Viser articulation preview: "
+ f"{escaped_record_id}"
+ f""
+ "
"
+ )
+
+
+class _ArticraftViserPreview:
+ """Own the single Articraft Viser process and its dedicated TCP port."""
+
+ def __init__(self, port: int) -> None:
+ self._port = port
+ self._lock = threading.Lock()
+ self._process: subprocess.Popen[str] | None = None
+
+ def start(self, urdf_path: Path, record_id: str) -> str:
+ """Replace the active preview with a verified preview of one URDF."""
+ if not urdf_path.is_file():
+ raise FileNotFoundError(f"Compiled URDF is missing: {urdf_path}")
+
+ with self._lock:
+ self._stop_managed_process()
+ self._clear_stale_listener()
+ process = start_pipeline(self._command(urdf_path))
+ if not self._wait_until_owned(process):
+ terminate_process_group(process)
+ raise RuntimeError("New Articraft Viser preview did not bind its port.")
+ self._process = process
+ return _articraft_viser_iframe(record_id)
+
+ def stop(self) -> None:
+ """Stop the preview process, if this panel started one."""
+ with self._lock:
+ self._stop_managed_process()
+
+ def _command(self, urdf_path: Path) -> list[str]:
+ return [
+ sys.executable,
+ str(Path(__file__).with_name("app_media.py")),
+ "--asset_path",
+ str(urdf_path),
+ "--asset_type",
+ "articulation",
+ "--headless",
+ "--viser",
+ "--viser-host",
+ "0.0.0.0",
+ "--viser-port",
+ str(self._port),
+ ]
+
+ def _stop_managed_process(self) -> None:
+ if self._process is not None:
+ terminate_process_group(self._process)
+ self._process = None
+
+ def _clear_stale_listener(self) -> None:
+ if self._port_is_available():
+ return
+ listener_pids = self._listener_pids()
+ if listener_pids is None:
+ raise RuntimeError(
+ "Cannot identify the process using the Articraft Viser port."
+ )
+ if not listener_pids:
+ raise RuntimeError(
+ f"Port {self._port} is unavailable without a visible listener."
+ )
+
+ self._signal_listeners(listener_pids, signal.SIGTERM, "stop")
+ if self._wait_for_port_release():
+ return
+
+ remaining_pids = self._listener_pids()
+ if remaining_pids is None:
+ raise RuntimeError(
+ f"Cannot identify the stale Viser service on port {self._port}."
+ )
+ self._signal_listeners(remaining_pids, signal.SIGKILL, "force-stop")
+ if not self._wait_for_port_release():
+ raise RuntimeError(
+ f"The stale Viser service is still listening on port {self._port}."
+ )
+
+ def _signal_listeners(
+ self, listener_pids: set[int], signal_value: int, action: str
+ ) -> None:
+ for pid in listener_pids:
+ try:
+ os.kill(pid, signal_value)
+ except ProcessLookupError:
+ continue
+ except PermissionError as exc:
+ raise RuntimeError(
+ f"Cannot {action} Viser process {pid} using port {self._port}."
+ ) from exc
+
+ def _wait_for_port_release(self) -> bool:
+ deadline = time.monotonic() + _VISER_STOP_TIMEOUT_SECONDS
+ while time.monotonic() < deadline:
+ if self._port_is_available():
+ return True
+ time.sleep(0.1)
+ return self._port_is_available()
+
+ def _wait_until_owned(self, process: subprocess.Popen[str]) -> bool:
+ deadline = time.monotonic() + _VISER_START_TIMEOUT_SECONDS
+ while time.monotonic() < deadline:
+ if process.poll() is not None:
+ return False
+ try:
+ with socket.create_connection(("127.0.0.1", self._port), timeout=0.2):
+ listener_pids = self._listener_pids()
+ if listener_pids is not None and process.pid in listener_pids:
+ return True
+ except OSError:
+ pass
+ time.sleep(0.25)
+ return False
+
+ def _port_is_available(self) -> bool:
+ probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ probe.bind(("0.0.0.0", self._port))
+ except OSError:
+ return False
+ finally:
+ probe.close()
+ return True
+
+ def _listener_pids(self) -> set[int] | None:
+ for command in self._listener_commands():
+ try:
+ result = subprocess.run(
+ command,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ timeout=5,
+ check=False,
+ )
+ except OSError:
+ continue
+ if result.returncode in (0, 1):
+ return {int(pid) for pid in result.stdout.split() if pid.isdecimal()}
+ return None
+
+ def _listener_commands(self) -> list[list[str]]:
+ commands: list[list[str]] = []
+ if lsof := _command_path("lsof"):
+ commands.append([lsof, "-nP", f"-iTCP:{self._port}", "-sTCP:LISTEN", "-t"])
+ if fuser := _command_path("fuser"):
+ commands.append([fuser, "-n", "tcp", str(self._port)])
+ return commands
+
+
+_articraft_viser_preview = _ArticraftViserPreview(ARTICRAFT_VISER_PORT)
+
+
+def stop_articraft_viser_preview() -> None:
+ """Stop the Viser subprocess currently owned by the Articraft panel.
+
+ The preview runs independently from Gradio so it can be embedded through an
+ iframe. Expose its cleanup explicitly so application shutdown can release
+ the dedicated port instead of leaving an orphaned Viser server behind.
+ """
+ _articraft_viser_preview.stop()
+
+
+atexit.register(stop_articraft_viser_preview)
+
+
+def _start_articraft_viser_preview(materialized: Path, record_id: str) -> str:
+ """Load the compiled URDF as an articulation and expose it through Viser."""
+ return _articraft_viser_preview.start(materialized / "model.urdf", record_id)
+
+
+def _external_check_is_unsupported(result: subprocess.CompletedProcess[str]) -> bool:
+ """Recognize the older Articraft CLI, which has no ``external check``."""
+ output = (result.stdout or "").lower()
+ return "invalid choice: 'check'" in output and "external" in output
+
+
+def _compile_report_failures(record_id: str) -> list[str]:
+ """Read blocking QC/test signals from the older CLI's compile report."""
+ report_path = (
+ ARTICRAFT_OUTPUT_ROOT
+ / "data"
+ / "cache"
+ / "record_materialization"
+ / record_id
+ / "compile_report.json"
+ )
+ try:
+ report = json.loads(report_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return [f"Compile report is unavailable: {report_path}"]
+ bundle = report.get("signal_bundle") if isinstance(report, dict) else None
+ signals = bundle.get("signals") if isinstance(bundle, dict) else None
+ if not isinstance(signals, list):
+ return ["Compile report contains no validation signals."]
+ failures: list[str] = []
+ for signal in signals:
+ if not isinstance(signal, dict):
+ continue
+ if signal.get("severity") == "failure" or signal.get("blocking") is True:
+ failures.append(
+ str(
+ signal.get("summary")
+ or signal.get("code")
+ or "Unnamed validation failure"
+ )
+ )
+ return failures
+
+
+def _build_codex_prompt(
+ *,
+ prompt: str,
+ record_id: str,
+ record_dir: Path,
+ model_path: Path,
+ reference_image: Path | None,
+) -> str:
+ image_note = (
+ f"A reference image is attached and also copied at {reference_image}. Use it as visual reference."
+ if reference_image
+ else "No reference image was supplied."
+ )
+ return f"""You are the Codex external author for one Articraft articulated 3D asset.
+
+User request:
+{prompt}
+
+{image_note}
+
+The Articraft source repository is {ARTICRAFT_ROOT}. The shared UI output/storage root is
+{ARTICRAFT_OUTPUT_ROOT}. Articraft has already created this external workbench record:
+record_id={record_id}
+record_dir={record_dir}
+active_model={model_path}
+
+Codex itself is launched from the Gradio environment, not the Articraft Conda environment.
+For every Articraft CLI invocation, use this command prefix:
+
+{_conda_path()} run --no-capture-output -n {ARTICRAFT_CONDA_ENV} python -m cli.main
+
+Follow EXTERNAL_AGENT_DATA.md exactly. Read the design and link-naming guidance it references,
+then use relevant SDK docs/examples. Edit only the active model.py for this record. Do not create
+record folders or metadata manually, edit unrelated records, commit/push, or promote this
+workbench record to the dataset.
+
+Create a realistic mechanically meaningful articulated object matching the request. Use semantic
+parts, visible plausible joints, appropriate materials, and prompt-specific run_tests(). Iterate
+until this succeeds:
+
+{_conda_path()} run --no-capture-output -n {ARTICRAFT_CONDA_ENV} python -m cli.main external --repo-root {ARTICRAFT_OUTPUT_ROOT} check {record_id}
+
+Then run:
+
+{_conda_path()} run --no-capture-output -n {ARTICRAFT_CONDA_ENV} python -m cli.main external --repo-root {ARTICRAFT_OUTPUT_ROOT} finalize {record_id}
+
+The Gradio app packages the compiled URDF and meshes after you finish. In your final response,
+briefly state the articulation mechanisms and validation result."""
+
+
+def generate_articraft_asset(prompt_value: str, image_value: Any):
+ """Initialize a record, let Codex author it, and expose one result bundle."""
+ token = _begin_articraft_generation()
+ prompt = (prompt_value or "").strip()
+ if not prompt:
+ if _articraft_generation_is_active(token):
+ yield None, "", "**Input error:** enter a description of the articulated object.", "", ""
+ return
+
+ details, errors, codex = _check_requirements()
+ if errors or not codex:
+ message = (
+ "\n".join(f"- {error}" for error in errors) or "Codex CLI is unavailable."
+ )
+ if _articraft_generation_is_active(token):
+ yield None, "", f"**Articulation is not ready.**\n\n{message}", "", ""
+ return
+
+ record_id = _record_id()
+ run_root = ARTICRAFT_OUTPUT_ROOT / "runs" / record_id
+ record_dir = ARTICRAFT_OUTPUT_ROOT / "data" / "records" / record_id
+ log_lines = [*details, f"Shared output: {ARTICRAFT_OUTPUT_ROOT}"]
+ try:
+ run_root.mkdir(parents=True, exist_ok=False)
+ reference_image = _copy_reference_image(image_value, run_root)
+ init_command = _articraft_cli_command(
+ "external",
+ "--repo-root",
+ str(ARTICRAFT_OUTPUT_ROOT),
+ "init",
+ "--agent",
+ "codex",
+ "--record-id",
+ record_id,
+ prompt,
+ )
+ log_lines.append("$ " + " ".join(init_command[:-1]) + " ")
+ initialized = _run_articraft_generation_check(
+ init_command, token=token, timeout=90
+ )
+ if initialized is None:
+ return
+ log_lines.append(_short_output(initialized, limit=4000))
+ if initialized.returncode:
+ yield None, "", "**Articraft record initialization failed.**", "\n".join(
+ log_lines
+ ), ""
+ return
+ model_path = _active_model_path(record_dir)
+ except Exception as exc:
+ if _articraft_generation_is_active(token):
+ yield None, "", f"**Setup failed:** {exc}", "\n".join(log_lines), ""
+ return
+
+ if not _articraft_generation_is_active(token):
+ return
+
+ final_message = run_root / "codex_final_message.txt"
+ codex_command = [
+ codex,
+ "exec",
+ "--sandbox",
+ "workspace-write",
+ "--color",
+ "never",
+ "-C",
+ str(ARTICRAFT_ROOT),
+ "--add-dir",
+ str(ARTICRAFT_OUTPUT_ROOT),
+ "--output-last-message",
+ str(final_message),
+ ]
+ if reference_image:
+ codex_command.extend(["--image", str(reference_image)])
+ codex_command.append(
+ _build_codex_prompt(
+ prompt=prompt,
+ record_id=record_id,
+ record_dir=record_dir,
+ model_path=model_path,
+ reference_image=reference_image,
+ )
+ )
+ log_lines.append("$ codex exec --sandbox workspace-write …")
+ yield None, record_dir.as_posix(), "**Codex is generating and validating the Articraft model…**", "\n".join(
+ log_lines
+ ), ""
+
+ try:
+ process = register_managed_process(
+ subprocess.Popen(
+ codex_command,
+ cwd=ARTICRAFT_ROOT,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ start_new_session=True,
+ env=os.environ.copy(),
+ )
+ )
+ if not _set_articraft_generation_process(token, process):
+ terminate_process_group(process)
+ return
+ except Exception as exc:
+ if _articraft_generation_is_active(token):
+ yield None, record_dir.as_posix(), f"**Codex could not start:** {exc}", "\n".join(
+ log_lines
+ ), ""
+ return
+
+ output_queue: queue.Queue[str] = queue.Queue()
+ reader = threading.Thread(
+ target=read_process_output, args=(process, output_queue), daemon=True
+ )
+ reader.start()
+ while process.poll() is None:
+ if not _articraft_generation_is_active(token, process):
+ return
+ try:
+ while True:
+ log_lines.append(output_queue.get_nowait())
+ except queue.Empty:
+ pass
+ yield None, record_dir.as_posix(), "**Codex is generating and validating the Articraft model…**", "\n".join(
+ log_lines[-240:]
+ ), ""
+ time.sleep(0.75)
+ try:
+ reader.join(timeout=2)
+ try:
+ while True:
+ log_lines.append(output_queue.get_nowait())
+ except queue.Empty:
+ pass
+ finally:
+ _finish_articraft_generation_process(token, process)
+
+ if not _articraft_generation_is_active(token):
+ return
+
+ if final_message.is_file():
+ final_text = final_message.read_text(encoding="utf-8", errors="replace").strip()
+ if final_text:
+ log_lines.append("\nCodex final response:\n" + final_text)
+ if process.returncode:
+ yield None, record_dir.as_posix(), f"**Codex generation failed** (exit code {process.returncode}).", "\n".join(
+ log_lines[-300:]
+ ), ""
+ return
+
+ # Do not rely solely on Codex's final message: independently run the
+ # external validation and finalize gates before exposing an output bundle.
+ check_command = _articraft_cli_command(
+ "external",
+ "--repo-root",
+ str(ARTICRAFT_OUTPUT_ROOT),
+ "check",
+ record_id,
+ )
+ log_lines.append("$ " + " ".join(check_command))
+ yield (
+ None,
+ record_dir.as_posix(),
+ "**Codex finished. Articraft is running the final validation gate…**",
+ "\n".join(log_lines[-300:]),
+ "",
+ )
+ try:
+ checked = _run_articraft_generation_check(
+ check_command, token=token, timeout=300
+ )
+ if checked is None:
+ return
+ log_lines.append(_short_output(checked, limit=5000))
+ except Exception as exc:
+ yield (
+ None,
+ record_dir.as_posix(),
+ f"**Final Articraft validation could not run:** {exc}",
+ "\n".join(log_lines[-300:]),
+ "",
+ )
+ return
+ if checked.returncode:
+ if not _external_check_is_unsupported(checked):
+ yield (
+ None,
+ record_dir.as_posix(),
+ "**Articraft validation failed; no output bundle was published.**",
+ "\n".join(log_lines[-300:]),
+ "",
+ )
+ return
+ # The older CLI reports external init/finalize/categories only. Its
+ # equivalent strict model validation is the top-level compile command.
+ compile_command = _articraft_cli_command(
+ "compile",
+ "--repo-root",
+ str(ARTICRAFT_OUTPUT_ROOT),
+ "--target",
+ "full",
+ "--validate",
+ "--strict-geom-qc",
+ record_id,
+ )
+ log_lines.append(
+ "external check is unavailable; falling back to compile --validate."
+ )
+ log_lines.append("$ " + " ".join(compile_command))
+ yield (
+ None,
+ record_dir.as_posix(),
+ "**Using this Articraft version's compile validation gate…**",
+ "\n".join(log_lines[-300:]),
+ "",
+ )
+ try:
+ compiled = _run_articraft_generation_check(
+ compile_command, token=token, timeout=300
+ )
+ if compiled is None:
+ return
+ log_lines.append(_short_output(compiled, limit=5000))
+ except Exception as exc:
+ yield (
+ None,
+ record_dir.as_posix(),
+ f"**Fallback Articraft validation could not run:** {exc}",
+ "\n".join(log_lines[-300:]),
+ "",
+ )
+ return
+ if compiled.returncode:
+ yield (
+ None,
+ record_dir.as_posix(),
+ "**Articraft validation failed; no output bundle was published.**",
+ "\n".join(log_lines[-300:]),
+ "",
+ )
+ return
+ failures = _compile_report_failures(record_id)
+ if failures:
+ log_lines.append("Blocking compile-report failures: " + "; ".join(failures))
+ yield (
+ None,
+ record_dir.as_posix(),
+ "**Articraft validation found blocking model defects; no output bundle was published.**",
+ "\n".join(log_lines[-300:]),
+ "",
+ )
+ return
+
+ finalize_command = _articraft_cli_command(
+ "external",
+ "--repo-root",
+ str(ARTICRAFT_OUTPUT_ROOT),
+ "finalize",
+ record_id,
+ )
+ log_lines.append("$ " + " ".join(finalize_command))
+ try:
+ finalized = _run_articraft_generation_check(
+ finalize_command, token=token, timeout=300
+ )
+ if finalized is None:
+ return
+ log_lines.append(_short_output(finalized, limit=5000))
+ except Exception as exc:
+ yield (
+ None,
+ record_dir.as_posix(),
+ f"**Articraft finalization could not run:** {exc}",
+ "\n".join(log_lines[-300:]),
+ "",
+ )
+ return
+ if finalized.returncode:
+ yield (
+ None,
+ record_dir.as_posix(),
+ "**Articraft finalization failed; no output bundle was published.**",
+ "\n".join(log_lines[-300:]),
+ "",
+ )
+ return
+
+ if not _articraft_generation_is_active(token):
+ return
+ try:
+ materialized, archive = _make_result_bundle(record_id)
+ status = (
+ "**Articraft generation completed and passed the Codex validation workflow.**\n\n"
+ f"- Record: `{record_dir}`\n- Compiled output: `{materialized}`\n- Downloadable bundle: `{archive}`"
+ )
+ try:
+ preview_html = _start_articraft_viser_preview(materialized, record_id)
+ status += "\n- Interactive Viser preview: ready"
+ except Exception as exc:
+ preview_html = ""
+ status += f"\n- Interactive Viser preview could not start: `{exc}`"
+ log_lines.append(f"Viser preview failed: {exc}")
+ yield archive.as_posix(), record_dir.as_posix(), status, "\n".join(
+ log_lines[-300:]
+ ), preview_html
+ except Exception as exc:
+ yield None, record_dir.as_posix(), f"**Codex finished, but result packaging failed:** {exc}", "\n".join(
+ log_lines[-300:]
+ ), ""
+
+
+def build_articraft_panel() -> None:
+ """Render the Articraft tab inside the Asset engine."""
+ gr.Markdown(
+ "### Articulation\n"
+ "Generate an articulated object from text and an optional reference image. Codex writes and validates the Articraft model; only submit trusted requests."
+ )
+ with gr.Row():
+ configure_button = gr.Button("Configure Articulation & check Codex")
+ generate_button = gr.Button("Generate articulation", variant="primary")
+ reset_button = gr.Button("Reset Articulation", variant="stop")
+ environment_status = gr.Markdown("**Environment:** not checked.")
+ with gr.Row():
+ prompt = gr.Textbox(
+ label="Articulated object description",
+ lines=5,
+ placeholder="e.g. A countertop toaster oven with a hinged door and rotating temperature knob.",
+ )
+ image = gr.Image(
+ label="Optional reference image",
+ type="filepath",
+ image_mode="RGB",
+ sources=["upload"],
+ )
+ with gr.Row():
+ output_file = gr.File(
+ label="Compiled Articulation result bundle (.zip)", interactive=False
+ )
+ record_folder = gr.Textbox(
+ label="Articulation record folder", interactive=False
+ )
+ articulation_preview = gr.HTML(_ARTICRAFT_IDLE_PREVIEW)
+ generation_status = gr.Markdown("**Status:** waiting for a description.")
+ generation_log = gr.Textbox(
+ label="Codex / Articraft log", lines=14, interactive=False
+ )
+
+ configure_button.click(
+ configure_articraft_environment, outputs=[environment_status], queue=False
+ )
+ generate_button.click(
+ generate_articraft_asset,
+ inputs=[prompt, image],
+ outputs=[
+ output_file,
+ record_folder,
+ generation_status,
+ generation_log,
+ articulation_preview,
+ ],
+ )
+ reset_button.click(
+ reset_articraft_asset,
+ outputs=[
+ environment_status,
+ prompt,
+ image,
+ output_file,
+ record_folder,
+ generation_status,
+ generation_log,
+ articulation_preview,
+ ],
+ queue=False,
+ )
diff --git a/embodichain/gen_sim/gradio_ui/app_asset_engine.py b/embodichain/gen_sim/gradio_ui/app_asset_engine.py
new file mode 100644
index 000000000..5acfbfb76
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_asset_engine.py
@@ -0,0 +1,369 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Standalone SimReady asset-engine workflow used by Debug mode.
+
+The upstream SimReady CLI works on a directory, while Gradio uploads files.
+This adapter creates an isolated directory for every run, keeps material
+sidecars together with the mesh, and exposes GLB previews before and after
+processing. It deliberately has no DexSim dependency.
+"""
+
+from __future__ import annotations
+
+import queue
+import shutil
+import subprocess
+import sys
+import threading
+import time
+import uuid
+from pathlib import Path
+from typing import Any, Iterable
+
+import gradio as gr
+import trimesh
+
+from app_articraft import build_articraft_panel
+from app_config import DEBUG_ASSET_ENGINE_ROOT, SIMREADY_MESH_SUFFIXES
+from app_env import EMBODICHAIN_ROOT
+from app_processes import read_process_output, start_pipeline, terminate_process_group
+
+_simready_run_lock = threading.Lock()
+_simready_process: subprocess.Popen[str] | None = None
+_simready_run_token: str | None = None
+_SIMREADY_IDLE_STATUS = "**Status:** waiting for an asset."
+
+
+def _begin_simready_run() -> str:
+ """Invalidate any previous SimReady run and return a new ownership token."""
+ global _simready_process, _simready_run_token
+ with _simready_run_lock:
+ previous_process = _simready_process
+ _simready_process = None
+ token = uuid.uuid4().hex
+ _simready_run_token = token
+ if previous_process is not None:
+ terminate_process_group(previous_process)
+ return token
+
+
+def _simready_run_is_active(
+ token: str, process: subprocess.Popen[str] | None = None
+) -> bool:
+ with _simready_run_lock:
+ return _simready_run_token == token and (
+ process is None or _simready_process is process
+ )
+
+
+def _finish_simready_run(
+ token: str, process: subprocess.Popen[str] | None = None
+) -> None:
+ global _simready_process
+ with _simready_run_lock:
+ if _simready_run_token == token and (
+ process is None or _simready_process is process
+ ):
+ _simready_process = None
+
+
+def reset_simready_asset():
+ """Clear SimReady widgets and terminate the process group for its active run."""
+ global _simready_process, _simready_run_token
+ with _simready_run_lock:
+ process = _simready_process
+ _simready_process = None
+ _simready_run_token = None
+ if process is not None:
+ terminate_process_group(process)
+ return None, "rigid_object", None, None, None, _SIMREADY_IDLE_STATUS, ""
+
+
+def _as_paths(value: Any) -> list[Path]:
+ if value is None:
+ return []
+ values: Iterable[Any] = value if isinstance(value, (list, tuple)) else [value]
+ paths: list[Path] = []
+ for item in values:
+ if isinstance(item, str):
+ paths.append(Path(item))
+ elif isinstance(item, dict) and item.get("path"):
+ paths.append(Path(item["path"]))
+ return [path for path in paths if path.is_file()]
+
+
+def _mesh_path(paths: Iterable[Path]) -> Path:
+ meshes = [path for path in paths if path.suffix.lower() in SIMREADY_MESH_SUFFIXES]
+ if not meshes:
+ supported = ", ".join(sorted(SIMREADY_MESH_SUFFIXES))
+ raise ValueError(
+ f"Upload one mesh file ({supported}) and optional material files."
+ )
+ return meshes[0]
+
+
+def _safe_copy_uploads(upload_paths: list[Path], destination: Path) -> Path:
+ destination.mkdir(parents=True, exist_ok=False)
+ copied: list[Path] = []
+ for index, source in enumerate(upload_paths):
+ # Upload file names are untrusted. Keep only their basename and avoid
+ # collisions without ever interpreting a supplied relative path.
+ name = source.name or f"upload_{index}"
+ target = destination / name
+ if target.exists():
+ target = destination / f"{target.stem}_{index}{target.suffix}"
+ shutil.copy2(source, target)
+ copied.append(target)
+ return _mesh_path(copied)
+
+
+def _export_preview(mesh_path: Path, destination: Path) -> Path:
+ """Convert every supported mesh type to GLB for one consistent viewer."""
+ loaded = trimesh.load(mesh_path, force="scene", process=False)
+ if isinstance(loaded, trimesh.Trimesh):
+ scene = trimesh.Scene(loaded)
+ elif isinstance(loaded, trimesh.Scene):
+ scene = loaded
+ else:
+ raise ValueError(f"Unsupported mesh payload: {type(loaded)!r}")
+ if not scene.geometry:
+ raise ValueError("The uploaded asset contains no renderable geometry.")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ scene.export(destination)
+ return destination
+
+
+def prepare_asset_input_preview(upload_value: Any):
+ """Validate an upload and return a normalized GLB preview without running SimReady."""
+ try:
+ source = _mesh_path(_as_paths(upload_value))
+ preview = DEBUG_ASSET_ENGINE_ROOT / "previews" / f"{uuid.uuid4().hex}.glb"
+ _export_preview(source, preview)
+ return (
+ preview.as_posix(),
+ "**Asset input ready.** Review the model, then run SimReady.",
+ )
+ except Exception as exc:
+ return None, f"**Input error:** {exc}"
+
+
+def _find_simready_output(output_root: Path) -> Path:
+ candidates = sorted(
+ output_root.rglob("asset_simready.glb"),
+ key=lambda path: path.stat().st_mtime_ns,
+ reverse=True,
+ )
+ if not candidates:
+ candidates = sorted(
+ output_root.rglob("asset_simready.obj"),
+ key=lambda path: path.stat().st_mtime_ns,
+ reverse=True,
+ )
+ if not candidates:
+ raise FileNotFoundError(
+ "SimReady completed without asset_simready.glb or asset_simready.obj."
+ )
+ return candidates[0]
+
+
+def run_simready_asset(upload_value: Any, category: str):
+ """Run one upstream SimReady job and stream concise subprocess progress."""
+ global _simready_process
+ token = _begin_simready_run()
+ category = (category or "").strip()
+ if not category:
+ if _simready_run_is_active(token):
+ yield None, None, None, "**Input error:** enter an asset category.", ""
+ return
+ try:
+ uploads = _as_paths(upload_value)
+ _mesh_path(uploads)
+ run_root = DEBUG_ASSET_ENGINE_ROOT / "runs" / uuid.uuid4().hex
+ input_dir = run_root / "input"
+ output_root = run_root / "output"
+ source_mesh = _safe_copy_uploads(uploads, input_dir)
+ input_preview = _export_preview(source_mesh, run_root / "input_preview.glb")
+ except Exception as exc:
+ if _simready_run_is_active(token):
+ yield None, None, None, f"**Input error:** {exc}", ""
+ return
+
+ command = [
+ sys.executable,
+ "-m",
+ "embodichain.gen_sim.simready_pipeline.cli.start",
+ "--input_dir",
+ str(input_dir),
+ "--output_root",
+ str(output_root),
+ "--category",
+ category,
+ ]
+ log_lines = ["$ " + " ".join(command)]
+ if not _simready_run_is_active(token):
+ return
+ yield input_preview.as_posix(), None, None, "**SimReady is running…**", "\n".join(
+ log_lines
+ )
+
+ try:
+ process = start_pipeline(command)
+ except Exception as exc:
+ if _simready_run_is_active(token):
+ yield input_preview.as_posix(), None, None, f"**Pipeline start failed:** {exc}", "\n".join(
+ log_lines
+ )
+ return
+
+ with _simready_run_lock:
+ owns_process = _simready_run_token == token
+ if owns_process:
+ _simready_process = process
+ if not owns_process:
+ terminate_process_group(process)
+ return
+
+ try:
+ output_queue: queue.Queue[str] = queue.Queue()
+ reader = threading.Thread(
+ target=read_process_output, args=(process, output_queue), daemon=True
+ )
+ reader.start()
+ while process.poll() is None:
+ if not _simready_run_is_active(token, process):
+ return
+ try:
+ while True:
+ log_lines.append(output_queue.get_nowait())
+ except queue.Empty:
+ pass
+ # Keep the browser responsive while the Blender/LLM stages run.
+ yield input_preview.as_posix(), None, None, "**SimReady is running…**", "\n".join(
+ log_lines[-160:]
+ )
+ time.sleep(0.5)
+ reader.join(timeout=1)
+ try:
+ while True:
+ log_lines.append(output_queue.get_nowait())
+ except queue.Empty:
+ pass
+ if not _simready_run_is_active(token, process):
+ return
+
+ if process.returncode != 0:
+ yield input_preview.as_posix(), None, None, f"**SimReady failed** (exit code {process.returncode}).", "\n".join(
+ log_lines[-220:]
+ )
+ return
+ try:
+ result = _find_simready_output(output_root)
+ preview = (
+ result
+ if result.suffix.lower() == ".glb"
+ else _export_preview(result, run_root / "output_preview.glb")
+ )
+ yield input_preview.as_posix(), preview.as_posix(), result.as_posix(), "**SimReady completed.**", "\n".join(
+ log_lines[-220:]
+ )
+ except Exception as exc:
+ yield input_preview.as_posix(), None, None, f"**Output error:** {exc}", "\n".join(
+ log_lines[-220:]
+ )
+ finally:
+ _finish_simready_run(token, process)
+
+
+def build_asset_engine_panel() -> dict[str, Any]:
+ """Create the Debug Asset-engine panel and return its event endpoints."""
+ with gr.Column(visible=True) as panel:
+ gr.Markdown(
+ "## Asset engine\nConvert an existing mesh with SimReady, or generate a new articulated asset through Articraft and Codex. DexSim is not started in this engine."
+ )
+ with gr.Tabs():
+ with gr.Tab("SimReady"):
+ with gr.Row():
+ uploads = gr.File(
+ label="3D asset and optional material files",
+ file_count="multiple",
+ type="filepath",
+ file_types=[
+ ".glb",
+ ".gltf",
+ ".obj",
+ ".ply",
+ ".stl",
+ ".mtl",
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".webp",
+ ".bin",
+ ],
+ )
+ category = gr.Textbox(
+ label="Asset category",
+ value="rigid_object",
+ placeholder="e.g. cup, chair, bottle",
+ )
+ with gr.Row():
+ input_model = gr.Model3D(
+ label="Input asset preview",
+ height=440,
+ clear_color=(0.94, 0.94, 0.94, 1.0),
+ )
+ output_model = gr.Model3D(
+ label="SimReady asset preview",
+ height=440,
+ clear_color=(0.94, 0.94, 0.94, 1.0),
+ )
+ with gr.Row():
+ run_button = gr.Button("Run SimReady", variant="primary")
+ reset_button = gr.Button("Reset SimReady", variant="stop")
+ output_file = gr.File(
+ label="SimReady asset output", interactive=False
+ )
+ status = gr.Markdown(_SIMREADY_IDLE_STATUS)
+ log = gr.Textbox(label="Pipeline log", lines=10, interactive=False)
+ with gr.Tab("Articulation"):
+ build_articraft_panel()
+
+ uploads.change(
+ prepare_asset_input_preview,
+ inputs=[uploads],
+ outputs=[input_model, status],
+ queue=False,
+ )
+ run_button.click(
+ run_simready_asset,
+ inputs=[uploads, category],
+ outputs=[input_model, output_model, output_file, status, log],
+ )
+ reset_button.click(
+ reset_simready_asset,
+ outputs=[
+ uploads,
+ category,
+ input_model,
+ output_model,
+ output_file,
+ status,
+ log,
+ ],
+ queue=False,
+ )
+ return {"panel": panel}
diff --git a/embodichain/gen_sim/gradio_ui/app_commands.py b/embodichain/gen_sim/gradio_ui/app_commands.py
new file mode 100644
index 000000000..7dc1f4a1c
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_commands.py
@@ -0,0 +1,173 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""CLI command builders for EmbodiChain pipelines."""
+
+from __future__ import annotations
+
+import sys
+from typing import Protocol
+
+from app_config import (
+ COMMANDS,
+ ROBOT_PROFILE_FRANKA,
+ ROBOT_PROFILE_UR5,
+ ROBOT_PROFILE_UR10,
+ SCENE_ID,
+)
+
+
+class ScenePathsLike(Protocol):
+ scene_id: str
+ image_path: object
+ fast_gym_config: object
+ agent_config: object
+
+
+def robot_profile_cli_value(robot_profile: str | None) -> str | None:
+ return {
+ ROBOT_PROFILE_FRANKA: "franka",
+ ROBOT_PROFILE_UR5: "dual_ur5",
+ ROBOT_PROFILE_UR10: "dual_ur10",
+ }.get(robot_profile)
+
+
+def _pipeline_paths(paths: ScenePathsLike) -> tuple[str, str]:
+ return (
+ f"gym_project/{paths.scene_id}",
+ f"gym_project/action_agent_pipeline/configs/{paths.scene_id}",
+ )
+
+
+def build_initial_pipeline_command(
+ task_text: str,
+ paths: ScenePathsLike,
+ prompt2scene_prompt: str = "",
+ robot_profile: str | None = None,
+ load_template_material: bool = False,
+) -> list[str]:
+ prompt_root, config_dir = _pipeline_paths(paths)
+ command = [
+ sys.executable,
+ "-m",
+ COMMANDS["pipeline"]["module"],
+ "--image",
+ str(paths.image_path.resolve()),
+ "--prompt2scene-output-root",
+ prompt_root,
+ "--config-output-dir",
+ config_dir,
+ "--task_name",
+ SCENE_ID,
+ "--task_description",
+ task_text,
+ *COMMANDS["pipeline"]["base_args"],
+ ]
+ if profile := robot_profile_cli_value(robot_profile):
+ command.extend(["--robot-profile", profile])
+ if prompt2scene_prompt.strip():
+ command.extend(["--prompt2scene-prompt", prompt2scene_prompt.strip()])
+ if load_template_material:
+ command.append("--load-template-material")
+ return command
+
+
+def build_scene_edit_pipeline_command(
+ task_text: str,
+ env_text: str,
+ paths: ScenePathsLike,
+ robot_profile: str | None = None,
+ load_template_material: bool = False,
+) -> list[str]:
+ prompt_root, config_dir = _pipeline_paths(paths)
+ command = [
+ sys.executable,
+ "-m",
+ COMMANDS["pipeline"]["module"],
+ "--prompt2scene-output-root",
+ prompt_root,
+ "--prompt2scene-prompt",
+ env_text,
+ "--config-output-dir",
+ config_dir,
+ "--task_name",
+ SCENE_ID,
+ "--task_description",
+ task_text,
+ *COMMANDS["pipeline"]["base_args"],
+ ]
+ if profile := robot_profile_cli_value(robot_profile):
+ command.extend(["--robot-profile", profile])
+ if load_template_material:
+ command.append("--load-template-material")
+ return command
+
+
+def build_config_command_for_paths(
+ task_text: str,
+ paths: ScenePathsLike,
+ robot_profile: str | None = None,
+ load_template_material: bool = False,
+) -> list[str]:
+ _, config_dir = _pipeline_paths(paths)
+ command = [
+ sys.executable,
+ "-m",
+ COMMANDS["config"]["module"],
+ "--gym_project",
+ f"gym_project/{paths.scene_id}/gym_export",
+ "--output_dir",
+ config_dir,
+ "--task_name",
+ SCENE_ID,
+ "--task_description",
+ task_text,
+ *COMMANDS["config"]["base_args"],
+ ]
+ if profile := robot_profile_cli_value(robot_profile):
+ command.extend(["--robot-profile", profile])
+ if load_template_material:
+ command.append("--load-template-material")
+ return command
+
+
+def build_run_agent_command(
+ paths: ScenePathsLike,
+ *,
+ parallel_env: bool = False,
+ robot_profile: str | None = None,
+ supports_robot_profile: bool = False,
+) -> list[str]:
+ agent = COMMANDS["agent"]
+ command = [
+ sys.executable,
+ "-m",
+ agent["module"],
+ "--task_name",
+ SCENE_ID,
+ "--gym_config",
+ str(paths.fast_gym_config),
+ "--agent_config",
+ str(paths.agent_config),
+ *agent["base_args"],
+ "--num_envs",
+ agent["parallel_num_envs"] if parallel_env else agent["single_num_envs"],
+ ]
+ if parallel_env:
+ command.extend(agent["parallel_args"])
+ if supports_robot_profile and (profile := robot_profile_cli_value(robot_profile)):
+ command.extend(["--robot-profile", profile])
+ return command
diff --git a/embodichain/gen_sim/gradio_ui/app_config.py b/embodichain/gen_sim/gradio_ui/app_config.py
new file mode 100644
index 000000000..21085ea53
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_config.py
@@ -0,0 +1,233 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Static settings and helpers for the Gradio application.
+
+Deployment-specific settings live in :mod:`app_env`, which reads the shared
+``embodichain/gen_sim/.env`` file. This module keeps UI constants, path
+derivation, and CLI command definitions close to the application code.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import app_env
+
+APP_ROOT = Path(__file__).resolve().parent
+ASSETS_DIR = APP_ROOT / "assets"
+DEXFORCE_LOGO = ASSETS_DIR / "dexforce.png"
+INTERACT_RANDOM_PREVIEW_DIR = APP_ROOT / ".gradio_previews"
+DEBUG_ENGINE_ROOT = APP_ROOT / ".debug_engine"
+DEBUG_ASSET_ENGINE_ROOT = DEBUG_ENGINE_ROOT / "assets"
+DEBUG_SCENE_ENGINE_ROOT = DEBUG_ENGINE_ROOT / "scenes"
+SCENE_ID = "current"
+
+GYM_PROJECT_ROOT = app_env.EMBODICHAIN_ROOT / "gym_project"
+ACTION_AGENT_ROOT = GYM_PROJECT_ROOT / "action_agent_pipeline"
+IMAGE_DIR = ACTION_AGENT_ROOT / "images"
+AUTO_LOG_DIR = ACTION_AGENT_ROOT / "auto_logs"
+IMAGE_PATH = IMAGE_DIR / f"{SCENE_ID}.png"
+PROMPT2SCENE_ROOT = GYM_PROJECT_ROOT / SCENE_ID
+CONFIG_DIR = ACTION_AGENT_ROOT / "configs" / SCENE_ID
+FAST_GYM_CONFIG = CONFIG_DIR / "fast_gym_config.json"
+OUTPUTS_DIR = app_env.EMBODICHAIN_ROOT / "outputs"
+CURRENT_GYM_EXPORT_DIR = PROMPT2SCENE_ROOT / "gym_export"
+CURRENT_GYM_EXPORT_CONFIG = CURRENT_GYM_EXPORT_DIR / "gym_config.json"
+GRADIO_SCENE_DIR = CONFIG_DIR / "gradio_scene"
+GRADIO_SCENE_GLB = GRADIO_SCENE_DIR / "scene_current.glb"
+GRADIO_INITIAL_SCENE_GLB = GRADIO_SCENE_DIR / "initial_scene.glb"
+GRADIO_OBJECT_PREVIEW_GLB = GRADIO_SCENE_DIR / "object_preview.glb"
+SCENE_MANIFEST = GRADIO_SCENE_DIR / "scene_manifest.json"
+PENDING_PREFIX = "_gradio_pending_"
+REPLACED_PREFIX = "_gradio_replaced_"
+GRADIO_SCENE_TRANSFORM_POLICY = "dexsim_gltf_y_up_to_sim_z_up_v1"
+
+PROCESS_STOP_TIMEOUT_S = 8.0
+TEXT_REWRITE_SUFFIXES = {".json", ".jsonl", ".txt", ".yaml", ".yml", ".md", ".csv"}
+VIDEO_SUFFIXES = {".mp4", ".avi", ".mov", ".mkv", ".webm"}
+LEROBOT_PREVIEW_DIR = OUTPUTS_DIR / "lerobot_previews"
+COMBINED_PREVIEW_DIR = OUTPUTS_DIR / "combined_previews"
+LEROBOT_PREVIEW_MAX_FRAMES = 360
+COMBINED_VIDEO_FPS = 25
+
+TOP_MODE_AUTO = "auto"
+TOP_MODE_INTERACT = "interact"
+TOP_MODE_PARALLEL_ENV = "parallel_env"
+APP_MODE_DEMO = "demo"
+APP_MODE_DEBUG = "debug"
+DEBUG_ENGINE_ASSET = "asset_engine"
+DEBUG_ENGINE_SCENE = "scene_engine"
+DEBUG_ENGINE_ACTION = "action_engine"
+DEBUG_ENGINES = (
+ (DEBUG_ENGINE_ASSET, "Asset_engine"),
+ (DEBUG_ENGINE_SCENE, "Scene_engine"),
+ (DEBUG_ENGINE_ACTION, "Action_engine"),
+)
+
+# SimReady accepts one mesh plus optional material/texture sidecar files. The
+# File component deliberately permits the sidecars so OBJ/GLTF uploads retain
+# their appearance during both preview and processing.
+SIMREADY_MESH_SUFFIXES = {".glb", ".gltf", ".obj", ".ply", ".stl"}
+
+LANGUAGE_EN = "en"
+LANGUAGE_ZH = "zh"
+BUTTON_LABELS = {
+ LANGUAGE_EN: {
+ "auto": "Auto",
+ "interact": "Interact",
+ "parallel_env": "Parallel Simulation",
+ "rerun_simulation": "Run Task",
+ "generate": "Generate",
+ "start": "Start",
+ "random_input": "Random Task",
+ "random_scene_input": "Random Scene",
+ "reset": "Reset",
+ "stop": "Stop",
+ "language": "中文",
+ },
+ LANGUAGE_ZH: {
+ "auto": "自动",
+ "interact": "交互",
+ "parallel_env": "并行仿真",
+ "rerun_simulation": "运行任务",
+ "generate": "生成",
+ "start": "开始",
+ "random_input": "随机任务",
+ "random_scene_input": "随机场景",
+ "reset": "重置",
+ "stop": "停止",
+ "language": "English",
+ },
+}
+UI_TEXT = {
+ LANGUAGE_EN: {
+ "heading": "# Generative Simulation User Interface",
+ "instruction": "Upload one image, enter one task, then EmbodiChain will generate simulation data what you want.",
+ "robot": "Robot",
+ "input_image": "Input image",
+ "task_description": "Task description",
+ "task_placeholder": "Put the middle bottle on the book",
+ "scene_description": "Scene description",
+ "scene_placeholder": "Optional: describe how to edit the current scene",
+ "scene_mode": "Generation mode",
+ "scene_mode_initial": "Initial generation",
+ "scene_mode_edit": "Edit current scene",
+ "scene_mode_task_only": "Change task only",
+ "single_video_preview": "LeRobot Data Preview",
+ "parallel_video_preview": "Parallel Env Data Preview",
+ "current_task": "Current task",
+ "progress": "Progress",
+ "initial_preview": "Initial scene preview",
+ "edited_preview": "Edited scene preview",
+ "object_preview": "Generated object GLBs preview",
+ },
+ LANGUAGE_ZH: {
+ "heading": "# 生成式仿真用户界面",
+ "instruction": "上传一张图片,输入一个任务,EmbodiChain 将生成所需的仿真数据。",
+ "robot": "机器人",
+ "input_image": "输入图像",
+ "task_description": "任务描述",
+ "task_placeholder": "把中间的水瓶放到书上",
+ "scene_description": "场景描述",
+ "scene_placeholder": "可选:描述如何编辑当前场景",
+ "scene_mode": "生成模式",
+ "scene_mode_initial": "初始生成",
+ "scene_mode_edit": "编辑当前场景",
+ "scene_mode_task_only": "仅修改任务",
+ "single_video_preview": "LeRobot 数据预览",
+ "parallel_video_preview": "并行环境数据预览",
+ "current_task": "当前任务",
+ "progress": "进度",
+ "initial_preview": "初始场景预览",
+ "edited_preview": "编辑后场景预览",
+ "object_preview": "生成对象 GLB 预览",
+ },
+}
+
+PIPELINE_MODE_INITIAL = "initial"
+PIPELINE_MODE_EDIT = "edit"
+PIPELINE_MODE_TASK_ONLY = "task_only"
+SCENE_MODE_INITIAL = "initial"
+SCENE_MODE_EDIT = "edit"
+SCENE_MODE_TASK_ONLY = "task_only"
+ROBOT_PROFILE_FRANKA = "Franka"
+ROBOT_PROFILE_UR5 = "UR5"
+ROBOT_PROFILE_UR10 = "UR10"
+ROBOT_PROFILES = [ROBOT_PROFILE_FRANKA, ROBOT_PROFILE_UR5, ROBOT_PROFILE_UR10]
+DEFAULT_ROBOT_PROFILE = ROBOT_PROFILE_UR5
+RUN_LOG_MODE_AUTO = "auto"
+RUN_LOG_MODE_INTERACT = "interact"
+
+# Command modules and immutable argument defaults. Dynamic values are added by
+# command builders in app_commands.py.
+COMMANDS = {
+ "pipeline": {
+ "module": "embodichain.gen_sim.action_agent_pipeline.cli.run_agent_pipeline",
+ "base_args": (
+ "--use-prompt2scene",
+ "--overwrite-config",
+ "--regenerate",
+ "--skip-run-agent",
+ ),
+ },
+ "config": {
+ "module": "embodichain.gen_sim.action_agent_pipeline.cli.generate_action_agent_config",
+ "base_args": ("--overwrite",),
+ },
+ "agent": {
+ "module": "embodichain.gen_sim.action_agent_pipeline.cli.run_agent",
+ "help_args": ("--help",),
+ "base_args": ("--regenerate", "--renderer", "fast-rt"),
+ "parallel_args": ("--arena_space", "2.2", "--filter_dataset_saving"),
+ "parallel_num_envs": "9",
+ "single_num_envs": "1",
+ },
+ # Scene Engine is dispatched by EmbodiChain's registered top-level CLI.
+ "scene_engine": {
+ "module": "embodichain",
+ "base_args": ("scene-engine",),
+ "preview_script": "embodichain/gen_sim/scene_engine/cli/preview.py",
+ },
+}
+
+PHASE_DEFINITIONS = {
+ "idle": (0, "Idle"),
+ "received": (5, "Input received"),
+ "started": (10, "Local pipeline started"),
+ "scene_intake": (20, "Scene understanding"),
+ "relations": (35, "Segmentation and spatial relations"),
+ "asset_generation": (55, "3D asset generation"),
+ "gym_export": (70, "Scene export"),
+ "config": (82, "Action config generated"),
+ "preview": (90, "3D preview loaded"),
+ "complete": (100, "Complete"),
+ "failed": (100, "Failed"),
+}
+TIMING_PHASE_LABELS = {
+ "relations": "Segmentation / spatial relations",
+ "asset_generation": "Object generation",
+ "gym_export": "Scene generation / export",
+ "action_graph_execution": "Action graph execution",
+}
+TIMING_PHASE_ORDER = (
+ "relations",
+ "asset_generation",
+ "gym_export",
+ "action_graph_execution",
+)
+
+DEFAULT_CONCURRENCY_LIMIT = 1
diff --git a/embodichain/gen_sim/gradio_ui/app_env.py b/embodichain/gen_sim/gradio_ui/app_env.py
new file mode 100644
index 000000000..f9227e25e
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_env.py
@@ -0,0 +1,113 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Environment-backed deployment settings for the Gradio application."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any
+
+from embodichain.gen_sim.env import get_embodichain_root, load_gen_sim_env
+
+__all__ = [
+ "ARTICRAFT_CONDA_ENV",
+ "ARTICRAFT_OUTPUT_ROOT",
+ "ARTICRAFT_REPOSITORY_URL",
+ "ARTICRAFT_ROOT",
+ "ARTICRAFT_VISER_PORT",
+ "DIRECT_NO_PROXY_VALUE",
+ "EMBODICHAIN_ROOT",
+ "PROXY_ENV_KEYS",
+ "SCENE_ENGINE_VISER_PORT",
+ "SERVER_NAME",
+ "SERVER_PORT",
+ "SIMREADY_OPENAI_API_KEY",
+ "SIMREADY_OPENAI_BASE_URL",
+ "SIMREADY_OPENAI_MODEL",
+ "configure_direct_network_env",
+ "configure_simready_llm_env",
+]
+
+load_gen_sim_env()
+
+APP_ROOT = Path(__file__).resolve().parent
+DEBUG_ENGINE_ROOT = APP_ROOT / ".debug_engine"
+PROXY_ENV_KEYS = (
+ "HTTP_PROXY",
+ "HTTPS_PROXY",
+ "ALL_PROXY",
+ "FTP_PROXY",
+ "http_proxy",
+ "https_proxy",
+ "all_proxy",
+ "ftp_proxy",
+)
+DIRECT_NO_PROXY_VALUE = "*"
+
+
+def _getenv(name: str, default: str) -> str:
+ """Read a non-empty shared ``.env`` value, falling back to ``default``."""
+ return os.environ.get(name) or default
+
+
+# The repository root must follow this checkout, not a machine-specific .env
+# value. Its path is shared with child processes through their working
+# directory, so deriving it once here keeps every Debug workflow relocatable.
+EMBODICHAIN_ROOT = get_embodichain_root()
+ARTICRAFT_ROOT = Path(
+ _getenv("ARTICRAFT_ROOT", str(APP_ROOT / ".articraft"))
+).expanduser()
+ARTICRAFT_REPOSITORY_URL = _getenv(
+ "ARTICRAFT_REPOSITORY_URL", "https://github.com/mattzh72/articraft.git"
+)
+ARTICRAFT_CONDA_ENV = _getenv("ARTICRAFT_CONDA_ENV", "articraft")
+ARTICRAFT_OUTPUT_ROOT = Path(
+ _getenv("ARTICRAFT_OUTPUT_ROOT", str(DEBUG_ENGINE_ROOT / "articraft"))
+).expanduser()
+SCENE_ENGINE_VISER_PORT = int(_getenv("SCENE_ENGINE_VISER_PORT", "8080"))
+ARTICRAFT_VISER_PORT = int(_getenv("ARTICRAFT_VISER_PORT", "8081"))
+SERVER_NAME = _getenv("GRADIO_SERVER_NAME", "0.0.0.0")
+SERVER_PORT = int(_getenv("GRADIO_SERVER_PORT", "7860"))
+SIMREADY_OPENAI_API_KEY = _getenv("SIMREADY_OPENAI_API_KEY", "")
+SIMREADY_OPENAI_MODEL = _getenv("SIMREADY_OPENAI_MODEL", "")
+SIMREADY_OPENAI_BASE_URL = _getenv("SIMREADY_OPENAI_BASE_URL", "")
+
+
+def configure_direct_network_env(env: Any = None) -> None:
+ """Disable proxy inheritance for local pipeline and Gradio processes."""
+ if env is None:
+ env = os.environ
+ for key in PROXY_ENV_KEYS:
+ env.pop(key, None)
+ env["NO_PROXY"] = DIRECT_NO_PROXY_VALUE
+ env["no_proxy"] = DIRECT_NO_PROXY_VALUE
+ env.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
+
+
+def configure_simready_llm_env(env: Any = None) -> None:
+ """Map app-level SimReady settings to the upstream CLI's environment."""
+ if env is None:
+ env = os.environ
+ configured_values = {
+ "OPENAI_API_KEY": SIMREADY_OPENAI_API_KEY,
+ "OPENAI_MODEL": SIMREADY_OPENAI_MODEL,
+ "OPENAI_BASE_URL": SIMREADY_OPENAI_BASE_URL,
+ }
+ for key, value in configured_values.items():
+ if value:
+ env[key] = value
diff --git a/embodichain/gen_sim/gradio_ui/app_media.py b/embodichain/gen_sim/gradio_ui/app_media.py
new file mode 100644
index 000000000..5c4813c49
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_media.py
@@ -0,0 +1,725 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Run-log archival and video/dataset preview generation."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import shutil
+import subprocess
+from collections.abc import Sequence
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+from PIL import Image, ImageDraw
+
+from app_config import * # noqa: F403 - media paths and limits are configuration.
+from app_env import EMBODICHAIN_ROOT
+from app_state import format_timing_lines, runtime, runtime_lock, snapshot_timing_locked
+
+
+def archive_run_log(
+ *,
+ mode: str,
+ task_description: str = "",
+ scene_description: str = "",
+ outcome: str,
+ audience_video: Path | None = None,
+) -> Path | None:
+ with runtime_lock:
+ run_logs = list(runtime.log_lines)
+ status_text = runtime.status
+ last_error = runtime.last_error
+ runtime_video = runtime.video_path
+ timing_durations, simulation_duration = snapshot_timing_locked()
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+ run_dir = make_next_log_archive_dir()
+ video_paths, video_errors = archive_audience_video(
+ run_dir,
+ audience_video or runtime_video,
+ )
+ log_path = run_dir / "log.md"
+ content = [
+ f"mode: {mode}",
+ "",
+ f"Timestamp: {timestamp}",
+ f"Outcome: {outcome}",
+ "",
+ "## Task description",
+ "",
+ task_description or "",
+ "",
+ "## Scene description",
+ "",
+ scene_description or "",
+ "",
+ "## Status",
+ "",
+ status_text or "",
+ ]
+ if last_error:
+ content.extend(["", "## Last error", "", last_error])
+ if video_paths:
+ content.extend(
+ [
+ "",
+ "## Archived audience video",
+ "",
+ *[path.as_posix() for path in video_paths],
+ ]
+ )
+ if video_errors:
+ content.extend(["", "## Video archive errors", "", *video_errors])
+ content.extend(
+ [
+ "",
+ "## Logs",
+ "",
+ "```text",
+ "\n".join(run_logs) if run_logs else "(no logs)",
+ "```",
+ "",
+ "## Timing",
+ "",
+ *format_timing_lines(timing_durations, simulation_duration),
+ "",
+ ]
+ )
+
+ try:
+ run_dir.mkdir(parents=True, exist_ok=True)
+ log_path.write_text("\n".join(content), encoding="utf-8")
+ except Exception as exc:
+ with runtime_lock:
+ runtime.log_lines.append(f"Failed to archive run log: {exc}")
+ return None
+ return log_path
+
+
+def make_next_log_archive_dir() -> Path:
+ AUTO_LOG_DIR.mkdir(parents=True, exist_ok=True)
+ existing_indices = [
+ int(path.name)
+ for path in AUTO_LOG_DIR.iterdir()
+ if path.is_dir() and path.name.isdigit()
+ ]
+ next_index = (max(existing_indices) + 1) if existing_indices else 1
+ while True:
+ candidate = AUTO_LOG_DIR / f"{next_index:04d}"
+ if not candidate.exists():
+ try:
+ candidate.mkdir(parents=True, exist_ok=False)
+ return candidate
+ except FileExistsError:
+ pass
+ next_index += 1
+
+
+def archive_audience_video(
+ run_dir: Path,
+ video_path: Path | None,
+) -> tuple[list[Path], list[str]]:
+ copied_paths: list[Path] = []
+ errors: list[str] = []
+ if video_path is None:
+ return copied_paths, errors
+ if not video_path.is_file():
+ return copied_paths, [f"Audience video not found: {video_path}"]
+ destination = run_dir / "audience_video" / video_path.name
+ try:
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(video_path, destination)
+ except Exception as exc:
+ errors.append(f"Failed to archive audience video {video_path}: {exc}")
+ return copied_paths, errors
+ copied_paths.append(destination.relative_to(run_dir))
+ return copied_paths, errors
+
+
+def collect_output_videos() -> list[Path]:
+ if not OUTPUTS_DIR.is_dir():
+ return []
+ return sorted(
+ path
+ for path in OUTPUTS_DIR.rglob("*")
+ if path.is_file() and path.suffix.lower() in VIDEO_SUFFIXES
+ )
+
+
+def collect_audience_output_videos() -> list[Path]:
+ videos = collect_output_videos()
+ audience_videos = [
+ path
+ for path in videos
+ if "audience" in path.relative_to(OUTPUTS_DIR).as_posix().lower()
+ ]
+ if audience_videos:
+ return audience_videos
+ return [
+ path
+ for path in videos
+ if "audience" in path.relative_to(OUTPUTS_DIR).as_posix().lower()
+ ]
+
+
+def latest_audience_output_video(min_mtime_ns: int | None = None) -> Path | None:
+ latest_path: Path | None = None
+ latest_mtime = -1
+ for path in collect_audience_output_videos():
+ try:
+ mtime = path.stat().st_mtime_ns
+ except OSError:
+ continue
+ if min_mtime_ns is not None and mtime < min_mtime_ns:
+ continue
+ if mtime > latest_mtime:
+ latest_path = path
+ latest_mtime = mtime
+ return latest_path
+
+
+def configured_lerobot_roots() -> list[Path]:
+ roots: list[Path] = []
+ env_root = os.environ.get("EMBODICHAIN_DATASET_ROOT")
+ if env_root:
+ roots.append(Path(env_root).expanduser())
+ roots.append(Path("~/.cache/embodichain_datasets").expanduser())
+
+ config_roots = read_lerobot_save_paths(CURRENT_PATHS.fast_gym_config)
+ roots.extend(config_roots)
+
+ normalized: list[Path] = []
+ seen: set[Path] = set()
+ for root in roots:
+ root = root.expanduser()
+ if not root.is_absolute():
+ root = EMBODICHAIN_ROOT / root
+ try:
+ resolved = root.resolve()
+ except OSError:
+ resolved = root
+ if resolved in seen:
+ continue
+ seen.add(resolved)
+ normalized.append(root)
+ return normalized
+
+
+def read_lerobot_save_paths(config_path: Path) -> list[Path]:
+ if not config_path.is_file():
+ return []
+ try:
+ config = json.loads(config_path.read_text(encoding="utf-8"))
+ except Exception:
+ return []
+
+ paths: list[Path] = []
+
+ def visit(value: Any, key_path: tuple[str, ...] = ()) -> None:
+ if isinstance(value, dict):
+ if key_path[-2:] == ("lerobot", "params") and isinstance(
+ value.get("save_path"), str
+ ):
+ paths.append(Path(value["save_path"]))
+ for key, child in value.items():
+ visit(child, (*key_path, str(key)))
+ elif isinstance(value, list):
+ for item in value:
+ visit(item, key_path)
+
+ visit(config)
+ return paths
+
+
+def collect_lerobot_datasets() -> list[Path]:
+ datasets: list[Path] = []
+ for root in configured_lerobot_roots():
+ if not root.is_dir():
+ continue
+ try:
+ candidates = list(root.iterdir())
+ except OSError:
+ continue
+ for candidate in candidates:
+ if not candidate.is_dir():
+ continue
+ if (candidate / "meta" / "info.json").is_file() or (
+ candidate / "data"
+ ).is_dir():
+ datasets.append(candidate)
+ return datasets
+
+
+def latest_lerobot_dataset(min_mtime_ns: int | None = None) -> Path | None:
+ latest_path: Path | None = None
+ latest_mtime = -1
+ for dataset_path in collect_lerobot_datasets():
+ if not lerobot_dataset_has_frames(dataset_path):
+ continue
+ mtime = latest_lerobot_dataset_mtime_ns(dataset_path)
+ if min_mtime_ns is not None and mtime < min_mtime_ns:
+ continue
+ if mtime > latest_mtime:
+ latest_path = dataset_path
+ latest_mtime = mtime
+ return latest_path
+
+
+def lerobot_dataset_has_frames(dataset_path: Path) -> bool:
+ data_dir = dataset_path / "data"
+ return data_dir.is_dir() and any(data_dir.rglob("*.parquet"))
+
+
+def latest_lerobot_dataset_mtime_ns(dataset_path: Path) -> int:
+ latest_mtime = -1
+ for child in dataset_path.rglob("*"):
+ if not child.is_file():
+ continue
+ try:
+ latest_mtime = max(latest_mtime, child.stat().st_mtime_ns)
+ except OSError:
+ continue
+ if latest_mtime >= 0:
+ return latest_mtime
+ try:
+ return dataset_path.stat().st_mtime_ns
+ except OSError:
+ return -1
+
+
+def build_lerobot_preview_video(dataset_path: Path) -> Path | None:
+ parquet_paths = sorted((dataset_path / "data").rglob("*.parquet"))
+ if not parquet_paths:
+ return None
+
+ latest_source_mtime = max(
+ latest_lerobot_dataset_mtime_ns(dataset_path),
+ *(path.stat().st_mtime_ns for path in parquet_paths),
+ )
+ output_path = LEROBOT_PREVIEW_DIR / f"{dataset_path.name}_data_preview.mp4"
+ if output_path.is_file() and output_path.stat().st_mtime_ns >= latest_source_mtime:
+ return output_path
+
+ try:
+ import imageio.v2 as imageio
+ import pandas as pd
+ except Exception as exc:
+ with runtime_lock:
+ runtime.log_lines.append(
+ f"LeRobot preview skipped; missing dependency: {exc}"
+ )
+ return None
+
+ try:
+ data_frame = pd.concat(
+ [pd.read_parquet(path) for path in parquet_paths],
+ ignore_index=True,
+ )
+ except Exception as exc:
+ with runtime_lock:
+ runtime.log_lines.append(f"LeRobot preview skipped; read failed: {exc}")
+ return None
+
+ if data_frame.empty:
+ return None
+
+ try:
+ fps = read_lerobot_fps(dataset_path) or 25
+ fps = max(1, min(int(round(fps)), 30))
+ frames = render_lerobot_data_frames(data_frame, dataset_path.name)
+ if not frames:
+ return None
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ with imageio.get_writer(output_path, fps=fps, codec="libx264") as writer:
+ for frame in frames:
+ writer.append_data(frame)
+ except Exception as exc:
+ with runtime_lock:
+ runtime.log_lines.append(f"LeRobot preview skipped; render failed: {exc}")
+ return None
+
+ return output_path
+
+
+def video_duration_seconds(video_path: Path) -> float | None:
+ command = [
+ "ffprobe",
+ "-v",
+ "error",
+ "-show_entries",
+ "format=duration",
+ "-of",
+ "default=noprint_wrappers=1:nokey=1",
+ str(video_path),
+ ]
+ try:
+ result = subprocess.run(
+ command,
+ capture_output=True,
+ text=True,
+ check=False,
+ timeout=15,
+ )
+ duration = float(result.stdout.strip())
+ except (OSError, ValueError, subprocess.TimeoutExpired):
+ return None
+ return duration if duration > 0 else None
+
+
+def build_single_env_combined_video(
+ audience_video: Path | None,
+ lerobot_video: Path | None,
+) -> Path | None:
+ """Create a synchronized side-by-side simulation and LeRobot video."""
+ if (
+ audience_video is None
+ or lerobot_video is None
+ or not audience_video.is_file()
+ or not lerobot_video.is_file()
+ ):
+ return None
+
+ audience_duration = video_duration_seconds(audience_video)
+ lerobot_duration = video_duration_seconds(lerobot_video)
+ if audience_duration is None or lerobot_duration is None:
+ return None
+
+ latest_source_mtime = max(
+ audience_video.stat().st_mtime_ns,
+ lerobot_video.stat().st_mtime_ns,
+ )
+ output_path = (
+ COMBINED_PREVIEW_DIR
+ / f"{safe_filename_part(audience_video.stem)}_with_lerobot.mp4"
+ )
+ if output_path.is_file() and output_path.stat().st_mtime_ns >= latest_source_mtime:
+ return output_path
+
+ lerobot_time_scale = audience_duration / lerobot_duration
+ filter_graph = (
+ f"[0:v]fps={COMBINED_VIDEO_FPS},scale=960:540:force_original_aspect_ratio=decrease,"
+ "pad=960:540:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,"
+ "setpts=PTS-STARTPTS[sim];"
+ f"[1:v]fps={COMBINED_VIDEO_FPS},scale=960:540:force_original_aspect_ratio=decrease,"
+ "pad=960:540:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,"
+ f"setpts=(PTS-STARTPTS)*{lerobot_time_scale:.9f}[data];"
+ "[sim][data]hstack=inputs=2:shortest=1,format=yuv420p[video]"
+ )
+ command = [
+ "ffmpeg",
+ "-y",
+ "-i",
+ str(audience_video),
+ "-i",
+ str(lerobot_video),
+ "-filter_complex",
+ filter_graph,
+ "-map",
+ "[video]",
+ "-an",
+ "-c:v",
+ "libx264",
+ "-preset",
+ "veryfast",
+ "-crf",
+ "23",
+ "-movflags",
+ "+faststart",
+ str(output_path),
+ ]
+ try:
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ result = subprocess.run(
+ command,
+ capture_output=True,
+ text=True,
+ check=False,
+ timeout=180,
+ )
+ if result.returncode == 0 and output_path.is_file():
+ return output_path
+ with runtime_lock:
+ runtime.log_lines.append(
+ "Combined video skipped: "
+ + (
+ result.stderr.strip().splitlines()[-1]
+ if result.stderr
+ else "ffmpeg failed"
+ )
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ with runtime_lock:
+ runtime.log_lines.append(f"Combined video skipped: {exc}")
+ return None
+
+
+def read_lerobot_fps(dataset_path: Path) -> int | None:
+ info_path = dataset_path / "meta" / "info.json"
+ if not info_path.is_file():
+ return None
+ try:
+ info = json.loads(info_path.read_text(encoding="utf-8"))
+ except Exception:
+ return None
+ fps = info.get("fps")
+ if isinstance(fps, (int, float)):
+ return int(fps)
+ return None
+
+
+def render_lerobot_data_frames(data_frame: Any, dataset_name: str) -> list[np.ndarray]:
+ total_rows = len(data_frame)
+ frame_indices = np.linspace(
+ 0,
+ total_rows - 1,
+ num=min(total_rows, LEROBOT_PREVIEW_MAX_FRAMES),
+ dtype=int,
+ )
+ state = series_to_matrix(data_frame.get("observation.state"))
+ action = series_to_matrix(data_frame.get("action"))
+ qvel = series_to_matrix(data_frame.get("observation.qvel"))
+ timestamps = numeric_column(data_frame, "timestamp", total_rows)
+
+ frames: list[np.ndarray] = []
+ for row_index in frame_indices:
+ image = Image.new("RGB", (960, 544), (247, 248, 250))
+ draw = ImageDraw.Draw(image)
+ draw_lerobot_header(
+ draw,
+ dataset_name=dataset_name,
+ row_index=int(row_index),
+ total_rows=total_rows,
+ timestamp=float(timestamps[row_index]) if len(timestamps) else None,
+ )
+ draw_signal_panel(
+ draw, (40, 96, 920, 220), state, row_index, "observation.state"
+ )
+ draw_signal_panel(draw, (40, 244, 920, 368), action, row_index, "action")
+ draw_bar_panel(draw, (40, 392, 920, 506), qvel, row_index, "observation.qvel")
+ frames.append(np.asarray(image))
+ return frames
+
+
+def series_to_matrix(series: Any, max_dims: int = 12) -> np.ndarray:
+ if series is None:
+ return np.empty((0, 0), dtype=float)
+ rows: list[np.ndarray] = []
+ for value in series:
+ array = np.asarray(value, dtype=float).reshape(-1)
+ if array.size:
+ rows.append(array[:max_dims])
+ if not rows:
+ return np.empty((0, 0), dtype=float)
+ width = max(row.size for row in rows)
+ matrix = np.full((len(rows), width), np.nan, dtype=float)
+ for index, row in enumerate(rows):
+ matrix[index, : row.size] = row
+ return matrix
+
+
+def numeric_column(data_frame: Any, column: str, fallback_length: int) -> np.ndarray:
+ if column not in data_frame:
+ return np.arange(fallback_length, dtype=float)
+ try:
+ values = np.asarray(data_frame[column], dtype=float)
+ except Exception:
+ values = np.arange(fallback_length, dtype=float)
+ return values
+
+
+def draw_lerobot_header(
+ draw: ImageDraw.ImageDraw,
+ *,
+ dataset_name: str,
+ row_index: int,
+ total_rows: int,
+ timestamp: float | None,
+) -> None:
+ draw.text((40, 28), "LeRobot dataset preview", fill=(17, 24, 39))
+ short_name = dataset_name if len(dataset_name) <= 78 else f"{dataset_name[:75]}..."
+ draw.text((40, 54), short_name, fill=(75, 85, 99))
+ progress = 0 if total_rows <= 1 else row_index / (total_rows - 1)
+ draw.text((750, 28), f"frame {row_index + 1}/{total_rows}", fill=(17, 24, 39))
+ if timestamp is not None:
+ draw.text((750, 54), f"t = {timestamp:.2f}s", fill=(75, 85, 99))
+ draw.rectangle((40, 78, 920, 82), fill=(224, 231, 239))
+ draw.rectangle((40, 78, int(40 + 880 * progress), 82), fill=(37, 99, 235))
+
+
+def draw_signal_panel(
+ draw: ImageDraw.ImageDraw,
+ box: tuple[int, int, int, int],
+ matrix: np.ndarray,
+ row_index: int,
+ title: str,
+) -> None:
+ x0, y0, x1, y1 = box
+ draw.rounded_rectangle(box, radius=8, fill=(255, 255, 255), outline=(209, 213, 219))
+ draw.text((x0 + 14, y0 + 10), title, fill=(17, 24, 39))
+ if matrix.size == 0:
+ draw.text((x0 + 14, y0 + 48), "No numeric data", fill=(107, 114, 128))
+ return
+ plot_box = (x0 + 14, y0 + 36, x1 - 14, y1 - 16)
+ draw_timeseries(draw, plot_box, matrix, row_index)
+
+
+def draw_bar_panel(
+ draw: ImageDraw.ImageDraw,
+ box: tuple[int, int, int, int],
+ matrix: np.ndarray,
+ row_index: int,
+ title: str,
+) -> None:
+ x0, y0, x1, y1 = box
+ draw.rounded_rectangle(box, radius=8, fill=(255, 255, 255), outline=(209, 213, 219))
+ draw.text((x0 + 14, y0 + 10), title, fill=(17, 24, 39))
+ if matrix.size == 0 or row_index >= len(matrix):
+ draw.text((x0 + 14, y0 + 48), "No numeric data", fill=(107, 114, 128))
+ return
+ values = matrix[row_index]
+ finite = values[np.isfinite(values)]
+ if finite.size == 0:
+ return
+ max_abs = max(float(np.nanmax(np.abs(finite))), 1e-6)
+ base_y = y1 - 30
+ left = x0 + 18
+ available_width = x1 - x0 - 36
+ bar_count = min(len(values), 12)
+ bar_gap = 8
+ bar_width = max(8, (available_width - bar_gap * (bar_count - 1)) // bar_count)
+ for index in range(bar_count):
+ value = values[index]
+ if not np.isfinite(value):
+ continue
+ x = left + index * (bar_width + bar_gap)
+ height = int((abs(float(value)) / max_abs) * 58)
+ color = (22, 163, 74) if value >= 0 else (220, 38, 38)
+ y_top = base_y - height
+ draw.rectangle((x, y_top, x + bar_width, base_y), fill=color)
+ draw.text((x, base_y + 5), str(index), fill=(107, 114, 128))
+
+
+def draw_timeseries(
+ draw: ImageDraw.ImageDraw,
+ box: tuple[int, int, int, int],
+ matrix: np.ndarray,
+ row_index: int,
+) -> None:
+ x0, y0, x1, y1 = box
+ draw.rectangle(box, outline=(229, 231, 235))
+ sample_count = min(len(matrix), LEROBOT_PREVIEW_MAX_FRAMES)
+ if sample_count <= 1:
+ return
+ sampled = matrix[
+ np.linspace(0, len(matrix) - 1, num=sample_count, dtype=int),
+ : min(matrix.shape[1], 8),
+ ]
+ finite = sampled[np.isfinite(sampled)]
+ if finite.size == 0:
+ return
+ minimum = float(np.nanmin(finite))
+ maximum = float(np.nanmax(finite))
+ if math.isclose(minimum, maximum):
+ minimum -= 1.0
+ maximum += 1.0
+ palette = [
+ (37, 99, 235),
+ (5, 150, 105),
+ (217, 119, 6),
+ (220, 38, 38),
+ (124, 58, 237),
+ (8, 145, 178),
+ (79, 70, 229),
+ (202, 138, 4),
+ ]
+
+ def point(sample_index: int, value: float) -> tuple[int, int]:
+ x = int(x0 + (x1 - x0) * sample_index / (sample_count - 1))
+ y = int(y1 - (y1 - y0) * (value - minimum) / (maximum - minimum))
+ return x, y
+
+ for dim in range(sampled.shape[1]):
+ points = [
+ point(index, float(value))
+ for index, value in enumerate(sampled[:, dim])
+ if np.isfinite(value)
+ ]
+ if len(points) >= 2:
+ draw.line(points, fill=palette[dim % len(palette)], width=2)
+
+ cursor_x = int(x0 + (x1 - x0) * row_index / max(len(matrix) - 1, 1))
+ draw.line((cursor_x, y0, cursor_x, y1), fill=(17, 24, 39), width=2)
+
+
+def safe_filename_part(value: str) -> str:
+ safe = "".join(
+ char if char.isalnum() or char in {"-", "_"} else "_" for char in value.strip()
+ )
+ return safe.strip("_")[:80]
+
+
+def run_articraft_viser_preview(args: argparse.Namespace) -> None:
+ """Load an Articraft URDF and publish its initial scene topology to Viser.
+
+ The generic asset-preview command starts Viser lazily. For an asset loaded
+ before that first capture, explicitly marking the topology dirty and
+ capturing once more ensures the initial scene is sent to the browser.
+
+ Args:
+ args: Parsed preview-asset command-line arguments.
+ """
+ from embodichain.lab.scripts import preview_asset
+ from embodichain.lab.sim.sim_manager import SimulationManager
+ from embodichain.utils.logger import log_info
+
+ sim = SimulationManager(preview_asset.build_sim_cfg(args))
+ try:
+ if args.env_map:
+ log_info(f"Setting environment map: {args.env_map} ...", color="green")
+ sim.set_indirect_lighting(args.env_map)
+
+ assets = preview_asset.load_assets(sim, args)
+ log_info(f"Loaded {len(assets)} asset(s) successfully.", color="green")
+ if args.viser:
+ sim.start_visualization()
+ sim.notify_visualization_topology_changed()
+ sim.capture_visualization_safely(force=True)
+ preview_asset._run_preview_mode(sim, assets, args)
+ finally:
+ log_info("Destroying simulation ...", color="green")
+ sim.destroy()
+
+
+def articraft_viser_preview_cli(argv: Sequence[str] | None = None) -> None:
+ """Run the Articraft-aware variant of the generic preview-asset CLI.
+
+ Args:
+ argv: Arguments excluding the program name, or ``None`` for ``sys.argv``.
+ """
+ from embodichain.lab.scripts import preview_asset
+
+ parser = preview_asset._create_parser()
+ run_articraft_viser_preview(parser.parse_args(argv))
+
+
+if __name__ == "__main__":
+ articraft_viser_preview_cli()
diff --git a/embodichain/gen_sim/gradio_ui/app_processes.py b/embodichain/gen_sim/gradio_ui/app_processes.py
new file mode 100644
index 000000000..a0f414f3a
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_processes.py
@@ -0,0 +1,348 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Pipeline subprocess execution and progress detection."""
+
+from __future__ import annotations
+
+import os
+import queue
+import signal
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+
+from app_config import COMMANDS, PROCESS_STOP_TIMEOUT_S
+from app_env import (
+ EMBODICHAIN_ROOT,
+ configure_direct_network_env,
+ configure_simready_llm_env,
+)
+from app_state import PHASES
+
+__all__ = [
+ "build_pipeline_env",
+ "build_run_agent_command",
+ "detect_phase_from_files",
+ "force_stop_all_child_processes",
+ "read_process_output",
+ "register_managed_process",
+ "run_agent_cli_supports_robot_profile",
+ "start_pipeline",
+ "terminate_process_group",
+ "update_phase_from_log",
+]
+
+_RUN_AGENT_SUPPORTS_ROBOT_PROFILE: bool | None = None
+_managed_processes: dict[int, subprocess.Popen[str]] = {}
+_managed_processes_lock = threading.Lock()
+_shutdown_requested = False
+
+
+def run_agent_cli_supports_robot_profile() -> bool:
+ global _RUN_AGENT_SUPPORTS_ROBOT_PROFILE
+ if _RUN_AGENT_SUPPORTS_ROBOT_PROFILE is not None:
+ return _RUN_AGENT_SUPPORTS_ROBOT_PROFILE
+ try:
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ COMMANDS["agent"]["module"],
+ *COMMANDS["agent"]["help_args"],
+ ],
+ cwd=EMBODICHAIN_ROOT,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ env=build_pipeline_env(),
+ timeout=20,
+ )
+ help_text = (result.stdout or "").lower()
+ _RUN_AGENT_SUPPORTS_ROBOT_PROFILE = "--robot-profile" in help_text
+ except Exception:
+ _RUN_AGENT_SUPPORTS_ROBOT_PROFILE = False
+ return _RUN_AGENT_SUPPORTS_ROBOT_PROFILE
+
+
+def build_run_agent_command(
+ paths: ScenePaths, *, parallel_env: bool = False, robot_profile: str | None = None
+) -> list[str]:
+ from app_commands import build_run_agent_command as build_command
+
+ return build_command(
+ paths,
+ parallel_env=parallel_env,
+ robot_profile=robot_profile,
+ supports_robot_profile=run_agent_cli_supports_robot_profile(),
+ )
+
+
+def start_pipeline(command: list[str]) -> subprocess.Popen[str]:
+ env = build_pipeline_env()
+ env["PYTHONUNBUFFERED"] = "1"
+ return register_managed_process(
+ subprocess.Popen(
+ command,
+ cwd=EMBODICHAIN_ROOT,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ start_new_session=True,
+ env=env,
+ )
+ )
+
+
+def build_pipeline_env() -> dict[str, str]:
+ env = os.environ.copy()
+ configure_direct_network_env(env)
+ configure_simready_llm_env(env)
+ return env
+
+
+def register_managed_process(
+ process: subprocess.Popen[str],
+) -> subprocess.Popen[str]:
+ """Register a UI-owned subprocess for application-shutdown cleanup.
+
+ Processes must be registered immediately after they are created. If Gradio
+ shutdown has already begun, the new process is stopped before this function
+ returns so a callback cannot leave an orphan behind.
+ """
+ with _managed_processes_lock:
+ if not _shutdown_requested:
+ _managed_processes[process.pid] = process
+ return process
+
+ terminate_process_group(process)
+ return process
+
+
+def _unregister_managed_process(process: subprocess.Popen[str]) -> None:
+ with _managed_processes_lock:
+ _managed_processes.pop(process.pid, None)
+
+
+def _child_process_ids(parent_pid: int) -> set[int]:
+ """Return a snapshot of every descendant of ``parent_pid`` on POSIX."""
+ try:
+ result = subprocess.run(
+ ["ps", "-eo", "pid=,ppid="],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ timeout=2,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return set()
+
+ children_by_parent: dict[int, set[int]] = {}
+ for line in result.stdout.splitlines():
+ fields = line.split()
+ if len(fields) != 2 or not all(field.isdecimal() for field in fields):
+ continue
+ pid, ppid = (int(field) for field in fields)
+ children_by_parent.setdefault(ppid, set()).add(pid)
+
+ descendants: set[int] = set()
+ pending = list(children_by_parent.get(parent_pid, set()))
+ while pending:
+ pid = pending.pop()
+ if pid in descendants:
+ continue
+ descendants.add(pid)
+ pending.extend(children_by_parent.get(pid, set()))
+ return descendants
+
+
+def _force_stop_process_ids(process_ids: set[int]) -> None:
+ """Stop unregistered child PIDs, escalating from SIGTERM to SIGKILL."""
+ process_ids.discard(os.getpid())
+ for pid in process_ids:
+ try:
+ os.kill(pid, signal.SIGTERM)
+ except ProcessLookupError:
+ continue
+ except PermissionError:
+ continue
+
+ deadline = time.monotonic() + PROCESS_STOP_TIMEOUT_S
+ remaining = set(process_ids)
+ while remaining and time.monotonic() < deadline:
+ remaining = {pid for pid in remaining if _process_is_running(pid)}
+ if remaining:
+ time.sleep(0.1)
+
+ for pid in remaining:
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ continue
+ except PermissionError:
+ continue
+
+
+def _process_is_running(pid: int) -> bool:
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ return False
+ except PermissionError:
+ return True
+ try:
+ status = Path(f"/proc/{pid}/stat").read_text().rsplit(")", maxsplit=1)[1]
+ except (FileNotFoundError, IndexError, PermissionError):
+ return True
+ return not status.lstrip().startswith("Z")
+
+
+def force_stop_all_child_processes() -> None:
+ """Force-stop every subprocess owned by the Gradio application.
+
+ Registered processes are stopped by their isolated process groups, which
+ also stops their descendants. A second descendant scan catches short-lived
+ or legacy subprocesses that were not registered explicitly.
+ """
+ global _shutdown_requested
+ with _managed_processes_lock:
+ _shutdown_requested = True
+ managed_processes = tuple(_managed_processes.values())
+ child_process_ids = _child_process_ids(os.getpid())
+
+ for process in managed_processes:
+ terminate_process_group(process)
+
+ _force_stop_process_ids(child_process_ids)
+
+
+def terminate_process_group(process: subprocess.Popen[str]) -> None:
+ try:
+ if process.poll() is not None:
+ return
+ try:
+ os.killpg(process.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ return
+ except Exception:
+ process.terminate()
+
+ deadline = time.monotonic() + PROCESS_STOP_TIMEOUT_S
+ while time.monotonic() < deadline:
+ if process.poll() is not None:
+ return
+ time.sleep(0.2)
+
+ try:
+ os.killpg(process.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ return
+ except Exception:
+ process.kill()
+ finally:
+ _unregister_managed_process(process)
+
+
+def detect_phase_from_files(current_key: str, paths: ScenePaths) -> str:
+ candidates = [
+ ("scene_intake", paths.prompt_root / "scene_intake" / "result.json"),
+ ("relations", paths.prompt_root / "image_segments" / "result.json"),
+ (
+ "relations",
+ paths.prompt_root / "image_spatial_relations" / "result.json",
+ ),
+ ("gym_export", paths.prompt_root / "gym_export" / "gym_config.json"),
+ ("config", paths.fast_gym_config),
+ ("preview", paths.gradio_scene_glb),
+ ]
+ best_key = current_key
+ best_progress = PHASES.get(best_key, PHASES["idle"]).progress
+
+ if any(paths.prompt_root.glob("unified_scene_gen/**/*.glb")):
+ best_key, best_progress = _choose_later_phase(
+ best_key,
+ best_progress,
+ "asset_generation",
+ )
+ for phase_key, marker in candidates:
+ if marker.exists():
+ best_key, best_progress = _choose_later_phase(
+ best_key,
+ best_progress,
+ phase_key,
+ )
+ return best_key
+
+
+def _choose_later_phase(
+ current_key: str,
+ current_progress: int,
+ candidate_key: str,
+) -> tuple[str, int]:
+ candidate_progress = PHASES[candidate_key].progress
+ if candidate_progress > current_progress:
+ return candidate_key, candidate_progress
+ return current_key, current_progress
+
+
+def update_phase_from_log(line: str, current_key: str) -> str:
+ text = line.lower()
+ mapping = [
+ ("scene_intake", "scene_intake"),
+ ("image_segments", "relations"),
+ ("image_spatial_relations", "relations"),
+ ("unified_scene_gen", "asset_generation"),
+ ("glb", "asset_generation"),
+ ("gym_export", "gym_export"),
+ ("generated gym config", "config"),
+ ("fast_gym_config", "config"),
+ ]
+ best_key = current_key
+ best_progress = PHASES.get(best_key, PHASES["idle"]).progress
+ for needle, phase_key in mapping:
+ if needle in text:
+ best_key, best_progress = _choose_later_phase(
+ best_key,
+ best_progress,
+ phase_key,
+ )
+ return best_key
+
+
+def read_process_output(
+ process: subprocess.Popen[str],
+ output_queue: queue.Queue[str],
+ log_path: Path | None = None,
+) -> None:
+ """Forward merged subprocess output to the UI queue and an optional log."""
+ if process.stdout is None:
+ return
+ log_file = log_path.open("a", encoding="utf-8") if log_path is not None else None
+ try:
+ for line in process.stdout:
+ output_queue.put(line.rstrip())
+ if log_file is not None:
+ log_file.write(line)
+ if not line.endswith("\n"):
+ log_file.write("\n")
+ log_file.flush()
+ finally:
+ if log_file is not None:
+ log_file.close()
diff --git a/embodichain/gen_sim/gradio_ui/app_services.py b/embodichain/gen_sim/gradio_ui/app_services.py
new file mode 100644
index 000000000..36b7fccdf
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_services.py
@@ -0,0 +1,28 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Compatibility facade for application services.
+
+The executable workflow lives in :mod:`app_workflows`; the Gradio view lives
+in :mod:`app_ui`. Keep this module small so existing imports remain valid
+while callers move to the focused modules.
+"""
+
+from __future__ import annotations
+
+from app_ui import build_demo
+
+__all__ = ["build_demo"]
diff --git a/embodichain/gen_sim/gradio_ui/app_state.py b/embodichain/gen_sim/gradio_ui/app_state.py
new file mode 100644
index 000000000..9ad6b9dac
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_state.py
@@ -0,0 +1,189 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Shared, thread-safe runtime state and timing helpers."""
+
+from __future__ import annotations
+
+import subprocess
+import threading
+import time
+import uuid
+from collections import deque
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from app_config import (
+ DEFAULT_ROBOT_PROFILE,
+ LANGUAGE_EN,
+ PHASE_DEFINITIONS,
+ SCENE_MODE_INITIAL,
+ TIMING_PHASE_LABELS,
+ TIMING_PHASE_ORDER,
+)
+
+
+@dataclass(frozen=True)
+class Phase:
+ progress: int
+ label: str
+
+
+PHASES = {key: Phase(*value) for key, value in PHASE_DEFINITIONS.items()}
+
+
+@dataclass
+class RuntimeState:
+ is_busy: bool = False
+ run_token: str = field(default_factory=lambda: uuid.uuid4().hex)
+ auto_loop_active: bool = False
+ auto_loop_token: str | None = None
+ auto_round: int = 0
+ auto_scene_mode: str = SCENE_MODE_INITIAL
+ auto_parallel_env: bool = False
+ auto_robot_profile: str = DEFAULT_ROBOT_PROFILE
+ language: str = LANGUAGE_EN
+ process: subprocess.Popen[str] | None = None
+ sim_process: subprocess.Popen[str] | None = None
+ scene_engine_process: subprocess.Popen[str] | None = None
+ scene_preview_process: subprocess.Popen[str] | None = None
+ scene_engine_is_running: bool = False
+ sim_started: bool = False
+ sim_finished: bool = False
+ sim_returncode: int | None = None
+ phase_key: str = "idle"
+ status: str = "Idle."
+ task_text: str = ""
+ input_task_text: str = ""
+ input_scene_text: str = ""
+ image_path: Path | None = None
+ video_path: Path | None = None
+ last_sent_video_signature: tuple[str, int] | None = None
+ lerobot_video_path: Path | None = None
+ lerobot_dataset_path: Path | None = None
+ submitted_input_revision: int = 0
+ object_model_path: Path | None = None
+ scene_model_path: Path | None = None
+ edited_scene_model_path: Path | None = None
+ last_error: str | None = None
+ log_lines: deque[str] = field(default_factory=deque)
+ timing_started_ns: int | None = None
+ current_timing_phase_key: str | None = None
+ current_timing_phase_started_ns: int | None = None
+ phase_durations_ns: dict[str, int] = field(default_factory=dict)
+ simulation_started_monotonic_ns: int | None = None
+ simulation_duration_ns: int | None = None
+
+
+runtime = RuntimeState()
+runtime_lock = threading.Lock()
+
+
+def clear_run_timing_locked() -> None:
+ runtime.timing_started_ns = None
+ runtime.current_timing_phase_key = None
+ runtime.current_timing_phase_started_ns = None
+ runtime.phase_durations_ns.clear()
+ runtime.simulation_started_monotonic_ns = None
+ runtime.simulation_duration_ns = None
+
+
+def start_run_timing_locked(phase_key: str) -> None:
+ now_ns = time.monotonic_ns()
+ runtime.timing_started_ns = now_ns
+ runtime.current_timing_phase_key = phase_key
+ runtime.current_timing_phase_started_ns = now_ns
+ runtime.phase_durations_ns.clear()
+ runtime.simulation_started_monotonic_ns = None
+ runtime.simulation_duration_ns = None
+
+
+def record_phase_transition_locked(new_phase_key: str) -> None:
+ current_key = runtime.current_timing_phase_key
+ current_started_ns = runtime.current_timing_phase_started_ns
+ now_ns = time.monotonic_ns()
+ if current_key is None or current_started_ns is None:
+ runtime.timing_started_ns = runtime.timing_started_ns or now_ns
+ runtime.current_timing_phase_key = new_phase_key
+ runtime.current_timing_phase_started_ns = now_ns
+ return
+ if new_phase_key == current_key:
+ return
+ runtime.phase_durations_ns[current_key] = runtime.phase_durations_ns.get(
+ current_key, 0
+ ) + max(0, now_ns - current_started_ns)
+ runtime.current_timing_phase_key = new_phase_key
+ runtime.current_timing_phase_started_ns = now_ns
+
+
+def set_runtime_phase_locked(new_phase_key: str) -> None:
+ record_phase_transition_locked(new_phase_key)
+ runtime.phase_key = new_phase_key
+
+
+def record_simulation_started_locked() -> None:
+ runtime.simulation_started_monotonic_ns = time.monotonic_ns()
+ runtime.simulation_duration_ns = None
+
+
+def record_simulation_finished_locked() -> None:
+ started_ns = runtime.simulation_started_monotonic_ns
+ if started_ns is not None:
+ runtime.simulation_duration_ns = max(0, time.monotonic_ns() - started_ns)
+ runtime.simulation_started_monotonic_ns = None
+
+
+def snapshot_timing_locked() -> tuple[dict[str, int], int | None]:
+ durations = dict(runtime.phase_durations_ns)
+ current_key = runtime.current_timing_phase_key
+ current_started_ns = runtime.current_timing_phase_started_ns
+ if (
+ current_key
+ and current_started_ns is not None
+ and current_key not in {"complete", "failed", "idle"}
+ ):
+ durations[current_key] = durations.get(current_key, 0) + max(
+ 0, time.monotonic_ns() - current_started_ns
+ )
+ simulation_duration_ns = runtime.simulation_duration_ns
+ if (
+ simulation_duration_ns is None
+ and runtime.simulation_started_monotonic_ns is not None
+ ):
+ simulation_duration_ns = max(
+ 0, time.monotonic_ns() - runtime.simulation_started_monotonic_ns
+ )
+ return durations, simulation_duration_ns
+
+
+def format_duration_ns(duration_ns: int) -> str:
+ seconds = duration_ns / 1_000_000_000
+ if seconds < 60:
+ return f"{seconds:.2f}s"
+ minutes = int(seconds // 60)
+ return f"{minutes}m {seconds - minutes * 60:05.2f}s"
+
+
+def format_timing_lines(
+ phase_durations_ns: dict[str, int], simulation_duration_ns: int | None
+) -> list[str]:
+ timing_values = dict(phase_durations_ns)
+ if simulation_duration_ns is not None:
+ timing_values["action_graph_execution"] = simulation_duration_ns
+ return [
+ f"- {TIMING_PHASE_LABELS[key]}: {format_duration_ns(value) if (value := timing_values.get(key)) is not None else 'skipped'}"
+ for key in TIMING_PHASE_ORDER
+ ]
diff --git a/embodichain/gen_sim/gradio_ui/app_ui.py b/embodichain/gen_sim/gradio_ui/app_ui.py
new file mode 100644
index 000000000..92e30eeed
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_ui.py
@@ -0,0 +1,586 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Gradio layout and event bindings.
+
+The workflow layer supplies all callbacks; this module only owns presentation
+and wires components to those callbacks.
+"""
+
+from __future__ import annotations
+
+from app_workflows import * # noqa: F401,F403 - callbacks/constants form the UI contract.
+from app_asset_engine import build_asset_engine_panel
+
+
+def select_application_mode(selected_mode: str | None):
+ """Switch between the full product UI and the focused engine UI."""
+ is_debug = selected_mode == APP_MODE_DEBUG
+ debug_css = (
+ "" if is_debug else ""
+ )
+ return (
+ gr.update(variant="secondary" if is_debug else "primary"),
+ gr.update(variant="primary" if is_debug else "secondary"),
+ gr.update(visible=is_debug),
+ gr.update(value=debug_css),
+ APP_MODE_DEBUG if is_debug else APP_MODE_DEMO,
+ gr.update(visible=is_debug),
+ )
+
+
+def select_debug_engine(selected_engine: str):
+ """Expose an explicit active state without starting any pipeline."""
+ button_updates = tuple(
+ gr.update(variant="primary" if engine == selected_engine else "secondary")
+ for engine, _ in DEBUG_ENGINES
+ )
+ return (
+ *button_updates,
+ gr.update(visible=selected_engine == DEBUG_ENGINE_ASSET),
+ gr.update(visible=selected_engine == DEBUG_ENGINE_SCENE),
+ gr.update(visible=selected_engine == DEBUG_ENGINE_ACTION),
+ )
+
+
+def action_engine_snapshot():
+ """Adapt the shared runtime snapshot to the five Action-engine widgets."""
+ video, task, progress, status, initial, edited, _objects = ui_snapshot()
+ return video, task, progress, status, initial or edited
+
+
+def run_action_engine_panel(task_text: str, robot_profile: str | None):
+ run_action_engine_from_current(task_text, robot_profile)
+ return action_engine_snapshot()
+
+
+def build_demo() -> gr.Blocks:
+ with gr.Blocks(title="EmbodiChain Gradio") as demo:
+ app_mode = gr.State(APP_MODE_DEMO)
+ run_mode = gr.State(TOP_MODE_INTERACT)
+ action_mode = gr.State(None)
+ language = gr.State(LANGUAGE_EN)
+ last_seen_input_revision = gr.State(0)
+ interact_prebuilt_scene_dir = gr.State(None)
+ mode_style = gr.HTML(value="", visible=True)
+ with gr.Row():
+ demo_mode_button = gr.Button("Demo", variant="primary")
+ debug_mode_button = gr.Button("Debug", variant="secondary")
+ with gr.Row(visible=False) as debug_controls:
+ asset_engine_button = gr.Button("Asset_engine", variant="primary")
+ scene_engine_button = gr.Button("Scene_engine", variant="secondary")
+ action_engine_button = gr.Button("Action_engine", variant="secondary")
+ with gr.Column(visible=False) as debug_engine_area:
+ asset_engine = build_asset_engine_panel()
+ with gr.Column(visible=False) as scene_engine_panel:
+ gr.Markdown(
+ "## Scene engine\n"
+ "Upload one image to generate a Scene Engine export. "
+ "The resulting Viser page is shown below."
+ )
+ with gr.Row():
+ with gr.Column(scale=1):
+ debug_scene_image = gr.Image(
+ label=UI_TEXT[LANGUAGE_EN]["input_image"],
+ sources=["upload", "webcam"],
+ type="filepath",
+ format="png",
+ height=300,
+ )
+ with gr.Row():
+ debug_scene_run = gr.Button(
+ "Generate scene", variant="primary"
+ )
+ debug_scene_reset = gr.Button(
+ "Reset Scene Engine", variant="stop"
+ )
+ with gr.Column(scale=2):
+ debug_scene_progress = gr.Slider(
+ 0,
+ 100,
+ value=0,
+ step=1,
+ label=UI_TEXT[LANGUAGE_EN]["progress"],
+ interactive=False,
+ )
+ debug_scene_status = gr.Markdown(format_status("Idle."))
+ debug_scene_output = gr.Textbox(
+ label="Scene output directory (hash-named)",
+ interactive=False,
+ )
+ debug_scene_preview = gr.HTML(
+ ""
+ "The Viser preview will appear here after generation."
+ "
"
+ )
+ with gr.Column(visible=False) as action_engine_panel:
+ gr.Markdown(
+ "## Action engine\nUses the Gym scene produced by Scene engine (not merely a rendered GLB), then generates the action config and launches DexSim. This retains collisions, poses and physics metadata required by simulation."
+ )
+ with gr.Row():
+ with gr.Column(scale=1):
+ debug_action_task = gr.Textbox(
+ label="Task description",
+ placeholder="e.g. Put the bottle on the table",
+ )
+ debug_action_robot = gr.Radio(
+ choices=ROBOT_PROFILES,
+ value=DEFAULT_ROBOT_PROFILE,
+ label=UI_TEXT[LANGUAGE_EN]["robot"],
+ )
+ debug_action_load = gr.Button("Load current scene")
+ debug_action_run = gr.Button("Run DexSim", variant="primary")
+ with gr.Column(scale=2):
+ debug_action_scene = gr.Model3D(
+ label="Input Gym scene preview",
+ height=420,
+ clear_color=(0.94, 0.94, 0.94, 1.0),
+ )
+ debug_action_video = gr.Video(
+ label=UI_TEXT[LANGUAGE_EN]["single_video_preview"],
+ height=320,
+ autoplay=True,
+ loop=True,
+ )
+ debug_action_current_task = gr.Textbox(
+ label=UI_TEXT[LANGUAGE_EN]["current_task"],
+ interactive=False,
+ )
+ debug_action_progress = gr.Slider(
+ 0,
+ 100,
+ value=0,
+ step=1,
+ label=UI_TEXT[LANGUAGE_EN]["progress"],
+ interactive=False,
+ )
+ debug_action_status = gr.Markdown(
+ format_status("Load or generate a scene first.")
+ )
+ debug_action_refresh_timer = gr.Timer(2.0)
+ with gr.Row(equal_height=True, elem_classes="demo-only"):
+ if DEXFORCE_LOGO.is_file():
+ gr.Image(
+ value=str(DEXFORCE_LOGO),
+ show_label=False,
+ container=False,
+ height=58,
+ width=183,
+ )
+ heading = gr.Markdown(UI_TEXT[LANGUAGE_EN]["heading"])
+ with gr.Row(elem_classes="demo-only"):
+ auto_button = gr.Button("Auto", variant="secondary")
+ interact_button = gr.Button("Interact", variant="primary")
+ parallel_env_button = gr.Button("Parallel Simulation", variant="secondary")
+ language_button = gr.Button("中文", variant="secondary")
+ with gr.Row(elem_classes="demo-only"):
+ with gr.Column(scale=4):
+ instruction = gr.HTML(
+ ""
+ "Upload one image, enter one task, then EmbodiChain "
+ " will generate what you want."
+ "
"
+ )
+ with gr.Column(scale=1):
+ robot_profile = gr.Radio(
+ choices=ROBOT_PROFILES,
+ value=DEFAULT_ROBOT_PROFILE,
+ label=UI_TEXT[LANGUAGE_EN]["robot"],
+ )
+
+ with gr.Row(elem_classes="demo-only"):
+ with gr.Column(scale=1):
+ image_input = gr.Image(
+ label=UI_TEXT[LANGUAGE_EN]["input_image"],
+ sources=["upload", "webcam"],
+ type="filepath",
+ format="png",
+ height=320,
+ )
+ with gr.Row():
+ with gr.Column():
+ task_input = gr.Textbox(
+ label=UI_TEXT[LANGUAGE_EN]["task_description"],
+ placeholder=UI_TEXT[LANGUAGE_EN]["task_placeholder"],
+ lines=1,
+ )
+ random_task_input_button = gr.Button("Random Task")
+ with gr.Column():
+ env_input = gr.Textbox(
+ label=UI_TEXT[LANGUAGE_EN]["scene_description"],
+ placeholder=UI_TEXT[LANGUAGE_EN]["scene_placeholder"],
+ lines=1,
+ )
+ random_scene_input_button = gr.Button("Random Scene")
+ scene_mode = gr.Radio(
+ choices=scene_mode_choices(LANGUAGE_EN),
+ value=SCENE_MODE_INITIAL,
+ label=UI_TEXT[LANGUAGE_EN]["scene_mode"],
+ )
+ with gr.Row():
+ generate_button = gr.Button("Generate", variant="primary")
+ rerun_simulation_button = gr.Button("Run Task", variant="secondary")
+ reset_button = gr.Button("Reset", variant="stop")
+ with gr.Column(scale=2):
+ current_image = gr.Video(
+ label=UI_TEXT[LANGUAGE_EN]["single_video_preview"],
+ height=420,
+ elem_id="embodichain-video-preview",
+ autoplay=True,
+ loop=True,
+ )
+ current_task = gr.Textbox(
+ label=UI_TEXT[LANGUAGE_EN]["current_task"],
+ interactive=False,
+ lines=2,
+ )
+
+ progress = gr.Slider(
+ minimum=0,
+ maximum=100,
+ value=0,
+ step=1,
+ label=UI_TEXT[LANGUAGE_EN]["progress"],
+ interactive=False,
+ elem_classes="demo-only",
+ )
+ status = gr.Markdown(format_status("Idle."), elem_classes="demo-only")
+ with gr.Row(elem_classes="demo-only"):
+ model = gr.Model3D(
+ label=UI_TEXT[LANGUAGE_EN]["initial_preview"],
+ height=520,
+ clear_color=(0.94, 0.94, 0.94, 1.0),
+ )
+ edited_model = gr.Model3D(
+ label=UI_TEXT[LANGUAGE_EN]["edited_preview"],
+ height=520,
+ clear_color=(0.94, 0.94, 0.94, 1.0),
+ )
+ object_model = gr.Model3D(
+ label=UI_TEXT[LANGUAGE_EN]["object_preview"],
+ height=360,
+ clear_color=(0.94, 0.94, 0.94, 1.0),
+ elem_classes="demo-only",
+ )
+
+ refresh_timer = gr.Timer(2.0)
+ top_mode_outputs = [
+ auto_button,
+ interact_button,
+ parallel_env_button,
+ generate_button,
+ rerun_simulation_button,
+ random_task_input_button,
+ random_scene_input_button,
+ reset_button,
+ current_image,
+ run_mode,
+ action_mode,
+ ]
+ demo_mode_button.click(
+ select_application_mode,
+ inputs=[gr.State(APP_MODE_DEMO)],
+ outputs=[
+ demo_mode_button,
+ debug_mode_button,
+ debug_controls,
+ mode_style,
+ app_mode,
+ debug_engine_area,
+ ],
+ queue=False,
+ )
+ debug_mode_button.click(
+ select_application_mode,
+ inputs=[gr.State(APP_MODE_DEBUG)],
+ outputs=[
+ demo_mode_button,
+ debug_mode_button,
+ debug_controls,
+ mode_style,
+ app_mode,
+ debug_engine_area,
+ ],
+ queue=False,
+ )
+ for engine, button in zip(
+ (engine for engine, _ in DEBUG_ENGINES),
+ (
+ asset_engine_button,
+ scene_engine_button,
+ action_engine_button,
+ ),
+ ):
+ button.click(
+ select_debug_engine,
+ inputs=[gr.State(engine)],
+ outputs=[
+ asset_engine_button,
+ scene_engine_button,
+ action_engine_button,
+ asset_engine["panel"],
+ scene_engine_panel,
+ action_engine_panel,
+ ],
+ queue=False,
+ )
+ debug_scene_run.click(
+ run_scene_engine,
+ inputs=[debug_scene_image],
+ outputs=[
+ debug_scene_progress,
+ debug_scene_status,
+ debug_scene_output,
+ debug_scene_preview,
+ ],
+ )
+ debug_scene_reset.click(
+ reset_scene_engine,
+ outputs=[
+ debug_scene_image,
+ debug_scene_progress,
+ debug_scene_status,
+ debug_scene_output,
+ debug_scene_preview,
+ ],
+ queue=False,
+ )
+ debug_action_load.click(
+ action_engine_snapshot,
+ outputs=[
+ debug_action_video,
+ debug_action_current_task,
+ debug_action_progress,
+ debug_action_status,
+ debug_action_scene,
+ ],
+ queue=False,
+ )
+ debug_action_run.click(
+ run_action_engine_panel,
+ inputs=[debug_action_task, debug_action_robot],
+ outputs=[
+ debug_action_video,
+ debug_action_current_task,
+ debug_action_progress,
+ debug_action_status,
+ debug_action_scene,
+ ],
+ )
+ debug_action_refresh_timer.tick(
+ action_engine_snapshot,
+ outputs=[
+ debug_action_video,
+ debug_action_current_task,
+ debug_action_progress,
+ debug_action_status,
+ debug_action_scene,
+ ],
+ queue=False,
+ )
+ auto_button.click(
+ select_top_mode,
+ inputs=[
+ gr.State(TOP_MODE_AUTO),
+ gr.State(None),
+ run_mode,
+ action_mode,
+ language,
+ ],
+ outputs=top_mode_outputs,
+ queue=False,
+ )
+ interact_button.click(
+ select_top_mode,
+ inputs=[
+ gr.State(TOP_MODE_INTERACT),
+ gr.State(None),
+ run_mode,
+ action_mode,
+ language,
+ ],
+ outputs=top_mode_outputs,
+ queue=False,
+ )
+ parallel_env_button.click(
+ select_top_mode,
+ inputs=[
+ gr.State(None),
+ gr.State(TOP_MODE_PARALLEL_ENV),
+ run_mode,
+ action_mode,
+ language,
+ ],
+ outputs=top_mode_outputs,
+ queue=False,
+ )
+ language_button.click(
+ toggle_language,
+ inputs=[language, run_mode, action_mode],
+ outputs=[
+ auto_button,
+ interact_button,
+ parallel_env_button,
+ generate_button,
+ rerun_simulation_button,
+ random_task_input_button,
+ random_scene_input_button,
+ reset_button,
+ language_button,
+ heading,
+ instruction,
+ robot_profile,
+ image_input,
+ task_input,
+ env_input,
+ scene_mode,
+ current_image,
+ current_task,
+ progress,
+ model,
+ edited_model,
+ object_model,
+ language,
+ ],
+ queue=False,
+ )
+ generate_button.click(
+ run_generate_for_top_mode,
+ inputs=[
+ run_mode,
+ action_mode,
+ scene_mode,
+ robot_profile,
+ image_input,
+ task_input,
+ env_input,
+ interact_prebuilt_scene_dir,
+ language,
+ ],
+ outputs=[
+ image_input,
+ task_input,
+ env_input,
+ current_image,
+ current_task,
+ progress,
+ status,
+ model,
+ edited_model,
+ object_model,
+ ],
+ )
+ random_task_input_button.click(
+ randomize_interact_task_input,
+ inputs=[run_mode, language],
+ outputs=[
+ image_input,
+ task_input,
+ env_input,
+ scene_mode,
+ interact_prebuilt_scene_dir,
+ model,
+ edited_model,
+ object_model,
+ ],
+ queue=False,
+ )
+ random_scene_input_button.click(
+ randomize_interact_scene_input,
+ inputs=[run_mode, language],
+ outputs=[env_input],
+ queue=False,
+ )
+ rerun_simulation_button.click(
+ rerun_current_simulation,
+ inputs=[
+ run_mode,
+ action_mode,
+ robot_profile,
+ ],
+ outputs=[
+ image_input,
+ task_input,
+ env_input,
+ current_image,
+ current_task,
+ progress,
+ status,
+ model,
+ edited_model,
+ object_model,
+ ],
+ queue=False,
+ )
+ image_input.upload(
+ clear_interact_prebuilt_scene,
+ outputs=[interact_prebuilt_scene_dir],
+ queue=False,
+ )
+ scene_mode.change(
+ scene_mode_input_updates,
+ inputs=[scene_mode],
+ outputs=[task_input, env_input],
+ queue=False,
+ )
+ reset_button.click(
+ clear_interact_prebuilt_scene,
+ outputs=[interact_prebuilt_scene_dir],
+ queue=False,
+ )
+ reset_button.click(
+ run_reset_or_stop,
+ inputs=[run_mode],
+ outputs=[
+ image_input,
+ task_input,
+ env_input,
+ current_image,
+ current_task,
+ progress,
+ status,
+ model,
+ edited_model,
+ object_model,
+ ],
+ queue=False,
+ )
+ refresh_timer.tick(
+ synced_ui_snapshot,
+ inputs=[run_mode, action_mode, last_seen_input_revision],
+ outputs=[
+ image_input,
+ task_input,
+ env_input,
+ current_image,
+ current_task,
+ progress,
+ status,
+ model,
+ edited_model,
+ object_model,
+ rerun_simulation_button,
+ last_seen_input_revision,
+ scene_mode,
+ robot_profile,
+ parallel_env_button,
+ action_mode,
+ ],
+ queue=False,
+ )
+ return demo
diff --git a/embodichain/gen_sim/gradio_ui/app_workflows.py b/embodichain/gen_sim/gradio_ui/app_workflows.py
new file mode 100644
index 000000000..4895454eb
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_workflows.py
@@ -0,0 +1,3427 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+import hashlib
+import html
+import io
+import json
+import importlib.util
+import math
+import os
+import queue
+import shutil
+import signal
+import socket
+import subprocess
+import sys
+import threading
+import time
+import uuid
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Iterable
+
+from random_input import IMAGE_DIR as AUTO_BASE_IMAGE_DIR
+from random_input import (
+ auto_image_directories,
+ available_auto_task_indices,
+ generate_auto_scene_description,
+ generate_auto_text_input,
+ get_prebuilt_scene_dir,
+ parse_task_id,
+)
+from app_config import * # noqa: F403 - services intentionally consume central config.
+from app_env import SCENE_ENGINE_VISER_PORT, configure_direct_network_env
+from app_processes import (
+ build_pipeline_env,
+ build_run_agent_command,
+ detect_phase_from_files,
+ read_process_output,
+ run_agent_cli_supports_robot_profile,
+ start_pipeline,
+ terminate_process_group,
+ update_phase_from_log,
+)
+from app_media import * # noqa: F403 - workflow consumes media service helpers.
+
+configure_direct_network_env()
+
+import gradio as gr
+import numpy as np
+import trimesh
+from PIL import Image, ImageDraw, ImageOps
+
+_RUN_AGENT_SUPPORTS_ROBOT_PROFILE: bool | None = None
+VIDEO_SYNC_JS = r"""
+() => {
+ const audienceRootId = "embodichain-audience-video";
+ const lerobotRootId = "embodichain-lerobot-video";
+ let syncing = false;
+
+ function findVideo(rootId) {
+ const root = document.getElementById(rootId);
+ return root ? root.querySelector("video") : null;
+ }
+
+ function sourceLoaded(video) {
+ return Boolean(video && (video.currentSrc || video.src));
+ }
+
+ function copyTime(source, target) {
+ if (!sourceLoaded(source) || !sourceLoaded(target)) {
+ return;
+ }
+ const sourceTime = source.currentTime || 0;
+ if (!Number.isFinite(sourceTime)) {
+ return;
+ }
+ const duration = Number.isFinite(target.duration) ? target.duration : sourceTime;
+ const targetTime = Math.min(sourceTime, duration);
+ if (Math.abs((target.currentTime || 0) - targetTime) > 0.35) {
+ try {
+ target.currentTime = targetTime;
+ } catch (_) {
+ // Some browsers reject seeking before metadata is fully available.
+ }
+ }
+ }
+
+ function syncPlayback(sourceRootId, targetRootId, shouldPlay) {
+ if (syncing) {
+ return;
+ }
+ const source = findVideo(sourceRootId);
+ const target = findVideo(targetRootId);
+ if (!sourceLoaded(source) || !sourceLoaded(target)) {
+ return;
+ }
+
+ syncing = true;
+ copyTime(source, target);
+
+ const release = () => {
+ window.setTimeout(() => {
+ syncing = false;
+ }, 0);
+ };
+
+ if (shouldPlay) {
+ const result = target.play();
+ if (result && typeof result.finally === "function") {
+ result.catch(() => {}).finally(release);
+ } else {
+ release();
+ }
+ } else {
+ target.pause();
+ release();
+ }
+ }
+
+ function bindOne(rootId, peerRootId) {
+ const video = findVideo(rootId);
+ if (!sourceLoaded(video) || video.dataset.embodichainSyncBound === "true") {
+ return;
+ }
+ video.dataset.embodichainSyncBound = "true";
+ video.addEventListener("play", () => syncPlayback(rootId, peerRootId, true));
+ video.addEventListener("pause", () => syncPlayback(rootId, peerRootId, false));
+ }
+
+ function bindVideos() {
+ bindOne(audienceRootId, lerobotRootId);
+ bindOne(lerobotRootId, audienceRootId);
+ }
+
+ bindVideos();
+ window.setInterval(bindVideos, 1000);
+ const observer = new MutationObserver(bindVideos);
+ observer.observe(document.body, { childList: true, subtree: true });
+}
+"""
+
+
+# Runtime ownership lives in app_state; this module only orchestrates it.
+from app_state import (
+ PHASES,
+ Phase,
+ RuntimeState,
+ clear_run_timing_locked,
+ format_duration_ns,
+ format_timing_lines,
+ record_phase_transition_locked,
+ record_simulation_finished_locked,
+ record_simulation_started_locked,
+ runtime,
+ runtime_lock,
+ set_runtime_phase_locked,
+ snapshot_timing_locked,
+ start_run_timing_locked,
+)
+
+
+@dataclass(frozen=True)
+class ScenePaths:
+ scene_id: str
+ image_path: Path
+ prompt_root: Path
+ config_dir: Path
+
+ @property
+ def fast_gym_config(self) -> Path:
+ return self.config_dir / "fast_gym_config.json"
+
+ @property
+ def agent_config(self) -> Path:
+ return self.config_dir / "agent_config.json"
+
+ @property
+ def gradio_scene_dir(self) -> Path:
+ return self.config_dir / "gradio_scene"
+
+ @property
+ def gradio_scene_glb(self) -> Path:
+ return self.gradio_scene_dir / "scene_current.glb"
+
+ @property
+ def gradio_object_preview_glb(self) -> Path:
+ return self.gradio_scene_dir / "object_preview.glb"
+
+ @property
+ def scene_manifest(self) -> Path:
+ return self.gradio_scene_dir / "scene_manifest.json"
+
+ @property
+ def object_preview_manifest(self) -> Path:
+ return self.gradio_scene_dir / "object_preview_manifest.json"
+
+
+CURRENT_PATHS = ScenePaths(
+ scene_id=SCENE_ID,
+ image_path=IMAGE_PATH,
+ prompt_root=PROMPT2SCENE_ROOT,
+ config_dir=CONFIG_DIR,
+)
+
+
+def make_stage_paths(run_token: str) -> ScenePaths:
+ scene_id = f"{PENDING_PREFIX}{run_token[:12]}"
+ return ScenePaths(
+ scene_id=scene_id,
+ image_path=IMAGE_DIR / f"{scene_id}.png",
+ prompt_root=GYM_PROJECT_ROOT / scene_id,
+ config_dir=ACTION_AGENT_ROOT / "configs" / scene_id,
+ )
+
+
+def make_replaced_paths(run_token: str) -> ScenePaths:
+ scene_id = f"{REPLACED_PREFIX}{run_token[:12]}"
+ return ScenePaths(
+ scene_id=scene_id,
+ image_path=IMAGE_DIR / f"{scene_id}.png",
+ prompt_root=GYM_PROJECT_ROOT / scene_id,
+ config_dir=ACTION_AGENT_ROOT / "configs" / scene_id,
+ )
+
+
+def save_input(
+ image_value: str | np.ndarray | Image.Image,
+ task_text: str,
+ image_path: Path,
+) -> Path:
+ if image_value is None:
+ raise ValueError("Please upload an image first.")
+ if not task_text.strip():
+ raise ValueError("Please enter a task description.")
+
+ image_path.parent.mkdir(parents=True, exist_ok=True)
+ if isinstance(image_value, str):
+ image = Image.open(image_value)
+ elif isinstance(image_value, np.ndarray):
+ image = Image.fromarray(image_value)
+ elif isinstance(image_value, Image.Image):
+ image = image_value
+ else:
+ raise TypeError(f"Unsupported image input type: {type(image_value)!r}")
+
+ image = ImageOps.exif_transpose(image).convert("RGB")
+ image.save(image_path, format="PNG")
+ return image_path
+
+
+def reset_current_scene() -> list[str]:
+ process: subprocess.Popen[str] | None = None
+ sim_process: subprocess.Popen[str] | None = None
+ with runtime_lock:
+ runtime.run_token = uuid.uuid4().hex
+ runtime.auto_loop_active = False
+ runtime.auto_loop_token = None
+ runtime.auto_round = 0
+ process = runtime.process
+ sim_process = runtime.sim_process
+ runtime.process = None
+ runtime.sim_process = None
+ runtime.sim_started = False
+ runtime.sim_finished = False
+ runtime.sim_returncode = None
+ runtime.is_busy = False
+ runtime.phase_key = "idle"
+ runtime.status = "Idle."
+ runtime.task_text = ""
+ runtime.input_task_text = ""
+ runtime.input_scene_text = ""
+ runtime.image_path = None
+ runtime.video_path = None
+ runtime.lerobot_video_path = None
+ runtime.lerobot_dataset_path = None
+ runtime.object_model_path = None
+ runtime.scene_model_path = None
+ runtime.edited_scene_model_path = None
+ runtime.last_error = None
+ runtime.log_lines.clear()
+ clear_run_timing_locked()
+
+ if process is not None:
+ terminate_process_group(process)
+ if sim_process is not None:
+ terminate_process_group(sim_process)
+
+ return cleanup_current_and_staging()
+
+
+def cleanup_current_and_staging() -> list[str]:
+ errors: list[str] = []
+ paths: list[Path] = [
+ PROMPT2SCENE_ROOT,
+ CONFIG_DIR,
+ IMAGE_PATH,
+ *pending_artifact_paths(),
+ ]
+ for path in paths:
+ errors.extend(remove_path(path))
+ errors.extend(cleanup_outputs_preserving_videos())
+ return errors
+
+
+def cleanup_auto_generated_artifacts(extra_image_path: Path | None = None) -> list[str]:
+ errors: list[str] = []
+ paths: list[Path] = [
+ PROMPT2SCENE_ROOT,
+ CONFIG_DIR,
+ IMAGE_PATH,
+ *pending_artifact_paths(),
+ ]
+ if extra_image_path is not None:
+ paths.append(extra_image_path)
+
+ for path in paths:
+ if is_protected_auto_base_image(path):
+ continue
+ errors.extend(remove_path(path))
+
+ with runtime_lock:
+ runtime.image_path = None
+ runtime.input_task_text = ""
+ runtime.input_scene_text = ""
+ runtime.lerobot_video_path = None
+ runtime.lerobot_dataset_path = None
+ runtime.object_model_path = None
+ runtime.scene_model_path = None
+ runtime.edited_scene_model_path = None
+ errors.extend(cleanup_outputs_preserving_videos())
+ return errors
+
+
+def is_protected_auto_base_image(path: Path) -> bool:
+ try:
+ path.resolve().relative_to(AUTO_BASE_IMAGE_DIR.resolve())
+ except ValueError:
+ return False
+ except FileNotFoundError:
+ return False
+ return True
+
+
+def pending_artifact_paths() -> list[Path]:
+ paths: list[Path] = []
+ for root in (GYM_PROJECT_ROOT, ACTION_AGENT_ROOT / "configs"):
+ if root.is_dir():
+ paths.extend(root.glob(f"{PENDING_PREFIX}*"))
+ paths.extend(root.glob(f"{REPLACED_PREFIX}*"))
+ if IMAGE_DIR.is_dir():
+ paths.extend(IMAGE_DIR.glob(f"{PENDING_PREFIX}*.png"))
+ paths.extend(IMAGE_DIR.glob(f"{REPLACED_PREFIX}*.png"))
+ return paths
+
+
+def remove_path(path: Path) -> list[str]:
+ try:
+ if path.is_dir():
+ shutil.rmtree(path)
+ else:
+ path.unlink(missing_ok=True)
+ except Exception as exc:
+ return [f"Failed to remove {path}: {exc}"]
+ return []
+
+
+def cleanup_outputs_preserving_videos() -> list[str]:
+ if not OUTPUTS_DIR.exists():
+ return []
+ if OUTPUTS_DIR.is_file():
+ if OUTPUTS_DIR.suffix.lower() in VIDEO_SUFFIXES:
+ return []
+ return remove_path(OUTPUTS_DIR)
+
+ errors: list[str] = []
+ for path in sorted(
+ OUTPUTS_DIR.rglob("*"),
+ key=lambda item: len(item.parts),
+ reverse=True,
+ ):
+ if path.is_file() and path.suffix.lower() not in VIDEO_SUFFIXES:
+ errors.extend(remove_path(path))
+
+ for path in sorted(
+ OUTPUTS_DIR.rglob("*"),
+ key=lambda item: len(item.parts),
+ reverse=True,
+ ):
+ if not path.is_dir():
+ continue
+ try:
+ path.rmdir()
+ except OSError:
+ pass
+ except Exception as exc:
+ errors.append(f"Failed to remove empty output directory {path}: {exc}")
+ return errors
+
+
+from app_commands import (
+ build_config_command_for_paths,
+ build_initial_pipeline_command,
+ build_scene_edit_pipeline_command,
+ robot_profile_cli_value,
+)
+
+
+def build_edit_pipeline_command(
+ task_text: str,
+ env_text: str,
+ robot_profile: str | None = None,
+ load_template_material: bool = False,
+) -> list[str]:
+ return build_scene_edit_pipeline_command(
+ task_text, env_text, CURRENT_PATHS, robot_profile, load_template_material
+ )
+
+
+def build_task_only_config_command(
+ task_text: str,
+ robot_profile: str | None = None,
+ load_template_material: bool = False,
+) -> list[str]:
+ return build_config_command_for_paths(
+ task_text, CURRENT_PATHS, robot_profile, load_template_material
+ )
+
+
+def format_current_task(task_text: str, env_text: str = "") -> str:
+ return "\n".join(
+ part for part in ((task_text or "").strip(), (env_text or "").strip()) if part
+ )
+
+
+def build_gradio_scene_from_fast_config(
+ config_path: Path,
+ scene_dir: Path | None = None,
+) -> Path:
+ config_dir = config_path.parent
+ if scene_dir is None:
+ scene_dir = config_dir / "gradio_scene"
+ scene_glb = scene_dir / "scene_current.glb"
+ scene_manifest = scene_dir / "scene_manifest.json"
+ with config_path.open("r", encoding="utf-8") as file:
+ config = json.load(file)
+ config_stat = config_path.stat()
+
+ scene = trimesh.Scene()
+ manifest: dict[str, Any] = {
+ "source_config": os.path.relpath(config_path, scene_dir),
+ "source_config_size": config_stat.st_size,
+ "source_config_mtime_ns": config_stat.st_mtime_ns,
+ "transform_policy": GRADIO_SCENE_TRANSFORM_POLICY,
+ "objects": [],
+ }
+
+ object_count = 0
+ for role, obj in iter_scene_objects(config):
+ shape = obj.get("shape") if isinstance(obj, dict) else None
+ if not isinstance(shape, dict) or shape.get("shape_type") != "Mesh":
+ continue
+ raw_fpath = shape.get("fpath")
+ if not raw_fpath:
+ continue
+ mesh_path = resolve_mesh_path(config_dir, str(raw_fpath))
+ if not mesh_path.is_file():
+ raise FileNotFoundError(
+ f"Mesh file not found for {obj.get('uid')}: {mesh_path}"
+ )
+
+ transform = object_transform(obj)
+ frame_transform = gltf_to_sim_frame_transform(mesh_path)
+ if frame_transform is not None:
+ transform = transform @ frame_transform
+ add_mesh_to_scene(scene, mesh_path, transform, str(obj.get("uid", "object")))
+ manifest["objects"].append(
+ {
+ "uid": obj.get("uid"),
+ "role": role,
+ "source_mesh": os.path.relpath(mesh_path, scene_dir),
+ "source_mesh_size": mesh_path.stat().st_size,
+ "source_mesh_mtime_ns": mesh_path.stat().st_mtime_ns,
+ "gltf_to_sim_frame": frame_transform is not None,
+ }
+ )
+ object_count += 1
+
+ if object_count == 0:
+ raise ValueError(f"No mesh objects found in {config_path}")
+
+ scene_dir.mkdir(parents=True, exist_ok=True)
+ scene.export(scene_glb)
+ scene_manifest.write_text(
+ json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ return scene_glb
+
+
+def gradio_scene_is_current(
+ scene_glb: Path,
+ manifest_path: Path,
+ config_path: Path,
+) -> bool:
+ if (
+ not scene_glb.is_file()
+ or not manifest_path.is_file()
+ or not config_path.is_file()
+ ):
+ return False
+ try:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ config = json.loads(config_path.read_text(encoding="utf-8"))
+ config_stat = config_path.stat()
+ except Exception:
+ return False
+
+ if manifest.get("source_config") != os.path.relpath(
+ config_path, manifest_path.parent
+ ):
+ return False
+ if manifest.get("source_config_size") != config_stat.st_size:
+ return False
+ if manifest.get("source_config_mtime_ns") != config_stat.st_mtime_ns:
+ return False
+ if manifest.get("transform_policy") != GRADIO_SCENE_TRANSFORM_POLICY:
+ return False
+
+ expected_objects = []
+ try:
+ for role, obj in iter_scene_objects(config):
+ shape = obj.get("shape") if isinstance(obj, dict) else None
+ if not isinstance(shape, dict) or shape.get("shape_type") != "Mesh":
+ continue
+ raw_fpath = shape.get("fpath")
+ if not raw_fpath:
+ continue
+ mesh_path = resolve_mesh_path(config_path.parent, str(raw_fpath))
+ mesh_stat = mesh_path.stat()
+ frame_transform = gltf_to_sim_frame_transform(mesh_path)
+ expected_objects.append(
+ {
+ "uid": obj.get("uid"),
+ "role": role,
+ "source_mesh": os.path.relpath(mesh_path, manifest_path.parent),
+ "source_mesh_size": mesh_stat.st_size,
+ "source_mesh_mtime_ns": mesh_stat.st_mtime_ns,
+ "gltf_to_sim_frame": frame_transform is not None,
+ }
+ )
+ except OSError:
+ return False
+ return manifest.get("objects") == expected_objects
+
+
+def collect_generated_object_glbs(paths: ScenePaths) -> list[Path]:
+ if not paths.prompt_root.is_dir():
+ return []
+
+ glb_paths: list[Path] = []
+ seen: set[Path] = set()
+ for glb_dir in sorted(paths.prompt_root.rglob("glb_gen")):
+ if not glb_dir.is_dir():
+ continue
+ candidates = [
+ path for path in glb_dir.rglob("*_simready.glb") if is_previewable_glb(path)
+ ]
+ if not candidates:
+ candidates = [
+ path for path in glb_dir.rglob("*.glb") if is_previewable_glb(path)
+ ]
+ for path in sorted(candidates):
+ resolved = path.resolve()
+ if resolved in seen:
+ continue
+ seen.add(resolved)
+ glb_paths.append(path)
+ return glb_paths
+
+
+def is_previewable_glb(path: Path) -> bool:
+ if not path.is_file() or path.name.startswith("."):
+ return False
+ return not any(part.startswith(".") for part in path.relative_to(path.anchor).parts)
+
+
+def build_object_preview_scene(
+ glb_paths: list[Path],
+ scene_dir: Path,
+) -> Path:
+ if not glb_paths:
+ raise ValueError("No generated object GLBs found")
+
+ scene_dir.mkdir(parents=True, exist_ok=True)
+ preview_glb = scene_dir / "object_preview.glb"
+ preview_manifest = scene_dir / "object_preview_manifest.json"
+ scene = trimesh.Scene()
+ manifest: dict[str, Any] = {"objects": []}
+
+ cursor = 0.0
+ spacing = 0.35
+ added_count = 0
+ for object_index, mesh_path in enumerate(glb_paths):
+ meshes = load_mesh_geometries(mesh_path)
+ if not meshes:
+ continue
+
+ bounds = combined_bounds(meshes)
+ extents = bounds[1] - bounds[0]
+ max_extent = float(max(extents.max(), 1e-6))
+ scale = 1.0 / max_extent
+ scaled_width = max(float(extents[0]) * scale, 0.2)
+ placement_x = cursor + scaled_width / 2.0
+ cursor += scaled_width + spacing
+
+ transform = (
+ trimesh.transformations.translation_matrix(
+ [
+ placement_x,
+ 0.0,
+ 0.0,
+ ]
+ )
+ @ trimesh.transformations.scale_matrix(scale)
+ @ trimesh.transformations.translation_matrix(
+ [
+ -float((bounds[0][0] + bounds[1][0]) / 2.0),
+ -float((bounds[0][1] + bounds[1][1]) / 2.0),
+ -float(bounds[0][2]),
+ ]
+ )
+ )
+
+ for mesh_index, mesh in enumerate(meshes):
+ mesh.apply_transform(transform)
+ name = f"object_{object_index}_{mesh_index}"
+ scene.add_geometry(mesh, node_name=name, geom_name=name)
+ added_count += 1
+
+ manifest["objects"].append(
+ {
+ "source_mesh": os.path.relpath(mesh_path, scene_dir),
+ "size": mesh_path.stat().st_size,
+ "mtime_ns": mesh_path.stat().st_mtime_ns,
+ }
+ )
+
+ if added_count == 0:
+ raise ValueError("No renderable meshes found in generated object GLBs")
+
+ if cursor > spacing:
+ scene.apply_transform(
+ trimesh.transformations.translation_matrix(
+ [-(cursor - spacing) / 2.0, 0.0, 0.0]
+ )
+ )
+ scene.export(preview_glb)
+ preview_manifest.write_text(
+ json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ return preview_glb
+
+
+def object_preview_is_current(
+ manifest_path: Path,
+ glb_paths: list[Path],
+) -> bool:
+ if not manifest_path.is_file():
+ return False
+ try:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ except Exception:
+ return False
+ expected = []
+ for path in glb_paths:
+ try:
+ stat = path.stat()
+ except OSError:
+ return False
+ expected.append(
+ {
+ "source_mesh": os.path.relpath(path, manifest_path.parent),
+ "size": stat.st_size,
+ "mtime_ns": stat.st_mtime_ns,
+ }
+ )
+ return manifest.get("objects") == expected
+
+
+def load_mesh_geometries(mesh_path: Path) -> list[trimesh.Trimesh]:
+ loaded = trimesh.load(mesh_path, force="scene", process=False)
+ gltf_to_sim_transform = gltf_to_sim_frame_transform(mesh_path)
+ if isinstance(loaded, trimesh.Trimesh):
+ mesh = loaded.copy()
+ if gltf_to_sim_transform is not None:
+ mesh.apply_transform(gltf_to_sim_transform)
+ return [mesh]
+ if isinstance(loaded, trimesh.Scene):
+ meshes: list[trimesh.Trimesh] = []
+ for geometry in loaded.dump(concatenate=False):
+ if isinstance(geometry, trimesh.Trimesh):
+ mesh = geometry.copy()
+ if gltf_to_sim_transform is not None:
+ mesh.apply_transform(gltf_to_sim_transform)
+ meshes.append(mesh)
+ return meshes
+ raise TypeError(f"Unsupported mesh type for {mesh_path}: {type(loaded)!r}")
+
+
+def gltf_to_sim_frame_transform(mesh_path: Path) -> np.ndarray | None:
+ if mesh_path.suffix.lower() not in {".glb", ".gltf"}:
+ return None
+ # Match DexSim's native GLTF Y-up to simulation Z-up conversion.
+ transform = np.eye(4)
+ transform[:3, :3] = np.array(
+ [
+ [1.0, 0.0, 0.0],
+ [0.0, 0.0, -1.0],
+ [0.0, 1.0, 0.0],
+ ],
+ dtype=float,
+ )
+ return transform
+
+
+def combined_bounds(meshes: list[trimesh.Trimesh]) -> np.ndarray:
+ valid_bounds = [
+ mesh.bounds
+ for mesh in meshes
+ if mesh.vertices is not None and len(mesh.vertices) > 0
+ ]
+ if not valid_bounds:
+ raise ValueError("Mesh has no vertices")
+ bounds = np.asarray(valid_bounds, dtype=float)
+ return np.stack([bounds[:, 0, :].min(axis=0), bounds[:, 1, :].max(axis=0)])
+
+
+def iter_scene_objects(config: dict[str, Any]) -> Iterable[tuple[str, dict[str, Any]]]:
+ for role in ("background", "rigid_object"):
+ value = config.get(role, [])
+ if isinstance(value, dict):
+ value = [value]
+ if not isinstance(value, list):
+ continue
+ for obj in value:
+ if isinstance(obj, dict):
+ yield role, obj
+
+
+def resolve_mesh_path(config_dir: Path, raw_fpath: str) -> Path:
+ mesh_path = Path(raw_fpath).expanduser()
+ if not mesh_path.is_absolute():
+ mesh_path = config_dir / mesh_path
+ return mesh_path.resolve()
+
+
+def object_transform(obj: dict[str, Any]) -> np.ndarray:
+ scale = vector3(obj.get("body_scale"), [1.0, 1.0, 1.0])
+
+ scale_matrix = np.eye(4)
+ scale_matrix[0, 0] = scale[0]
+ scale_matrix[1, 1] = scale[1]
+ scale_matrix[2, 2] = scale[2]
+
+ init_local_pose = matrix4(obj.get("init_local_pose"))
+ if init_local_pose is not None:
+ return init_local_pose @ scale_matrix
+
+ position = vector3(obj.get("init_pos"), [0.0, 0.0, 0.0])
+ rotation_degrees = vector3(obj.get("init_rot"), [0.0, 0.0, 0.0])
+ root_matrix = euler_xyz_degrees_matrix(rotation_degrees, position)
+ return root_matrix @ scale_matrix
+
+
+def euler_xyz_degrees_matrix(
+ rotation_degrees: list[float],
+ position: list[float],
+) -> np.ndarray:
+ rx, ry, rz = (math.radians(value) for value in rotation_degrees)
+ cx, sx = math.cos(rx), math.sin(rx)
+ cy, sy = math.cos(ry), math.sin(ry)
+ cz, sz = math.cos(rz), math.sin(rz)
+
+ rot_x = np.array(
+ [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, cx, -sx, 0.0],
+ [0.0, sx, cx, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ],
+ dtype=float,
+ )
+ rot_y = np.array(
+ [
+ [cy, 0.0, sy, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [-sy, 0.0, cy, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ],
+ dtype=float,
+ )
+ rot_z = np.array(
+ [
+ [cz, -sz, 0.0, 0.0],
+ [sz, cz, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ],
+ dtype=float,
+ )
+ matrix = rot_x @ rot_y @ rot_z
+ matrix[:3, 3] = position
+ return matrix
+
+
+def matrix4(value: Any) -> np.ndarray | None:
+ if not isinstance(value, (list, tuple)) or len(value) != 4:
+ return None
+ try:
+ matrix = np.asarray(value, dtype=float)
+ except (TypeError, ValueError):
+ return None
+ if matrix.shape != (4, 4) or not np.all(np.isfinite(matrix)):
+ return None
+ return matrix
+
+
+def vector3(value: Any, default: list[float]) -> list[float]:
+ if not isinstance(value, (list, tuple)) or len(value) != 3:
+ return list(default)
+ return [float(value[0]), float(value[1]), float(value[2])]
+
+
+def add_mesh_to_scene(
+ scene: trimesh.Scene,
+ mesh_path: Path,
+ transform: np.ndarray,
+ uid: str,
+) -> None:
+ loaded = trimesh.load(mesh_path, force="scene", process=False)
+ if isinstance(loaded, trimesh.Trimesh):
+ loaded.apply_transform(transform)
+ scene.add_geometry(loaded, node_name=uid, geom_name=uid)
+ return
+
+ if isinstance(loaded, trimesh.Scene):
+ loaded.apply_transform(transform)
+ for index, geometry in enumerate(loaded.dump(concatenate=False)):
+ if isinstance(geometry, trimesh.Trimesh):
+ scene.add_geometry(
+ geometry,
+ node_name=f"{uid}_{index}",
+ geom_name=f"{uid}_{index}",
+ )
+ return
+
+ raise TypeError(f"Unsupported mesh type for {mesh_path}: {type(loaded)!r}")
+
+
+def promote_stage_to_current(stage: ScenePaths, run_token: str) -> list[str]:
+ backup = make_replaced_paths(run_token)
+ promotion_errors: list[str] = []
+ cleanup_errors: list[str] = []
+
+ for required_path in (stage.prompt_root, stage.config_dir, stage.image_path):
+ if not required_path.exists():
+ raise FileNotFoundError(f"Generated artifact missing: {required_path}")
+
+ cleanup_errors.extend(remove_path(backup.prompt_root))
+ cleanup_errors.extend(remove_path(backup.config_dir))
+ cleanup_errors.extend(remove_path(backup.image_path))
+
+ moved_to_backup: list[tuple[Path, Path]] = []
+ moved_to_current: list[tuple[Path, Path]] = []
+ try:
+ move_if_exists(PROMPT2SCENE_ROOT, backup.prompt_root, moved_to_backup)
+ move_if_exists(CONFIG_DIR, backup.config_dir, moved_to_backup)
+ move_if_exists(IMAGE_PATH, backup.image_path, moved_to_backup)
+
+ move_required(stage.prompt_root, PROMPT2SCENE_ROOT, moved_to_current)
+ move_required(stage.config_dir, CONFIG_DIR, moved_to_current)
+ move_required(stage.image_path, IMAGE_PATH, moved_to_current)
+ rewrite_promoted_paths(stage)
+ except Exception as exc:
+ promotion_errors.append(f"Failed to promote generated scene: {exc}")
+ restore_promoted_paths(moved_to_current, moved_to_backup, promotion_errors)
+ raise RuntimeError("\n".join(promotion_errors)) from exc
+
+ cleanup_errors.extend(remove_path(backup.prompt_root))
+ cleanup_errors.extend(remove_path(backup.config_dir))
+ cleanup_errors.extend(remove_path(backup.image_path))
+ return cleanup_errors
+
+
+def move_if_exists(src: Path, dst: Path, moved: list[tuple[Path, Path]]) -> None:
+ if not src.exists():
+ return
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ src.rename(dst)
+ moved.append((src, dst))
+
+
+def move_required(src: Path, dst: Path, moved: list[tuple[Path, Path]]) -> None:
+ if not src.exists():
+ raise FileNotFoundError(src)
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ src.rename(dst)
+ moved.append((dst, src))
+
+
+def restore_promoted_paths(
+ moved_to_current: list[tuple[Path, Path]],
+ moved_to_backup: list[tuple[Path, Path]],
+ errors: list[str],
+) -> None:
+ for current_path, original_stage_path in reversed(moved_to_current):
+ try:
+ if current_path.exists():
+ original_stage_path.parent.mkdir(parents=True, exist_ok=True)
+ current_path.rename(original_stage_path)
+ except Exception as exc:
+ errors.append(
+ f"Failed to restore staging artifact {original_stage_path}: {exc}"
+ )
+
+ for original_current_path, backup_path in reversed(moved_to_backup):
+ try:
+ if backup_path.exists() and not original_current_path.exists():
+ original_current_path.parent.mkdir(parents=True, exist_ok=True)
+ backup_path.rename(original_current_path)
+ except Exception as exc:
+ errors.append(
+ f"Failed to restore previous scene {original_current_path}: {exc}"
+ )
+
+
+def rewrite_promoted_paths(stage: ScenePaths) -> None:
+ replacements = [
+ (str(stage.config_dir), str(CONFIG_DIR)),
+ (str(stage.prompt_root), str(PROMPT2SCENE_ROOT)),
+ (str(stage.image_path), str(IMAGE_PATH)),
+ (stage.scene_id, SCENE_ID),
+ ]
+ for root in (PROMPT2SCENE_ROOT, CONFIG_DIR):
+ if not root.is_dir():
+ continue
+ for path in root.rglob("*"):
+ if not path.is_file() or path.suffix.lower() not in TEXT_REWRITE_SUFFIXES:
+ continue
+ text = path.read_text(encoding="utf-8")
+ new_text = text
+ for old, new in replacements:
+ new_text = new_text.replace(old, new)
+ if new_text != text:
+ path.write_text(new_text, encoding="utf-8")
+
+
+def ensure_initial_scene_snapshot(*, overwrite: bool = False) -> Path:
+ if not GRADIO_SCENE_GLB.is_file():
+ build_gradio_scene_from_fast_config(FAST_GYM_CONFIG, GRADIO_SCENE_DIR)
+ if overwrite or not GRADIO_INITIAL_SCENE_GLB.is_file():
+ GRADIO_SCENE_DIR.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(GRADIO_SCENE_GLB, GRADIO_INITIAL_SCENE_GLB)
+ return GRADIO_INITIAL_SCENE_GLB
+
+
+def prepare_current_scene_for_edit() -> Path:
+ scene_state = PROMPT2SCENE_ROOT / "gym_export" / "scene_state" / "result.json"
+ if not scene_state.is_file():
+ raise FileNotFoundError(
+ f"Current prompt2scene scene state not found: {scene_state}"
+ )
+ if not FAST_GYM_CONFIG.is_file():
+ raise FileNotFoundError(f"Current gym config not found: {FAST_GYM_CONFIG}")
+
+ initial_scene_path = ensure_initial_scene_snapshot()
+ errors = remove_path(GRADIO_SCENE_GLB)
+ errors.extend(remove_path(SCENE_MANIFEST))
+ if errors:
+ raise RuntimeError("\n".join(errors))
+ return initial_scene_path
+
+
+def prebuilt_scene_dir_for_image_value(
+ image_value: str | np.ndarray | Image.Image,
+) -> Path | None:
+ if not isinstance(image_value, str):
+ return None
+ image_path = Path(image_value).expanduser()
+ task_index = parse_task_id(image_path.name)
+ if task_index is None:
+ return None
+
+ try:
+ resolved_image = image_path.resolve()
+ except FileNotFoundError:
+ return None
+ filename = image_path.name
+ matches_auto_image = False
+ for image_dir in auto_image_directories():
+ candidate = image_dir / filename
+ if not candidate.is_file():
+ continue
+ try:
+ if candidate.resolve() == resolved_image:
+ matches_auto_image = True
+ break
+ except FileNotFoundError:
+ continue
+ if not matches_auto_image:
+ return None
+
+ scene_dir = get_prebuilt_scene_dir(task_index)
+ return scene_dir if scene_dir.is_dir() else None
+
+
+def copy_prebuilt_scene_to_stage(prebuilt_scene_dir: Path, stage: ScenePaths) -> None:
+ required_paths = [
+ prebuilt_scene_dir / "gym_export" / "gym_config.json",
+ prebuilt_scene_dir / "gym_export" / "scene_state" / "result.json",
+ prebuilt_scene_dir / "gym_export" / "scene_state" / "unified_scene.json",
+ prebuilt_scene_dir / "gym_export" / "scene_state" / "unified_scene_gen.json",
+ ]
+ missing = [path for path in required_paths if not path.is_file()]
+ if missing:
+ missing_text = ", ".join(str(path) for path in missing)
+ raise FileNotFoundError(f"Prebuilt scene is incomplete: {missing_text}")
+
+ cleanup_errors = []
+ cleanup_errors.extend(remove_path(stage.prompt_root))
+ cleanup_errors.extend(remove_path(stage.config_dir))
+ if cleanup_errors:
+ raise RuntimeError("\n".join(cleanup_errors))
+ stage.prompt_root.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copytree(prebuilt_scene_dir, stage.prompt_root)
+
+
+def build_interact_random_initial_preview(prebuilt_scene_dir: Path) -> Path:
+ scene_id = prebuilt_scene_dir.name
+ preview_dir = INTERACT_RANDOM_PREVIEW_DIR / scene_id
+ config_path = prebuilt_scene_dir / "gym_export" / "gym_config.json"
+ return build_gradio_scene_from_fast_config(config_path, preview_dir)
+
+
+def current_scene_available_for_task_only() -> bool:
+ return CURRENT_GYM_EXPORT_CONFIG.is_file()
+
+
+def rerun_simulation_is_available() -> bool:
+ return (
+ CURRENT_PATHS.fast_gym_config.is_file() and CURRENT_PATHS.agent_config.is_file()
+ )
+
+
+def run_generate(
+ image_value: str | np.ndarray | Image.Image,
+ task_text: str,
+ env_text: str,
+ *,
+ force_initial: bool = False,
+ scene_mode: str = SCENE_MODE_INITIAL,
+ parallel_env: bool = False,
+ robot_profile: str | None = None,
+ load_template_material: bool = False,
+ run_log_mode: str = RUN_LOG_MODE_INTERACT,
+ prebuilt_scene_dir: Path | None = None,
+ launch_simulation: bool = True,
+):
+ task_text = (task_text or "").strip()
+ env_text = (env_text or "").strip()
+ if force_initial or scene_mode == SCENE_MODE_INITIAL:
+ mode = PIPELINE_MODE_INITIAL
+ elif scene_mode == SCENE_MODE_EDIT:
+ mode = PIPELINE_MODE_EDIT
+ elif scene_mode == SCENE_MODE_TASK_ONLY:
+ mode = PIPELINE_MODE_TASK_ONLY
+ else:
+ raise ValueError(f"Unsupported scene mode: {scene_mode}")
+ requested_mode = mode
+ if requested_mode == PIPELINE_MODE_TASK_ONLY:
+ env_text = ""
+ resolved_prebuilt_scene_dir = (
+ prebuilt_scene_dir or prebuilt_scene_dir_for_image_value(image_value)
+ )
+ use_prebuilt_scene = resolved_prebuilt_scene_dir is not None
+ supervisor_mode = PIPELINE_MODE_INITIAL if use_prebuilt_scene else mode
+ old_sim_process: subprocess.Popen[str] | None = None
+ with runtime_lock:
+ if runtime.is_busy:
+ yield ui_snapshot(extra_status="A pipeline run is already in progress.")
+ return
+ old_sim_process = runtime.sim_process
+ runtime.sim_process = None
+ runtime.sim_started = False
+ runtime.sim_finished = False
+ runtime.sim_returncode = None
+
+ if old_sim_process is not None:
+ terminate_process_group(old_sim_process)
+
+ token = uuid.uuid4().hex
+ stage = (
+ CURRENT_PATHS
+ if supervisor_mode in {PIPELINE_MODE_EDIT, PIPELINE_MODE_TASK_ONLY}
+ else make_stage_paths(token)
+ )
+ initial_scene_path: Path | None = None
+ existing_object_preview_path = (
+ GRADIO_OBJECT_PREVIEW_GLB
+ if supervisor_mode in {PIPELINE_MODE_EDIT, PIPELINE_MODE_TASK_ONLY}
+ and GRADIO_OBJECT_PREVIEW_GLB.is_file()
+ else None
+ )
+ prebuilt_initial_scene_dir: Path | None = None
+ try:
+ if use_prebuilt_scene:
+ if not task_text:
+ raise ValueError("Please enter a task description.")
+ if requested_mode == PIPELINE_MODE_EDIT and not env_text:
+ raise ValueError("Please enter a scene description to edit.")
+ image_path = save_input(image_value, task_text, stage.image_path)
+ prebuilt_initial_scene_dir = resolved_prebuilt_scene_dir
+ copy_prebuilt_scene_to_stage(prebuilt_initial_scene_dir, stage)
+ initial_scene_path = build_interact_random_initial_preview(
+ prebuilt_initial_scene_dir
+ )
+ elif mode == PIPELINE_MODE_EDIT:
+ if not task_text:
+ raise ValueError("Please enter a task description.")
+ if not env_text:
+ raise ValueError("Please enter a scene description to edit.")
+ initial_scene_path = prepare_current_scene_for_edit()
+ image_path = IMAGE_PATH if IMAGE_PATH.is_file() else None
+ elif mode == PIPELINE_MODE_TASK_ONLY:
+ if not task_text:
+ raise ValueError("Please enter a task description.")
+ if not CURRENT_GYM_EXPORT_CONFIG.is_file():
+ raise FileNotFoundError(
+ f"Current gym export not found: {CURRENT_GYM_EXPORT_CONFIG}"
+ )
+ if GRADIO_INITIAL_SCENE_GLB.is_file():
+ initial_scene_path = GRADIO_INITIAL_SCENE_GLB
+ elif GRADIO_SCENE_GLB.is_file():
+ initial_scene_path = GRADIO_SCENE_GLB
+ image_path = IMAGE_PATH if IMAGE_PATH.is_file() else None
+ else:
+ image_path = save_input(image_value, task_text, stage.image_path)
+ except Exception as exc:
+ with runtime_lock:
+ runtime.phase_key = "failed"
+ runtime.status = f"Input error: {exc}"
+ runtime.last_error = str(exc)
+ runtime.log_lines.clear()
+ clear_run_timing_locked()
+ runtime.log_lines.append(runtime.status)
+ if run_log_mode == RUN_LOG_MODE_INTERACT:
+ archive_run_log(
+ mode=RUN_LOG_MODE_INTERACT,
+ task_description=task_text,
+ scene_description=env_text,
+ outcome="input_error",
+ )
+ yield ui_snapshot()
+ return
+
+ should_edit_prebuilt_scene = (
+ prebuilt_initial_scene_dir is not None
+ and requested_mode != PIPELINE_MODE_TASK_ONLY
+ and bool(env_text)
+ )
+ if mode == PIPELINE_MODE_EDIT and prebuilt_initial_scene_dir is None:
+ command = build_edit_pipeline_command(
+ task_text,
+ env_text,
+ robot_profile,
+ load_template_material,
+ )
+ elif mode == PIPELINE_MODE_TASK_ONLY and prebuilt_initial_scene_dir is None:
+ command = build_task_only_config_command(
+ task_text,
+ robot_profile,
+ load_template_material,
+ )
+ elif should_edit_prebuilt_scene:
+ command = build_scene_edit_pipeline_command(
+ task_text,
+ env_text,
+ stage,
+ robot_profile,
+ load_template_material,
+ )
+ elif prebuilt_initial_scene_dir is not None:
+ command = build_config_command_for_paths(
+ task_text,
+ stage,
+ robot_profile,
+ load_template_material,
+ )
+ else:
+ command = build_initial_pipeline_command(
+ task_text,
+ stage,
+ env_text,
+ robot_profile,
+ load_template_material,
+ )
+ display_task_text = format_current_task(task_text, env_text)
+ with runtime_lock:
+ runtime.run_token = token
+ runtime.is_busy = True
+ runtime.phase_key = "received"
+ if mode == PIPELINE_MODE_EDIT:
+ runtime.status = "Starting scene edit..."
+ elif should_edit_prebuilt_scene:
+ runtime.status = "Prebuilt scene loaded. Starting scene edit..."
+ elif mode == PIPELINE_MODE_TASK_ONLY:
+ runtime.status = "Current scene found. Regenerating action config only..."
+ else:
+ runtime.status = "Input saved. Starting local pipeline..."
+ runtime.task_text = display_task_text
+ runtime.input_task_text = task_text
+ runtime.input_scene_text = env_text
+ runtime.image_path = image_path
+ runtime.submitted_input_revision += 1
+ runtime.lerobot_video_path = None
+ runtime.lerobot_dataset_path = None
+ runtime.object_model_path = existing_object_preview_path
+ runtime.scene_model_path = initial_scene_path
+ runtime.edited_scene_model_path = None
+ runtime.last_error = None
+ runtime.sim_started = False
+ runtime.sim_finished = False
+ runtime.sim_returncode = None
+ runtime.log_lines.clear()
+ clear_run_timing_locked()
+ runtime.log_lines.append("$ " + " ".join(command))
+ yield ui_snapshot()
+
+ try:
+ process = start_pipeline(command)
+ except Exception as exc:
+ with runtime_lock:
+ if runtime.run_token != token:
+ return
+ runtime.is_busy = False
+ runtime.process = None
+ runtime.phase_key = "failed"
+ runtime.status = f"Pipeline start failed: {exc}"
+ runtime.last_error = str(exc)
+ if run_log_mode == RUN_LOG_MODE_INTERACT:
+ archive_run_log(
+ mode=RUN_LOG_MODE_INTERACT,
+ task_description=task_text,
+ scene_description=env_text,
+ outcome="pipeline_start_failed",
+ )
+ yield ui_snapshot()
+ return
+
+ output_queue: queue.Queue[str] = queue.Queue()
+ reader = threading.Thread(
+ target=read_process_output,
+ args=(process, output_queue),
+ daemon=True,
+ )
+ supervisor = threading.Thread(
+ target=supervise_pipeline,
+ args=(
+ token,
+ stage,
+ supervisor_mode,
+ process,
+ display_task_text,
+ task_text,
+ env_text,
+ output_queue,
+ reader,
+ parallel_env,
+ robot_profile,
+ run_log_mode,
+ initial_scene_path,
+ should_edit_prebuilt_scene,
+ launch_simulation,
+ ),
+ daemon=True,
+ )
+
+ with runtime_lock:
+ if runtime.run_token != token:
+ terminate_process_group(process)
+ return
+ runtime.process = process
+ start_run_timing_locked("started")
+ runtime.phase_key = "started"
+ runtime.status = "Local pipeline started."
+ reader.start()
+ supervisor.start()
+ yield ui_snapshot()
+
+ while True:
+ with runtime_lock:
+ still_current = runtime.run_token == token
+ busy = runtime.is_busy
+ if not still_current or not busy:
+ break
+ time.sleep(1.0)
+ yield ui_snapshot()
+ yield ui_snapshot()
+
+
+def start_auto_loop_state() -> str | None:
+ process: subprocess.Popen[str] | None = None
+ sim_process: subprocess.Popen[str] | None = None
+ with runtime_lock:
+ if runtime.auto_loop_active or runtime.is_busy:
+ runtime.status = "A pipeline run is already in progress."
+ return None
+
+ available_tasks = available_auto_task_indices()
+ if not available_tasks:
+ image_dirs = ", ".join(str(path) for path in auto_image_directories())
+ message = (
+ "Auto cannot start: no task input images were found. "
+ "Add task1_0.png through task5_3.png to one of: "
+ f"{image_dirs}"
+ )
+ runtime.phase_key = "failed"
+ runtime.status = message
+ runtime.last_error = message
+ runtime.log_lines.clear()
+ clear_run_timing_locked()
+ runtime.log_lines.append(message)
+ return None
+
+ token = uuid.uuid4().hex
+ process = runtime.process
+ sim_process = runtime.sim_process
+ runtime.process = None
+ runtime.sim_process = None
+ runtime.sim_started = False
+ runtime.sim_finished = False
+ runtime.sim_returncode = None
+ runtime.auto_loop_active = True
+ runtime.auto_loop_token = token
+ runtime.auto_round = 0
+ runtime.auto_scene_mode = SCENE_MODE_INITIAL
+ runtime.auto_parallel_env = False
+ runtime.auto_robot_profile = DEFAULT_ROBOT_PROFILE
+ runtime.phase_key = "received"
+ runtime.status = "Auto loop starting."
+ runtime.video_path = None
+ runtime.lerobot_video_path = None
+ runtime.lerobot_dataset_path = None
+ runtime.last_error = None
+ runtime.log_lines.clear()
+ clear_run_timing_locked()
+
+ if process is not None:
+ terminate_process_group(process)
+ if sim_process is not None:
+ terminate_process_group(sim_process)
+ return token
+
+
+def auto_loop_is_active(loop_token: str) -> bool:
+ with runtime_lock:
+ return runtime.auto_loop_active and runtime.auto_loop_token == loop_token
+
+
+def finish_auto_loop(loop_token: str, status_text: str | None = None) -> None:
+ with runtime_lock:
+ if runtime.auto_loop_token != loop_token:
+ return
+ runtime.auto_loop_active = False
+ runtime.auto_loop_token = None
+ runtime.auto_round = 0
+ runtime.auto_scene_mode = SCENE_MODE_INITIAL
+ runtime.auto_parallel_env = False
+ runtime.auto_robot_profile = DEFAULT_ROBOT_PROFILE
+ if status_text is not None:
+ runtime.status = status_text
+
+
+def stop_auto_loop_if_running() -> bool:
+ process: subprocess.Popen[str] | None = None
+ sim_process: subprocess.Popen[str] | None = None
+ with runtime_lock:
+ if not runtime.auto_loop_active:
+ return False
+ runtime.run_token = uuid.uuid4().hex
+ runtime.auto_loop_active = False
+ runtime.auto_loop_token = None
+ runtime.auto_round = 0
+ runtime.auto_scene_mode = SCENE_MODE_INITIAL
+ runtime.auto_parallel_env = False
+ runtime.auto_robot_profile = DEFAULT_ROBOT_PROFILE
+ process = runtime.process
+ sim_process = runtime.sim_process
+ runtime.process = None
+ runtime.sim_process = None
+ runtime.sim_started = False
+ runtime.sim_finished = False
+ runtime.sim_returncode = None
+ runtime.is_busy = False
+ runtime.phase_key = "idle"
+ runtime.status = "Stopped."
+ runtime.task_text = ""
+ runtime.input_task_text = ""
+ runtime.input_scene_text = ""
+ runtime.image_path = None
+ runtime.video_path = None
+ runtime.lerobot_video_path = None
+ runtime.lerobot_dataset_path = None
+ runtime.object_model_path = None
+ runtime.scene_model_path = None
+ runtime.edited_scene_model_path = None
+ runtime.last_error = None
+ runtime.log_lines.clear()
+ clear_run_timing_locked()
+
+ if process is not None:
+ terminate_process_group(process)
+ if sim_process is not None:
+ terminate_process_group(sim_process)
+ return True
+
+
+def wait_for_current_simulation_to_exit(
+ loop_token: str,
+ base_image: str,
+ auto_task: str,
+ auto_scene: str,
+):
+ while auto_loop_is_active(loop_token):
+ with runtime_lock:
+ sim_running = runtime.sim_process is not None
+ if not sim_running:
+ break
+ time.sleep(1.0)
+ yield (
+ base_image,
+ auto_task,
+ auto_scene,
+ *ui_snapshot(extra_status="Auto waiting for Dexsim to exit."),
+ )
+
+
+def run_generate_for_top_mode(
+ run_mode: str,
+ action_mode: str | None,
+ scene_mode: str,
+ robot_profile: str | None,
+ image_value: str | np.ndarray | Image.Image,
+ task_text: str,
+ env_text: str,
+ interact_prebuilt_scene_dir: str | None,
+ language: str | None,
+):
+ parallel_env = action_mode == TOP_MODE_PARALLEL_ENV
+ if run_mode != TOP_MODE_AUTO:
+ selected_prebuilt_scene_dir = (
+ Path(interact_prebuilt_scene_dir) if interact_prebuilt_scene_dir else None
+ )
+ for snapshot in run_generate(
+ image_value,
+ task_text,
+ env_text,
+ force_initial=False,
+ scene_mode=scene_mode,
+ parallel_env=parallel_env,
+ robot_profile=robot_profile,
+ load_template_material=False,
+ run_log_mode=RUN_LOG_MODE_INTERACT,
+ prebuilt_scene_dir=selected_prebuilt_scene_dir,
+ ):
+ yield (
+ gr.update(),
+ gr.update(),
+ gr.update(),
+ *snapshot,
+ )
+ return
+
+ loop_token = start_auto_loop_state()
+ if loop_token is None:
+ yield (
+ gr.update(),
+ gr.update(),
+ gr.update(),
+ *ui_snapshot(),
+ )
+ return
+
+ with runtime_lock:
+ runtime.language = language or LANGUAGE_EN
+
+ def set_auto_control_state(
+ scene_mode: str,
+ parallel_env: bool,
+ robot_profile: str | None,
+ ) -> None:
+ with runtime_lock:
+ runtime.auto_scene_mode = scene_mode
+ runtime.auto_parallel_env = parallel_env
+ runtime.auto_robot_profile = robot_profile or DEFAULT_ROBOT_PROFILE
+
+ def run_auto_phase(
+ phase_name: str,
+ base_image: str,
+ task_text: str,
+ scene_text: str,
+ *,
+ scene_mode: str,
+ parallel_env: bool,
+ robot_profile: str | None,
+ force_initial: bool = False,
+ prebuilt_scene_dir: Path | None = None,
+ ):
+ for snapshot in run_generate(
+ base_image,
+ task_text,
+ scene_text,
+ force_initial=force_initial,
+ scene_mode=scene_mode,
+ parallel_env=parallel_env,
+ robot_profile=robot_profile,
+ load_template_material=False,
+ run_log_mode=RUN_LOG_MODE_AUTO,
+ prebuilt_scene_dir=prebuilt_scene_dir,
+ ):
+ yield (
+ base_image,
+ task_text,
+ scene_text,
+ *snapshot,
+ )
+ if not auto_loop_is_active(loop_token):
+ break
+
+ if not auto_loop_is_active(loop_token):
+ archive_run_log(
+ mode=RUN_LOG_MODE_AUTO,
+ task_description=task_text,
+ scene_description=scene_text,
+ outcome="stopped",
+ )
+ return "stopped"
+
+ with runtime_lock:
+ pipeline_failed = runtime.phase_key == "failed"
+ pipeline_error = runtime.last_error
+ if pipeline_failed:
+ cleanup_auto_generated_artifacts()
+ if pipeline_error:
+ with runtime_lock:
+ runtime.last_error = pipeline_error
+ runtime.log_lines.append(
+ f"{phase_name} generation failed: {pipeline_error}"
+ )
+ archive_run_log(
+ mode=RUN_LOG_MODE_AUTO,
+ task_description=task_text,
+ scene_description=scene_text,
+ outcome="pipeline_failed",
+ )
+ return "pipeline_failed"
+
+ for snapshot in wait_for_current_simulation_to_exit(
+ loop_token,
+ base_image,
+ task_text,
+ scene_text,
+ ):
+ yield snapshot
+
+ if not auto_loop_is_active(loop_token):
+ archive_run_log(
+ mode=RUN_LOG_MODE_AUTO,
+ task_description=task_text,
+ scene_description=scene_text,
+ outcome="stopped",
+ )
+ return "stopped"
+
+ with runtime_lock:
+ simulation_completed = (
+ runtime.sim_started
+ and runtime.sim_finished
+ and runtime.sim_process is None
+ )
+ round_outcome = "completed" if simulation_completed else "simulation_failed"
+ archive_run_log(
+ mode=RUN_LOG_MODE_AUTO,
+ task_description=task_text,
+ scene_description=scene_text,
+ outcome=round_outcome,
+ )
+ yield (
+ base_image,
+ task_text,
+ scene_text,
+ *ui_snapshot(extra_status=f"{phase_name}: {round_outcome}."),
+ )
+ return round_outcome
+
+ def run_auto_parallel_simulation(
+ base_image: str,
+ task_text: str,
+ scene_text: str,
+ *,
+ robot_profile: str | None,
+ ):
+ with runtime_lock:
+ simulation_token = runtime.run_token
+ runtime.sim_started = False
+ runtime.sim_finished = False
+ runtime.sim_returncode = None
+ runtime.last_error = None
+ runtime.status = "Starting parallel simulation..."
+ clear_run_timing_locked()
+ runtime.log_lines.append("Auto phase: starting parallel simulation.")
+
+ simulation_error = launch_current_simulation(
+ simulation_token,
+ parallel_env=True,
+ robot_profile=robot_profile,
+ run_log_mode=RUN_LOG_MODE_AUTO,
+ task_description=task_text,
+ scene_description=scene_text,
+ )
+ if simulation_error is not None:
+ with runtime_lock:
+ runtime.phase_key = "failed"
+ runtime.status = (
+ f"Parallel simulation launch failed: {simulation_error}"
+ )
+ runtime.last_error = simulation_error
+ runtime.log_lines.append(runtime.status)
+ archive_run_log(
+ mode=RUN_LOG_MODE_AUTO,
+ task_description=task_text,
+ scene_description=scene_text,
+ outcome="simulation_launch_failed",
+ )
+ yield (
+ base_image,
+ task_text,
+ scene_text,
+ *ui_snapshot(),
+ )
+ return "simulation_failed"
+
+ yield (
+ base_image,
+ task_text,
+ scene_text,
+ *ui_snapshot(extra_status="Parallel simulation started."),
+ )
+ for snapshot in wait_for_current_simulation_to_exit(
+ loop_token,
+ base_image,
+ task_text,
+ scene_text,
+ ):
+ yield snapshot
+
+ if not auto_loop_is_active(loop_token):
+ archive_run_log(
+ mode=RUN_LOG_MODE_AUTO,
+ task_description=task_text,
+ scene_description=scene_text,
+ outcome="stopped",
+ )
+ return "stopped"
+
+ with runtime_lock:
+ simulation_completed = (
+ runtime.sim_started
+ and runtime.sim_finished
+ and runtime.sim_process is None
+ )
+ round_outcome = "completed" if simulation_completed else "simulation_failed"
+ archive_run_log(
+ mode=RUN_LOG_MODE_AUTO,
+ task_description=task_text,
+ scene_description=scene_text,
+ outcome=round_outcome,
+ )
+ yield (
+ base_image,
+ task_text,
+ scene_text,
+ *ui_snapshot(extra_status=f"Parallel simulation: {round_outcome}."),
+ )
+ return round_outcome
+
+ while auto_loop_is_active(loop_token):
+ auto_task = ""
+ auto_scene = ""
+ task_label = "unknown"
+ with runtime_lock:
+ runtime.auto_round += 1
+ auto_round = runtime.auto_round
+ runtime.status = f"Auto round {auto_round}: cleaning previous artifacts."
+ runtime.last_error = None
+ runtime.sim_started = False
+ runtime.sim_finished = False
+ runtime.sim_returncode = None
+ runtime.log_lines.clear()
+ clear_run_timing_locked()
+ runtime.log_lines.append(f"Auto round {auto_round} started.")
+
+ cleanup_errors = cleanup_auto_generated_artifacts()
+ if cleanup_errors:
+ with runtime_lock:
+ runtime.log_lines.extend(cleanup_errors)
+
+ if not auto_loop_is_active(loop_token):
+ break
+
+ try:
+ with runtime_lock:
+ selected_language = runtime.language
+ auto_input = generate_auto_text_input(
+ language=selected_language,
+ include_scene=False,
+ )
+ except Exception as exc:
+ if not auto_loop_is_active(loop_token):
+ break
+ with runtime_lock:
+ runtime.phase_key = "failed"
+ runtime.status = f"Auto text generation failed: {exc}"
+ runtime.last_error = str(exc)
+ clear_run_timing_locked()
+ runtime.log_lines.append(runtime.status)
+ yield (
+ gr.update(),
+ gr.update(),
+ gr.update(),
+ *ui_snapshot(),
+ )
+ archive_run_log(
+ mode=RUN_LOG_MODE_AUTO,
+ task_description=auto_task,
+ scene_description=auto_scene,
+ outcome="text_generation_failed",
+ )
+ continue
+
+ base_image = auto_input.base_image_path.as_posix()
+ auto_task = auto_input.task_description
+ task_label = f"task{auto_input.task_index[0]}_{auto_input.task_index[1]}"
+ with runtime_lock:
+ runtime.task_text = format_current_task(auto_task, auto_scene)
+ runtime.input_task_text = auto_task
+ runtime.input_scene_text = auto_scene
+ runtime.image_path = auto_input.base_image_path
+ runtime.video_path = None
+ runtime.lerobot_video_path = None
+ runtime.lerobot_dataset_path = None
+ runtime.phase_key = "received"
+ runtime.status = (
+ f"Auto round {auto_round}: selected {task_label}. "
+ "Starting prompt2scene pipeline."
+ )
+ runtime.last_error = None
+ runtime.log_lines.append(
+ f"Auto selected {task_label}: task={auto_task!r}, scene={auto_scene!r}"
+ )
+ if auto_input.prebuilt_scene_dir is not None:
+ runtime.log_lines.append(
+ f"Auto prebuilt scene: {auto_input.prebuilt_scene_dir}"
+ )
+ yield (
+ base_image,
+ auto_task,
+ auto_scene,
+ *ui_snapshot(extra_status=f"Auto text generated: {task_label}."),
+ )
+
+ if not auto_loop_is_active(loop_token):
+ archive_run_log(
+ mode=RUN_LOG_MODE_AUTO,
+ task_description=auto_task,
+ scene_description=auto_scene,
+ outcome="stopped",
+ )
+ break
+
+ with runtime_lock:
+ selected_language = runtime.language
+ phase_results: list[str] = []
+
+ phase_results.append("stopped")
+ set_auto_control_state(SCENE_MODE_INITIAL, False, robot_profile)
+ phase_generator = run_auto_phase(
+ "Initial generation",
+ base_image,
+ auto_task,
+ auto_scene,
+ scene_mode=SCENE_MODE_INITIAL,
+ parallel_env=False,
+ robot_profile=robot_profile,
+ force_initial=True,
+ prebuilt_scene_dir=auto_input.prebuilt_scene_dir,
+ )
+ try:
+ while True:
+ yield next(phase_generator)
+ except StopIteration as exc:
+ phase_results[0] = str(exc.value)
+
+ if phase_results[0] == "stopped":
+ break
+ if phase_results[0] == "pipeline_failed":
+ continue
+
+ try:
+ auto_edit_scene = generate_auto_scene_description(
+ task_index=auto_input.task_index,
+ language=selected_language,
+ ensure_scene=True,
+ )
+ except Exception as exc:
+ with runtime_lock:
+ runtime.phase_key = "failed"
+ runtime.status = f"Auto scene description generation failed: {exc}"
+ runtime.last_error = str(exc)
+ clear_run_timing_locked()
+ runtime.log_lines.append(runtime.status)
+ yield (
+ gr.update(),
+ gr.update(),
+ gr.update(),
+ *ui_snapshot(),
+ )
+ archive_run_log(
+ mode=RUN_LOG_MODE_AUTO,
+ task_description=auto_task,
+ scene_description=auto_scene,
+ outcome="text_generation_failed",
+ )
+ continue
+
+ phase_results.append("stopped")
+ set_auto_control_state(SCENE_MODE_EDIT, False, robot_profile)
+ phase_generator = run_auto_phase(
+ "Scene edit",
+ base_image,
+ auto_task,
+ auto_edit_scene,
+ scene_mode=SCENE_MODE_EDIT,
+ parallel_env=False,
+ robot_profile=robot_profile,
+ force_initial=False,
+ )
+ try:
+ while True:
+ yield next(phase_generator)
+ except StopIteration as exc:
+ phase_results[1] = str(exc.value)
+
+ if phase_results[1] == "stopped":
+ break
+ if phase_results[1] == "pipeline_failed":
+ continue
+
+ phase_results.append("stopped")
+ set_auto_control_state(SCENE_MODE_EDIT, True, robot_profile)
+ phase_generator = run_auto_parallel_simulation(
+ base_image,
+ auto_task,
+ auto_edit_scene,
+ robot_profile=robot_profile,
+ )
+ try:
+ while True:
+ yield next(phase_generator)
+ except StopIteration as exc:
+ phase_results[2] = str(exc.value)
+
+ if phase_results[2] == "stopped":
+ break
+
+ phase_results.append("stopped")
+ set_auto_control_state(SCENE_MODE_TASK_ONLY, False, ROBOT_PROFILE_FRANKA)
+ phase_generator = run_auto_phase(
+ "Task-only Franka",
+ base_image,
+ auto_task,
+ "",
+ scene_mode=SCENE_MODE_TASK_ONLY,
+ parallel_env=False,
+ robot_profile=ROBOT_PROFILE_FRANKA,
+ force_initial=False,
+ )
+ try:
+ while True:
+ yield next(phase_generator)
+ except StopIteration as exc:
+ phase_results[3] = str(exc.value)
+
+ if phase_results[3] == "stopped":
+ break
+ if phase_results[3] == "pipeline_failed":
+ continue
+
+ phase_results.append("stopped")
+ set_auto_control_state(SCENE_MODE_TASK_ONLY, True, ROBOT_PROFILE_FRANKA)
+ phase_generator = run_auto_parallel_simulation(
+ base_image,
+ auto_task,
+ "",
+ robot_profile=ROBOT_PROFILE_FRANKA,
+ )
+ try:
+ while True:
+ yield next(phase_generator)
+ except StopIteration as exc:
+ phase_results[4] = str(exc.value)
+
+ if phase_results[4] == "stopped":
+ break
+
+ cleanup_errors = cleanup_auto_generated_artifacts()
+ if cleanup_errors:
+ with runtime_lock:
+ runtime.log_lines.extend(cleanup_errors)
+
+ finish_auto_loop(loop_token)
+
+
+def _scene_engine_phase_from_log(line: str, current_key: str) -> str:
+ """Map the standalone Scene Engine's stage names to the shared progress UI."""
+ text = line.lower()
+ mapping = (
+ ("scene understanding", "scene_intake"),
+ ("scene segmentation", "relations"),
+ ("coarse layout", "asset_generation"),
+ ("scene export", "gym_export"),
+ )
+ current_progress = PHASES.get(current_key, PHASES["idle"]).progress
+ for needle, phase_key in mapping:
+ if needle in text and PHASES[phase_key].progress > current_progress:
+ return phase_key
+ return current_key
+
+
+def _scene_engine_updates(
+ output_root: Path | None = None,
+ preview_html: str | None = None,
+) -> tuple[int, str, str | None, str]:
+ with runtime_lock:
+ phase = PHASES.get(runtime.phase_key, PHASES["idle"])
+ status = format_status(
+ runtime.status,
+ phase=phase,
+ busy=runtime.is_busy,
+ last_error=runtime.last_error,
+ )
+ return (
+ phase.progress,
+ status,
+ output_root.as_posix() if output_root is not None else None,
+ preview_html or "",
+ )
+
+
+def _prepare_scene_engine_input(
+ image_value: str | np.ndarray | Image.Image,
+) -> tuple[str, Path, Path]:
+ """Normalize an uploaded image and store it under a stable content hash."""
+ if image_value is None:
+ raise ValueError("Please upload an image first.")
+ if isinstance(image_value, str):
+ image = Image.open(image_value)
+ elif isinstance(image_value, np.ndarray):
+ image = Image.fromarray(image_value)
+ elif isinstance(image_value, Image.Image):
+ image = image_value
+ else:
+ raise TypeError(f"Unsupported image input type: {type(image_value)!r}")
+
+ normalized = ImageOps.exif_transpose(image).convert("RGB")
+ image_bytes = io.BytesIO()
+ normalized.save(image_bytes, format="PNG")
+ scene_hash = hashlib.sha256(image_bytes.getvalue()).hexdigest()[:16]
+ output_root = DEBUG_SCENE_ENGINE_ROOT / scene_hash
+ output_root.mkdir(parents=True, exist_ok=True)
+ image_path = output_root / "input.png"
+ image_path.write_bytes(image_bytes.getvalue())
+ return scene_hash, output_root, image_path
+
+
+def _wait_for_viser(port: int, process: subprocess.Popen[str]) -> bool:
+ """Wait briefly for Viser's HTTP listener, without treating Ctrl-C as success."""
+ deadline = time.monotonic() + 15.0
+ while time.monotonic() < deadline:
+ if process.poll() is not None:
+ return False
+ try:
+ with socket.create_connection(("127.0.0.1", port), timeout=0.2):
+ return True
+ except OSError:
+ time.sleep(0.25)
+ return False
+
+
+def _viser_iframe(port: int, scene_hash: str) -> str:
+ """Embed the Viser service using the same hostname as the Gradio page."""
+ srcdoc = (
+ ""
+ )
+ return (
+ f"Viser preview: {html.escape(scene_hash)}"
+ f""
+ "
"
+ )
+
+
+def reset_scene_engine():
+ """Clear Scene Engine widgets and stop its generator and Viser process groups."""
+ with runtime_lock:
+ generator_process = runtime.scene_engine_process
+ preview_process = runtime.scene_preview_process
+ owns_runtime = runtime.scene_engine_is_running
+ other_workflow_running = not owns_runtime and runtime.is_busy
+ if owns_runtime or not runtime.is_busy:
+ if owns_runtime:
+ runtime.run_token = uuid.uuid4().hex
+ if runtime.process is generator_process:
+ runtime.process = None
+ runtime.is_busy = False
+ set_runtime_phase_locked("idle")
+ runtime.status = "Scene Engine reset."
+ runtime.image_path = None
+ runtime.last_error = None
+ runtime.log_lines.clear()
+ clear_run_timing_locked()
+ runtime.scene_engine_process = None
+ runtime.scene_preview_process = None
+ runtime.scene_engine_is_running = False
+
+ for process in {generator_process, preview_process}:
+ if process is not None:
+ terminate_process_group(process)
+
+ return (
+ None,
+ PHASES["idle"].progress,
+ format_status(
+ "Scene Engine reset."
+ if not other_workflow_running
+ else "Scene Engine preview reset; another workflow is still running."
+ ),
+ "",
+ ""
+ "The Viser preview will appear here after generation."
+ "
",
+ )
+
+
+def run_scene_engine(image_value: str | np.ndarray | Image.Image):
+ """Generate one image-conditioned scene and expose its Viser preview."""
+ output_root: Path | None = None
+ preview_html = ""
+ token = uuid.uuid4().hex
+ with runtime_lock:
+ if runtime.is_busy:
+ runtime.status = "Another pipeline is already running."
+ runtime.last_error = runtime.status
+ busy_message = runtime.status
+ else:
+ runtime.run_token = token
+ runtime.is_busy = True
+ runtime.scene_engine_is_running = True
+ set_runtime_phase_locked("received")
+ runtime.status = "Preparing Scene Engine input."
+ runtime.last_error = None
+ runtime.log_lines.clear()
+ clear_run_timing_locked()
+ busy_message = None
+
+ if busy_message is not None:
+ yield _scene_engine_updates(output_root, preview_html)
+ return
+
+ try:
+ scene_hash, output_root, image_path = _prepare_scene_engine_input(image_value)
+ except Exception as exc:
+ with runtime_lock:
+ if runtime.run_token != token:
+ return
+ runtime.is_busy = False
+ runtime.scene_engine_is_running = False
+ set_runtime_phase_locked("failed")
+ runtime.status = f"Input error: {exc}"
+ runtime.last_error = str(exc)
+ yield _scene_engine_updates(output_root, preview_html)
+ return
+
+ old_preview: subprocess.Popen[str] | None = None
+ old_generator: subprocess.Popen[str] | None = None
+ with runtime_lock:
+ if runtime.run_token != token:
+ return
+ old_preview = runtime.scene_preview_process
+ old_generator = runtime.scene_engine_process
+ runtime.scene_engine_process = None
+ runtime.scene_preview_process = None
+ runtime.status = f"Image saved. Generating Scene Engine output {scene_hash}."
+ runtime.image_path = image_path
+
+ if old_preview is not None:
+ terminate_process_group(old_preview)
+ if old_generator is not None:
+ terminate_process_group(old_generator)
+
+ command = [
+ sys.executable,
+ "-m",
+ COMMANDS["scene_engine"]["module"],
+ *COMMANDS["scene_engine"]["base_args"],
+ "--image",
+ str(image_path),
+ "--output_root",
+ str(output_root),
+ ]
+ scene_engine_log = output_root / "scene_engine.log"
+ scene_engine_log.write_text(
+ "$ " + " ".join(command) + "\n",
+ encoding="utf-8",
+ )
+ with runtime_lock:
+ runtime.log_lines.append("$ " + " ".join(command))
+ yield _scene_engine_updates(output_root, preview_html)
+
+ try:
+ process = start_pipeline(command)
+ except Exception as exc:
+ with runtime_lock:
+ runtime.is_busy = False
+ runtime.scene_engine_is_running = False
+ set_runtime_phase_locked("failed")
+ runtime.status = f"Scene Engine start failed: {exc}"
+ runtime.last_error = str(exc)
+ yield _scene_engine_updates(output_root, preview_html)
+ return
+
+ output_queue: queue.Queue[str] = queue.Queue()
+ reader = threading.Thread(
+ target=read_process_output,
+ args=(process, output_queue, scene_engine_log),
+ daemon=True,
+ )
+ with runtime_lock:
+ if runtime.run_token != token:
+ terminate_process_group(process)
+ return
+ runtime.process = process
+ runtime.scene_engine_process = process
+ start_run_timing_locked("started")
+ set_runtime_phase_locked("started")
+ runtime.status = "Scene Engine generation started."
+ reader.start()
+
+ while process.poll() is None:
+ drained = drain_output_queue(output_queue)
+ with runtime_lock:
+ if (
+ runtime.run_token != token
+ or runtime.scene_engine_process is not process
+ ):
+ return
+ for line in drained:
+ runtime.log_lines.append(line)
+ set_runtime_phase_locked(
+ _scene_engine_phase_from_log(line, runtime.phase_key)
+ )
+ if (output_root / "scene_export" / "scene_config.json").is_file():
+ set_runtime_phase_locked("gym_export")
+ runtime.status = PHASES[runtime.phase_key].label + "."
+ yield _scene_engine_updates(output_root, preview_html)
+ time.sleep(0.5)
+
+ reader.join(timeout=1.0)
+ with runtime_lock:
+ if runtime.run_token != token or runtime.scene_engine_process is not process:
+ return
+ for line in drain_output_queue(output_queue):
+ runtime.log_lines.append(line)
+ set_runtime_phase_locked(
+ _scene_engine_phase_from_log(line, runtime.phase_key)
+ )
+ runtime.process = None
+ runtime.scene_engine_process = None
+
+ scene_export = output_root / "scene_export" / "scene_config.json"
+ if process.returncode != 0 or not scene_export.is_file():
+ detail = (
+ f"Scene Engine exited with code {process.returncode}."
+ if process.returncode != 0
+ else f"Scene Engine did not create {scene_export}."
+ )
+ with runtime_lock:
+ if runtime.run_token != token:
+ return
+ runtime.is_busy = False
+ runtime.scene_engine_is_running = False
+ set_runtime_phase_locked("failed")
+ runtime.status = detail
+ runtime.last_error = detail
+ yield _scene_engine_updates(output_root, preview_html)
+ return
+
+ port = SCENE_ENGINE_VISER_PORT
+ preview_command = [
+ sys.executable,
+ COMMANDS["scene_engine"]["preview_script"],
+ "--output_root",
+ str(output_root),
+ "--viser",
+ "--viser-host",
+ "0.0.0.0",
+ "--viser-port",
+ str(port),
+ ]
+ try:
+ preview_process = start_pipeline(preview_command)
+ except Exception as exc:
+ with runtime_lock:
+ if runtime.run_token != token:
+ return
+ runtime.is_busy = False
+ runtime.scene_engine_is_running = False
+ set_runtime_phase_locked("failed")
+ runtime.status = f"Viser preview start failed: {exc}"
+ runtime.last_error = str(exc)
+ yield _scene_engine_updates(output_root, preview_html)
+ return
+
+ with runtime_lock:
+ if runtime.run_token != token:
+ terminate_process_group(preview_process)
+ return
+ runtime.scene_preview_process = preview_process
+ runtime.log_lines.append("$ " + " ".join(preview_command))
+ set_runtime_phase_locked("preview")
+ runtime.status = "Starting Viser preview..."
+ yield _scene_engine_updates(output_root, preview_html)
+
+ if not _wait_for_viser(port, preview_process):
+ terminate_process_group(preview_process)
+ with runtime_lock:
+ if runtime.run_token != token:
+ return
+ runtime.scene_preview_process = None
+ runtime.is_busy = False
+ runtime.scene_engine_is_running = False
+ set_runtime_phase_locked("failed")
+ runtime.status = "Viser preview did not start."
+ runtime.last_error = runtime.status
+ yield _scene_engine_updates(output_root, preview_html)
+ return
+
+ preview_html = _viser_iframe(port, scene_hash)
+ with runtime_lock:
+ if (
+ runtime.run_token != token
+ or runtime.scene_preview_process is not preview_process
+ ):
+ terminate_process_group(preview_process)
+ return
+ runtime.scene_preview_process = preview_process
+ runtime.is_busy = False
+ runtime.scene_engine_is_running = False
+ set_runtime_phase_locked("complete")
+ runtime.status = "Scene generated successfully. Viser preview is ready."
+ runtime.last_error = None
+ yield _scene_engine_updates(output_root, preview_html)
+
+
+def run_action_engine_from_current(task_text: str, robot_profile: str | None):
+ """Launch DexSim for the Gym scene most recently generated by Scene engine."""
+ task_text = (task_text or "").strip()
+ failure: str | None = None
+ with runtime_lock:
+ if not task_text:
+ runtime.status = "Enter a task description first."
+ runtime.last_error = "Task description is required."
+ failure = runtime.status
+ elif not rerun_simulation_is_available():
+ runtime.status = "Generate a scene first."
+ runtime.last_error = "Current Gym scene/config is unavailable."
+ failure = runtime.status
+ elif (
+ runtime.process is not None
+ or runtime.sim_process is not None
+ or runtime.is_busy
+ ):
+ runtime.status = "Another pipeline or simulation is already running."
+ runtime.last_error = "Busy."
+ failure = runtime.status
+ elif not action_agent_cli_is_available():
+ runtime.status = (
+ "Action-agent CLI is unavailable in this EmbodiChain environment."
+ )
+ runtime.last_error = (
+ "Missing embodichain.gen_sim.action_agent_pipeline.cli.run_agent"
+ )
+ failure = runtime.status
+ else:
+ token = uuid.uuid4().hex
+ runtime.run_token = token
+ runtime.task_text = task_text
+ runtime.input_task_text = task_text
+ runtime.input_scene_text = ""
+ runtime.status = "Starting DexSim action simulation..."
+ runtime.last_error = None
+ runtime.log_lines.append(runtime.status)
+
+ if failure:
+ return ui_snapshot()
+
+ error = launch_current_simulation(
+ token,
+ robot_profile=robot_profile,
+ run_log_mode=RUN_LOG_MODE_INTERACT,
+ task_description=task_text,
+ )
+ if error:
+ with runtime_lock:
+ runtime.status = error
+ runtime.last_error = error
+ return ui_snapshot()
+
+
+def action_agent_cli_is_available() -> bool:
+ """Avoid spawning a subprocess when the optional action-agent package is absent."""
+ try:
+ return importlib.util.find_spec(COMMANDS["agent"]["module"]) is not None
+ except (ImportError, ModuleNotFoundError):
+ return False
+
+
+def supervise_pipeline(
+ token: str,
+ stage: ScenePaths,
+ mode: str,
+ process: subprocess.Popen[str],
+ display_task_text: str,
+ task_description: str,
+ scene_description: str,
+ output_queue: queue.Queue[str],
+ reader: threading.Thread,
+ parallel_env: bool,
+ robot_profile: str | None,
+ run_log_mode: str,
+ initial_scene_path: Path | None,
+ show_generated_scene_as_edit: bool,
+ launch_simulation: bool = True,
+) -> None:
+ is_edit = mode == PIPELINE_MODE_EDIT
+ is_task_only = mode == PIPELINE_MODE_TASK_ONLY
+ scene_build_error: str | None = None
+ simulation_error: str | None = None
+ simulation_started = False
+ try:
+ while True:
+ with runtime_lock:
+ still_current = runtime.run_token == token
+ if not still_current:
+ terminate_process_group(process)
+ return
+
+ drained = drain_output_queue(output_queue)
+ if drained:
+ with runtime_lock:
+ for line in drained:
+ runtime.log_lines.append(line)
+ set_runtime_phase_locked(
+ update_phase_from_log(line, runtime.phase_key)
+ )
+
+ with runtime_lock:
+ detected_key = detect_phase_from_files(runtime.phase_key, stage)
+ set_runtime_phase_locked(detected_key)
+ if detected_key in PHASES and runtime.phase_key != "failed":
+ runtime.status = PHASES[detected_key].label + "."
+
+ glb_paths = collect_generated_object_glbs(stage)
+ if glb_paths and (
+ not stage.gradio_object_preview_glb.is_file()
+ or not object_preview_is_current(
+ stage.object_preview_manifest,
+ glb_paths,
+ )
+ ):
+ try:
+ object_preview_path = build_object_preview_scene(
+ glb_paths,
+ stage.gradio_scene_dir,
+ )
+ with runtime_lock:
+ runtime.object_model_path = object_preview_path
+ set_runtime_phase_locked(
+ _choose_later_phase(
+ runtime.phase_key,
+ PHASES.get(runtime.phase_key, PHASES["idle"]).progress,
+ "asset_generation",
+ )[0]
+ )
+ runtime.status = (
+ f"Generated object GLB preview loaded "
+ f"({len(glb_paths)} files)."
+ )
+ except Exception as exc:
+ with runtime_lock:
+ runtime.log_lines.append(f"Object preview pending: {exc}")
+
+ if (
+ not is_edit
+ and not is_task_only
+ and stage.fast_gym_config.is_file()
+ and not gradio_scene_is_current(
+ stage.gradio_scene_glb,
+ stage.scene_manifest,
+ stage.fast_gym_config,
+ )
+ ):
+ try:
+ scene_path = build_gradio_scene_from_fast_config(
+ stage.fast_gym_config,
+ stage.gradio_scene_dir,
+ )
+ scene_build_error = None
+ with runtime_lock:
+ if show_generated_scene_as_edit:
+ if initial_scene_path is not None:
+ runtime.scene_model_path = initial_scene_path
+ runtime.edited_scene_model_path = scene_path
+ else:
+ runtime.scene_model_path = scene_path
+ set_runtime_phase_locked("preview")
+ runtime.status = "3D preview loaded."
+ runtime.last_error = None
+ except Exception as exc:
+ scene_build_error = str(exc)
+ with runtime_lock:
+ runtime.log_lines.append(
+ f"3D preview error: {scene_build_error}"
+ )
+ runtime.last_error = scene_build_error
+
+ if process.poll() is not None:
+ break
+ time.sleep(0.5)
+
+ reader.join(timeout=1.0)
+ drained = drain_output_queue(output_queue)
+ with runtime_lock:
+ for line in drained:
+ runtime.log_lines.append(line)
+ set_runtime_phase_locked(update_phase_from_log(line, runtime.phase_key))
+
+ glb_paths = collect_generated_object_glbs(stage)
+ if glb_paths and (
+ not stage.gradio_object_preview_glb.is_file()
+ or not object_preview_is_current(stage.object_preview_manifest, glb_paths)
+ ):
+ try:
+ object_preview_path = build_object_preview_scene(
+ glb_paths,
+ stage.gradio_scene_dir,
+ )
+ with runtime_lock:
+ runtime.object_model_path = object_preview_path
+ except Exception as exc:
+ with runtime_lock:
+ runtime.log_lines.append(f"Object preview skipped: {exc}")
+
+ if is_task_only and process.returncode == 0 and stage.fast_gym_config.is_file():
+ try:
+ scene_path = build_gradio_scene_from_fast_config(
+ stage.fast_gym_config,
+ stage.gradio_scene_dir,
+ )
+ scene_build_error = None
+ if GRADIO_SCENE_GLB.is_file():
+ GRADIO_SCENE_DIR.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(GRADIO_SCENE_GLB, GRADIO_INITIAL_SCENE_GLB)
+ with runtime_lock:
+ runtime.scene_model_path = scene_path
+ runtime.edited_scene_model_path = None
+ set_runtime_phase_locked("preview")
+ runtime.status = "3D preview loaded."
+ runtime.last_error = None
+ except Exception as exc:
+ scene_build_error = str(exc)
+ elif (
+ stage.fast_gym_config.is_file()
+ and (not is_edit or process.returncode == 0)
+ and not gradio_scene_is_current(
+ stage.gradio_scene_glb,
+ stage.scene_manifest,
+ stage.fast_gym_config,
+ )
+ ):
+ try:
+ scene_path = build_gradio_scene_from_fast_config(
+ stage.fast_gym_config,
+ stage.gradio_scene_dir,
+ )
+ scene_build_error = None
+ with runtime_lock:
+ if is_edit or show_generated_scene_as_edit:
+ runtime.edited_scene_model_path = scene_path
+ else:
+ runtime.scene_model_path = scene_path
+ set_runtime_phase_locked("preview")
+ runtime.status = "3D preview loaded."
+ runtime.last_error = None
+ except Exception as exc:
+ scene_build_error = str(exc)
+
+ cleanup_errors: list[str] = []
+ promotion_error: str | None = None
+ pipeline_output_ready = (
+ stage.fast_gym_config.is_file() and stage.agent_config.is_file()
+ if is_task_only
+ else stage.fast_gym_config.is_file()
+ )
+ missing_output_name = (
+ f"{stage.fast_gym_config.name} and/or {stage.agent_config.name}"
+ if is_task_only
+ else FAST_GYM_CONFIG.name
+ )
+ pipeline_succeeded = (
+ process.returncode == 0 and pipeline_output_ready and not scene_build_error
+ )
+ if pipeline_succeeded:
+ if is_edit:
+ with runtime_lock:
+ if runtime.run_token == token:
+ runtime.image_path = (
+ IMAGE_PATH if IMAGE_PATH.is_file() else None
+ )
+ if GRADIO_OBJECT_PREVIEW_GLB.is_file():
+ runtime.object_model_path = GRADIO_OBJECT_PREVIEW_GLB
+ if GRADIO_INITIAL_SCENE_GLB.is_file():
+ runtime.scene_model_path = GRADIO_INITIAL_SCENE_GLB
+ if GRADIO_SCENE_GLB.is_file():
+ runtime.edited_scene_model_path = GRADIO_SCENE_GLB
+ if launch_simulation:
+ simulation_error = launch_current_simulation(
+ token,
+ parallel_env=parallel_env,
+ robot_profile=robot_profile,
+ run_log_mode=run_log_mode,
+ task_description=task_description,
+ scene_description=scene_description,
+ )
+ simulation_started = simulation_error is None
+ elif is_task_only:
+ with runtime_lock:
+ if runtime.run_token == token:
+ runtime.image_path = (
+ IMAGE_PATH if IMAGE_PATH.is_file() else None
+ )
+ if GRADIO_OBJECT_PREVIEW_GLB.is_file():
+ runtime.object_model_path = GRADIO_OBJECT_PREVIEW_GLB
+ if GRADIO_INITIAL_SCENE_GLB.is_file():
+ runtime.scene_model_path = GRADIO_INITIAL_SCENE_GLB
+ elif GRADIO_SCENE_GLB.is_file():
+ runtime.scene_model_path = GRADIO_SCENE_GLB
+ runtime.edited_scene_model_path = None
+ if launch_simulation:
+ simulation_error = launch_current_simulation(
+ token,
+ parallel_env=parallel_env,
+ robot_profile=robot_profile,
+ run_log_mode=run_log_mode,
+ task_description=task_description,
+ scene_description=scene_description,
+ )
+ simulation_started = simulation_error is None
+ else:
+ try:
+ cleanup_errors = promote_stage_to_current(stage, token)
+ except Exception as exc:
+ promotion_error = str(exc)
+ else:
+ initial_scene_error: str | None = None
+ try:
+ if show_generated_scene_as_edit and initial_scene_path:
+ GRADIO_SCENE_DIR.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(initial_scene_path, GRADIO_INITIAL_SCENE_GLB)
+ else:
+ ensure_initial_scene_snapshot(overwrite=True)
+ except Exception as exc:
+ initial_scene_error = str(exc)
+ with runtime_lock:
+ if runtime.run_token == token:
+ runtime.image_path = IMAGE_PATH
+ if GRADIO_OBJECT_PREVIEW_GLB.is_file():
+ runtime.object_model_path = GRADIO_OBJECT_PREVIEW_GLB
+ if show_generated_scene_as_edit:
+ if GRADIO_INITIAL_SCENE_GLB.is_file():
+ runtime.scene_model_path = GRADIO_INITIAL_SCENE_GLB
+ elif initial_scene_path is not None:
+ runtime.scene_model_path = initial_scene_path
+ if GRADIO_SCENE_GLB.is_file():
+ runtime.edited_scene_model_path = GRADIO_SCENE_GLB
+ elif GRADIO_INITIAL_SCENE_GLB.is_file():
+ runtime.scene_model_path = GRADIO_INITIAL_SCENE_GLB
+ elif GRADIO_SCENE_GLB.is_file():
+ runtime.scene_model_path = GRADIO_SCENE_GLB
+ if not show_generated_scene_as_edit:
+ runtime.edited_scene_model_path = None
+ if initial_scene_error:
+ runtime.log_lines.append(
+ f"Initial scene snapshot skipped: {initial_scene_error}"
+ )
+ if launch_simulation:
+ simulation_error = launch_current_simulation(
+ token,
+ parallel_env=parallel_env,
+ robot_profile=robot_profile,
+ run_log_mode=run_log_mode,
+ task_description=task_description,
+ scene_description=scene_description,
+ )
+ simulation_started = simulation_error is None
+
+ archive_after_status = False
+ archive_outcome = "completed"
+ with runtime_lock:
+ if runtime.run_token != token:
+ return
+ runtime.is_busy = False
+ runtime.process = None
+ if pipeline_succeeded and not promotion_error:
+ set_runtime_phase_locked("complete")
+ runtime.status = "Pipeline completed successfully."
+ runtime.task_text = display_task_text
+ runtime.image_path = IMAGE_PATH if IMAGE_PATH.is_file() else None
+ if GRADIO_OBJECT_PREVIEW_GLB.is_file():
+ runtime.object_model_path = GRADIO_OBJECT_PREVIEW_GLB
+ if is_edit or show_generated_scene_as_edit:
+ if GRADIO_INITIAL_SCENE_GLB.is_file():
+ runtime.scene_model_path = GRADIO_INITIAL_SCENE_GLB
+ elif initial_scene_path is not None:
+ runtime.scene_model_path = initial_scene_path
+ if GRADIO_SCENE_GLB.is_file():
+ runtime.edited_scene_model_path = GRADIO_SCENE_GLB
+ elif GRADIO_INITIAL_SCENE_GLB.is_file():
+ runtime.scene_model_path = GRADIO_INITIAL_SCENE_GLB
+ runtime.edited_scene_model_path = None
+ elif GRADIO_SCENE_GLB.is_file():
+ runtime.scene_model_path = GRADIO_SCENE_GLB
+ runtime.edited_scene_model_path = None
+ if simulation_started:
+ runtime.status += "\nDexsim simulation launched."
+ if cleanup_errors:
+ runtime.status += "\nCleanup completed with errors; see Last error."
+ runtime.last_error = "\n".join(cleanup_errors)
+ if simulation_error:
+ runtime.status += (
+ "\nDexsim launch failed; Gradio preview is still available."
+ )
+ runtime.last_error = simulation_error
+ elif process.returncode == 0 and not pipeline_output_ready:
+ set_runtime_phase_locked("failed")
+ runtime.status = f"Pipeline ended without {missing_output_name}."
+ runtime.last_error = runtime.status
+ archive_outcome = "pipeline_output_missing"
+ elif scene_build_error:
+ set_runtime_phase_locked("failed")
+ runtime.status = f"3D preview failed: {scene_build_error}"
+ runtime.last_error = scene_build_error
+ archive_outcome = "preview_failed"
+ elif promotion_error:
+ set_runtime_phase_locked("failed")
+ runtime.status = f"Scene promotion failed: {promotion_error}"
+ runtime.last_error = promotion_error
+ archive_outcome = "promotion_failed"
+ else:
+ set_runtime_phase_locked("failed")
+ runtime.status = (
+ f"Pipeline failed with return code {process.returncode}."
+ )
+ runtime.last_error = runtime.status
+ archive_outcome = "pipeline_failed"
+ if pipeline_succeeded and not promotion_error:
+ archive_outcome = (
+ "dexsim_launch_failed" if simulation_error else "completed"
+ )
+ archive_after_status = (
+ run_log_mode == RUN_LOG_MODE_INTERACT and not simulation_started
+ )
+ if archive_after_status:
+ archive_run_log(
+ mode=RUN_LOG_MODE_INTERACT,
+ task_description=task_description or display_task_text,
+ scene_description=scene_description,
+ outcome=archive_outcome,
+ )
+ except Exception as exc:
+ should_archive_exception = False
+ with runtime_lock:
+ if runtime.run_token == token:
+ runtime.is_busy = False
+ runtime.process = None
+ set_runtime_phase_locked("failed")
+ runtime.status = f"Pipeline supervision failed: {exc}"
+ runtime.last_error = str(exc)
+ runtime.log_lines.append(runtime.status)
+ should_archive_exception = run_log_mode == RUN_LOG_MODE_INTERACT
+ if should_archive_exception:
+ archive_run_log(
+ mode=RUN_LOG_MODE_INTERACT,
+ task_description=task_description or display_task_text,
+ scene_description=scene_description,
+ outcome="pipeline_supervision_failed",
+ )
+
+
+def launch_current_simulation(
+ token: str,
+ *,
+ parallel_env: bool = False,
+ robot_profile: str | None = None,
+ run_log_mode: str = RUN_LOG_MODE_INTERACT,
+ task_description: str = "",
+ scene_description: str = "",
+) -> str | None:
+ if not CURRENT_PATHS.fast_gym_config.is_file():
+ return f"Dexsim launch skipped; missing {CURRENT_PATHS.fast_gym_config}"
+ if not CURRENT_PATHS.agent_config.is_file():
+ return f"Dexsim launch skipped; missing {CURRENT_PATHS.agent_config}"
+
+ command = build_run_agent_command(
+ CURRENT_PATHS,
+ parallel_env=parallel_env,
+ robot_profile=robot_profile,
+ )
+ started_at_ns = time.time_ns()
+ try:
+ process = start_pipeline(command)
+ except Exception as exc:
+ return f"Dexsim launch failed: {exc}"
+
+ output_queue: queue.Queue[str] = queue.Queue()
+ reader = threading.Thread(
+ target=read_process_output,
+ args=(process, output_queue),
+ daemon=True,
+ )
+ monitor = threading.Thread(
+ target=monitor_simulation,
+ args=(
+ token,
+ process,
+ output_queue,
+ reader,
+ started_at_ns,
+ run_log_mode,
+ task_description,
+ scene_description,
+ parallel_env,
+ ),
+ daemon=True,
+ )
+
+ with runtime_lock:
+ if runtime.run_token != token:
+ stale = True
+ else:
+ stale = False
+ runtime.sim_process = process
+ runtime.sim_started = True
+ runtime.sim_finished = False
+ runtime.sim_returncode = None
+ record_simulation_started_locked()
+ runtime.log_lines.append("$ " + " ".join(command))
+
+ if stale:
+ terminate_process_group(process)
+ return None
+
+ reader.start()
+ monitor.start()
+ return None
+
+
+def monitor_simulation(
+ token: str,
+ process: subprocess.Popen[str],
+ output_queue: queue.Queue[str],
+ reader: threading.Thread,
+ started_at_ns: int,
+ run_log_mode: str,
+ task_description: str,
+ scene_description: str,
+ parallel_env: bool,
+) -> None:
+ while process.poll() is None:
+ append_simulation_logs(token, process, drain_output_queue(output_queue))
+ time.sleep(0.5)
+
+ reader.join(timeout=1.0)
+ append_simulation_logs(token, process, drain_output_queue(output_queue))
+ latest_video = latest_audience_output_video(min_mtime_ns=started_at_ns)
+ latest_dataset = latest_lerobot_dataset(min_mtime_ns=started_at_ns)
+ lerobot_video = (
+ build_lerobot_preview_video(latest_dataset)
+ if latest_dataset is not None
+ else None
+ )
+ combined_video = (
+ build_single_env_combined_video(latest_video, lerobot_video)
+ if not parallel_env
+ else None
+ )
+ display_video = combined_video or latest_video
+
+ should_archive = False
+ archive_outcome = "completed"
+ with runtime_lock:
+ if runtime.run_token != token or runtime.sim_process is not process:
+ return
+ record_simulation_finished_locked()
+ runtime.sim_process = None
+ runtime.sim_finished = True
+ runtime.sim_returncode = process.returncode
+ runtime.video_path = display_video
+ runtime.lerobot_dataset_path = latest_dataset
+ runtime.lerobot_video_path = (
+ None if combined_video is not None else lerobot_video
+ )
+ if process.returncode == 0:
+ runtime.status = (
+ "Pipeline completed successfully.\nDexsim simulation finished."
+ )
+ if latest_video is None:
+ runtime.log_lines.append("Audience video not found in outputs.")
+ if latest_dataset is None:
+ runtime.log_lines.append(
+ "LeRobot dataset with recorded frames not found."
+ )
+ elif lerobot_video is None:
+ runtime.log_lines.append(
+ f"LeRobot dataset found, but preview was not generated: {latest_dataset}"
+ )
+ elif combined_video is not None:
+ runtime.log_lines.append(
+ f"Single-env combined video created: {combined_video}"
+ )
+ else:
+ runtime.status = (
+ "Pipeline completed successfully.\n"
+ f"Dexsim simulation exited with return code {process.returncode}."
+ )
+ runtime.log_lines.append(
+ f"Dexsim simulation exited with return code {process.returncode}."
+ )
+ archive_outcome = "simulation_failed"
+ should_archive = run_log_mode == RUN_LOG_MODE_INTERACT
+ if should_archive:
+ archive_run_log(
+ mode=RUN_LOG_MODE_INTERACT,
+ task_description=task_description,
+ scene_description=scene_description,
+ outcome=archive_outcome,
+ audience_video=display_video,
+ )
+
+
+def append_simulation_logs(
+ token: str,
+ process: subprocess.Popen[str],
+ lines: list[str],
+) -> None:
+ if not lines:
+ return
+ with runtime_lock:
+ if runtime.run_token != token or runtime.sim_process is not process:
+ return
+ for line in lines:
+ runtime.log_lines.append(line)
+
+
+def drain_output_queue(output_queue: queue.Queue[str]) -> list[str]:
+ lines: list[str] = []
+ while True:
+ try:
+ lines.append(output_queue.get_nowait())
+ except queue.Empty:
+ return lines
+
+
+def run_reset():
+ cleanup_errors = reset_current_scene()
+ last_error = "\n".join(cleanup_errors) if cleanup_errors else None
+ status_text = (
+ "Reset complete."
+ if not cleanup_errors
+ else "Reset completed, but some cleanup failed."
+ )
+ return (
+ None,
+ "",
+ "",
+ None,
+ "",
+ PHASES["idle"].progress,
+ format_status(status_text, last_error=last_error),
+ None,
+ None,
+ None,
+ )
+
+
+def stop_current_run_without_cleanup():
+ process: subprocess.Popen[str] | None = None
+ sim_process: subprocess.Popen[str] | None = None
+ with runtime_lock:
+ runtime.run_token = uuid.uuid4().hex
+ runtime.auto_loop_active = False
+ runtime.auto_loop_token = None
+ runtime.auto_round = 0
+ process = runtime.process
+ sim_process = runtime.sim_process
+ runtime.process = None
+ runtime.sim_process = None
+ runtime.sim_started = False
+ runtime.sim_finished = False
+ runtime.sim_returncode = None
+ runtime.is_busy = False
+ runtime.phase_key = "idle"
+ runtime.status = "Stopped."
+ runtime.task_text = ""
+ runtime.input_task_text = ""
+ runtime.input_scene_text = ""
+ runtime.image_path = None
+ runtime.video_path = None
+ runtime.lerobot_video_path = None
+ runtime.lerobot_dataset_path = None
+ runtime.object_model_path = None
+ runtime.scene_model_path = None
+ runtime.edited_scene_model_path = None
+ runtime.last_error = None
+ runtime.log_lines.clear()
+
+ if process is not None:
+ terminate_process_group(process)
+ if sim_process is not None:
+ terminate_process_group(sim_process)
+
+ return (
+ None,
+ "",
+ "",
+ None,
+ "",
+ PHASES["idle"].progress,
+ format_status("Stopped."),
+ None,
+ None,
+ None,
+ )
+
+
+def run_reset_or_stop(run_mode: str):
+ if run_mode == TOP_MODE_AUTO:
+ return stop_current_run_without_cleanup()
+ return run_reset()
+
+
+def rerun_current_simulation(
+ run_mode: str | None,
+ action_mode: str | None,
+ robot_profile: str | None,
+):
+ def _rerun_outputs():
+ return (
+ gr.update(),
+ gr.update(),
+ gr.update(),
+ *ui_snapshot(),
+ )
+
+ if run_mode != TOP_MODE_INTERACT:
+ with runtime_lock:
+ runtime.status = "Rerun 3D is only available in Interact mode."
+ runtime.last_error = runtime.status
+ runtime.log_lines.append(runtime.status)
+ return _rerun_outputs()
+
+ if not rerun_simulation_is_available():
+ with runtime_lock:
+ runtime.status = (
+ "Current simulation files are not available. Generate once first."
+ )
+ runtime.last_error = runtime.status
+ runtime.log_lines.append(runtime.status)
+ return _rerun_outputs()
+
+ token = uuid.uuid4().hex
+ with runtime_lock:
+ if runtime.process is not None:
+ runtime.status = "Another pipeline run is in progress. Stop it first."
+ runtime.last_error = runtime.status
+ runtime.log_lines.append(runtime.status)
+ return _rerun_outputs()
+ if runtime.sim_process is not None:
+ runtime.status = "Another Dexsim process is running. Stop it first."
+ runtime.last_error = runtime.status
+ runtime.log_lines.append(runtime.status)
+ return _rerun_outputs()
+ if runtime.is_busy:
+ runtime.status = "Another run is in progress. Stop it first."
+ runtime.last_error = runtime.status
+ runtime.log_lines.append(runtime.status)
+ return _rerun_outputs()
+
+ runtime.run_token = token
+ runtime.sim_started = False
+ runtime.sim_finished = False
+ runtime.sim_returncode = None
+ runtime.last_error = None
+ clear_run_timing_locked()
+ runtime.log_lines.append("Starting Dexsim rerun (run_agent only).")
+
+ simulation_error = launch_current_simulation(
+ runtime.run_token,
+ parallel_env=action_mode == TOP_MODE_PARALLEL_ENV,
+ robot_profile=robot_profile,
+ run_log_mode=RUN_LOG_MODE_INTERACT,
+ task_description=runtime.task_text,
+ scene_description=runtime.input_scene_text,
+ )
+ if simulation_error is not None:
+ with runtime_lock:
+ runtime.status = f"Dexsim rerun launch failed: {simulation_error}"
+ runtime.last_error = simulation_error
+ runtime.log_lines.append(runtime.status)
+
+ return _rerun_outputs()
+
+
+def randomize_interact_task_input(run_mode: str | None, language: str | None):
+ """Fill the Interact form with one available template task."""
+ if run_mode != TOP_MODE_INTERACT:
+ return (
+ gr.update(),
+ gr.update(),
+ gr.update(),
+ gr.update(),
+ None,
+ gr.update(),
+ None,
+ None,
+ )
+ auto_input = generate_auto_text_input(
+ language=language or LANGUAGE_EN,
+ include_scene=False,
+ )
+ initial_preview = None
+ if auto_input.prebuilt_scene_dir is not None:
+ initial_preview = build_interact_random_initial_preview(
+ auto_input.prebuilt_scene_dir
+ ).as_posix()
+ return (
+ auto_input.base_image_path.as_posix(),
+ gr.update(value=auto_input.task_description, interactive=True),
+ gr.update(),
+ SCENE_MODE_INITIAL,
+ (
+ auto_input.prebuilt_scene_dir.as_posix()
+ if auto_input.prebuilt_scene_dir
+ else None
+ ),
+ initial_preview,
+ None,
+ None,
+ )
+
+
+def randomize_interact_scene_input(run_mode: str | None, language: str | None):
+ """Fill only the scene text in the Interact form."""
+ if run_mode != TOP_MODE_INTERACT:
+ return gr.update()
+ scene_description = generate_auto_scene_description(
+ language=language or LANGUAGE_EN,
+ ensure_scene=True,
+ )
+ return gr.update(value=scene_description, interactive=True)
+
+
+def clear_interact_prebuilt_scene() -> None:
+ return None
+
+
+def button_updates(
+ language: str | None,
+ run_mode: str | None,
+ action_mode: str | None,
+) -> tuple[Any, Any, Any, Any, Any, Any, Any, Any]:
+ """Build localized labels while preserving the selected button variants."""
+ labels = BUTTON_LABELS.get(language or LANGUAGE_EN, BUTTON_LABELS[LANGUAGE_EN])
+ is_auto = run_mode == TOP_MODE_AUTO
+ is_interact = run_mode != TOP_MODE_AUTO
+ is_parallel_env = action_mode == TOP_MODE_PARALLEL_ENV
+ can_rerun = (
+ run_mode == TOP_MODE_INTERACT
+ and rerun_simulation_is_available()
+ and not runtime.is_busy
+ and runtime.process is None
+ and runtime.sim_process is None
+ )
+ return (
+ gr.update(
+ value=labels["auto"],
+ variant="primary" if is_auto else "secondary",
+ ),
+ gr.update(
+ value=labels["interact"],
+ variant="primary" if is_interact else "secondary",
+ ),
+ gr.update(
+ value=labels["parallel_env"],
+ variant="primary" if is_parallel_env else "secondary",
+ interactive=not is_auto,
+ ),
+ gr.update(value=labels["start"] if is_auto else labels["generate"]),
+ gr.update(
+ value=labels["rerun_simulation"],
+ visible=is_interact,
+ interactive=can_rerun,
+ ),
+ gr.update(value=labels["random_input"], visible=is_interact),
+ gr.update(value=labels["random_scene_input"], visible=is_interact),
+ gr.update(value=labels["stop"] if is_auto else labels["reset"]),
+ )
+
+
+def auto_control_updates(
+ run_mode: str | None,
+ action_mode: str | None,
+) -> tuple[Any, Any, Any, str | None]:
+ if run_mode != TOP_MODE_AUTO:
+ return (
+ gr.update(interactive=True),
+ gr.update(interactive=True),
+ gr.update(),
+ action_mode,
+ )
+
+ with runtime_lock:
+ scene_mode = runtime.auto_scene_mode
+ parallel_env = runtime.auto_parallel_env
+ robot_profile = runtime.auto_robot_profile
+ labels = BUTTON_LABELS.get(runtime.language, BUTTON_LABELS[LANGUAGE_EN])
+ return (
+ gr.update(value=scene_mode, interactive=False),
+ gr.update(value=robot_profile, interactive=False),
+ gr.update(
+ value=labels["parallel_env"],
+ variant="primary" if parallel_env else "secondary",
+ interactive=False,
+ ),
+ TOP_MODE_PARALLEL_ENV if parallel_env else None,
+ )
+
+
+def video_preview_label(language: str | None, action_mode: str | None) -> str:
+ text = UI_TEXT.get(language or LANGUAGE_EN, UI_TEXT[LANGUAGE_EN])
+ key = (
+ "parallel_video_preview"
+ if action_mode == TOP_MODE_PARALLEL_ENV
+ else "single_video_preview"
+ )
+ return text[key]
+
+
+def scene_mode_choices(language: str | None) -> list[tuple[str, str]]:
+ text = UI_TEXT.get(language or LANGUAGE_EN, UI_TEXT[LANGUAGE_EN])
+ return [
+ (text["scene_mode_initial"], SCENE_MODE_INITIAL),
+ (text["scene_mode_edit"], SCENE_MODE_EDIT),
+ (text["scene_mode_task_only"], SCENE_MODE_TASK_ONLY),
+ ]
+
+
+def scene_mode_input_updates(scene_mode: str | None) -> tuple[Any, Any]:
+ """Set field availability for the selected scene operation."""
+ is_task_only = scene_mode == SCENE_MODE_TASK_ONLY
+ return (
+ gr.update(interactive=True),
+ gr.update(interactive=not is_task_only),
+ )
+
+
+def localized_ui_updates(
+ language: str | None,
+ action_mode: str | None,
+) -> tuple[Any, ...]:
+ """Return updates for every non-button, user-facing static UI string."""
+ text = UI_TEXT.get(language or LANGUAGE_EN, UI_TEXT[LANGUAGE_EN])
+ instruction_html = (
+ "{text['instruction']}
"
+ )
+ return (
+ gr.update(value=text["heading"]),
+ gr.update(value=instruction_html),
+ gr.update(label=text["robot"]),
+ gr.update(label=text["input_image"]),
+ gr.update(
+ label=text["task_description"],
+ placeholder=text["task_placeholder"],
+ ),
+ gr.update(
+ label=text["scene_description"],
+ placeholder=text["scene_placeholder"],
+ ),
+ gr.update(
+ label=text["scene_mode"],
+ choices=scene_mode_choices(language),
+ ),
+ gr.update(label=video_preview_label(language, action_mode)),
+ gr.update(label=text["current_task"]),
+ gr.update(label=text["progress"]),
+ gr.update(label=text["initial_preview"]),
+ gr.update(label=text["edited_preview"]),
+ gr.update(label=text["object_preview"]),
+ )
+
+
+def toggle_language(
+ language: str | None,
+ run_mode: str | None,
+ action_mode: str | None,
+):
+ next_language = LANGUAGE_ZH if language != LANGUAGE_ZH else LANGUAGE_EN
+ with runtime_lock:
+ runtime.language = next_language
+ labels = BUTTON_LABELS[next_language]
+ return (
+ *button_updates(next_language, run_mode, action_mode),
+ gr.update(value=labels["language"]),
+ *localized_ui_updates(next_language, action_mode),
+ next_language,
+ )
+
+
+def select_top_mode(
+ selected_run_mode: str | None,
+ selected_action_mode: str | None,
+ current_run_mode: str,
+ current_action_mode: str | None,
+ language: str | None,
+):
+ run_mode = selected_run_mode or current_run_mode or TOP_MODE_INTERACT
+ action_mode = current_action_mode
+ if selected_action_mode == TOP_MODE_PARALLEL_ENV:
+ action_mode = (
+ None if action_mode == TOP_MODE_PARALLEL_ENV else TOP_MODE_PARALLEL_ENV
+ )
+ elif selected_action_mode:
+ action_mode = selected_action_mode
+ if (
+ run_mode != current_run_mode
+ or action_mode != current_action_mode
+ or run_mode != TOP_MODE_AUTO
+ ):
+ stop_auto_loop_if_running()
+ return (
+ *button_updates(language, run_mode, action_mode),
+ gr.update(label=video_preview_label(language, action_mode)),
+ run_mode,
+ action_mode,
+ )
+
+
+def ui_snapshot(extra_status: str | None = None):
+ with runtime_lock:
+ phase = PHASES.get(runtime.phase_key, PHASES["idle"])
+ video_value = None
+ video_signature = None
+ if runtime.video_path and runtime.video_path.is_file():
+ video_value = runtime.video_path.as_posix()
+ video_signature = (video_value, runtime.video_path.stat().st_mtime_ns)
+ if runtime.auto_loop_active:
+ video_update = video_value
+ elif video_signature != runtime.last_sent_video_signature:
+ runtime.last_sent_video_signature = video_signature
+ video_update = video_value
+ else:
+ video_update = gr.update()
+ object_model_value = (
+ runtime.object_model_path.as_posix()
+ if runtime.object_model_path and runtime.object_model_path.is_file()
+ else None
+ )
+ model_value = (
+ runtime.scene_model_path.as_posix()
+ if runtime.scene_model_path and runtime.scene_model_path.is_file()
+ else None
+ )
+ edited_model_value = (
+ runtime.edited_scene_model_path.as_posix()
+ if runtime.edited_scene_model_path
+ and runtime.edited_scene_model_path.is_file()
+ else None
+ )
+ task_text = runtime.task_text
+ status_text = runtime.status
+ if extra_status:
+ status_text = f"{status_text}\n{extra_status}"
+ busy = runtime.is_busy
+ last_error = runtime.last_error
+ return (
+ video_update,
+ task_text,
+ phase.progress,
+ format_status(
+ status_text,
+ phase=phase,
+ busy=busy,
+ last_error=last_error,
+ ),
+ model_value,
+ edited_model_value,
+ object_model_value,
+ )
+
+
+def synced_ui_snapshot(
+ run_mode: str | None = None,
+ action_mode: str | None = None,
+ last_seen_input_revision: int | None = None,
+):
+ sync_inputs = False
+ with runtime_lock:
+ submitted_input_revision = runtime.submitted_input_revision
+ sync_inputs = (
+ runtime.auto_loop_active
+ or run_mode == TOP_MODE_AUTO
+ or submitted_input_revision != (last_seen_input_revision or 0)
+ )
+ image_value = (
+ runtime.image_path.as_posix()
+ if runtime.image_path and runtime.image_path.is_file()
+ else None
+ )
+ input_task_text = runtime.input_task_text
+ input_scene_text = runtime.input_scene_text
+ can_rerun = (
+ runtime.process is None
+ and runtime.sim_process is None
+ and not runtime.is_busy
+ and rerun_simulation_is_available()
+ )
+
+ if sync_inputs:
+ input_values = (image_value, input_task_text, input_scene_text)
+ else:
+ input_values = (gr.update(), gr.update(), gr.update())
+ return (
+ *input_values,
+ *ui_snapshot(),
+ gr.update(
+ visible=run_mode == TOP_MODE_INTERACT,
+ interactive=run_mode == TOP_MODE_INTERACT and can_rerun,
+ ),
+ submitted_input_revision,
+ *auto_control_updates(run_mode, action_mode),
+ )
+
+
+def format_status(
+ status_text: str,
+ *,
+ phase: Phase | None = None,
+ busy: bool = False,
+ last_error: str | None = None,
+) -> str:
+ if phase is None:
+ phase = PHASES["idle"]
+ state = "running" if busy else "ready"
+ parts = [
+ f"**State:** {state}",
+ f"**Phase:** {phase.progress}% - {phase.label}",
+ f"**Status:** {status_text}",
+ ]
+ if last_error:
+ escaped_error = last_error.replace("`", "'")
+ if "\n" in escaped_error:
+ parts.append(f"**Last error:**\n```text\n{escaped_error}\n```")
+ else:
+ parts.append(f"**Last error:** `{escaped_error}`")
+ return "\n\n".join(parts)
diff --git a/embodichain/gen_sim/gradio_ui/assets/dexforce.png b/embodichain/gen_sim/gradio_ui/assets/dexforce.png
new file mode 100644
index 000000000..6a8e11b00
Binary files /dev/null and b/embodichain/gen_sim/gradio_ui/assets/dexforce.png differ
diff --git a/embodichain/gen_sim/gradio_ui/gradio_app.py b/embodichain/gen_sim/gradio_ui/gradio_app.py
new file mode 100644
index 000000000..7dce2e1a8
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/gradio_app.py
@@ -0,0 +1,83 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+"""Application entry point.
+
+The UI callbacks and pipeline services are intentionally kept out of this
+module; this file only validates configuration and launches the application.
+"""
+
+from __future__ import annotations
+
+import signal
+
+from app_config import (
+ ASSETS_DIR,
+ DEBUG_ENGINE_ROOT,
+ DEFAULT_CONCURRENCY_LIMIT,
+)
+from app_env import EMBODICHAIN_ROOT, SERVER_NAME, SERVER_PORT
+from app_processes import force_stop_all_child_processes
+from app_services import build_demo
+
+__all__ = ["main"]
+
+
+def _stop_child_processes() -> None:
+ """Force-stop UI-owned subprocesses without masking app shutdown."""
+ try:
+ force_stop_all_child_processes()
+ except Exception:
+ # Shutdown must not be blocked by an already-exited preview process.
+ pass
+
+
+def _handle_shutdown_signal(signum: int, _frame: object) -> None:
+ """Terminate UI subprocesses before leaving the Gradio process."""
+ _stop_child_processes()
+ if signum == signal.SIGINT:
+ raise KeyboardInterrupt
+ raise SystemExit(128 + signum)
+
+
+def _install_shutdown_handlers() -> None:
+ """Install cleanup-aware handlers for the normal Gradio stop signals."""
+ signal.signal(signal.SIGINT, _handle_shutdown_signal)
+ signal.signal(signal.SIGTERM, _handle_shutdown_signal)
+
+
+def main() -> None:
+ if not EMBODICHAIN_ROOT.is_dir():
+ raise FileNotFoundError(f"EmbodiChain root not found: {EMBODICHAIN_ROOT}")
+ demo = build_demo()
+ demo.queue(default_concurrency_limit=DEFAULT_CONCURRENCY_LIMIT)
+ _install_shutdown_handlers()
+ try:
+ demo.launch(
+ server_name=SERVER_NAME,
+ server_port=SERVER_PORT,
+ allowed_paths=[
+ str(EMBODICHAIN_ROOT),
+ str(ASSETS_DIR),
+ str(DEBUG_ENGINE_ROOT),
+ ],
+ )
+ finally:
+ _stop_child_processes()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/embodichain/gen_sim/gradio_ui/gradio_visualization_architecture.md b/embodichain/gen_sim/gradio_ui/gradio_visualization_architecture.md
new file mode 100644
index 000000000..f8cc2fe80
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/gradio_visualization_architecture.md
@@ -0,0 +1,285 @@
+# Gradio 可视化系统架构
+
+本文档以当前代码为准,描述 Gradio Demo、Debug 下的三个引擎,以及它们与 EmbodiChain、SimReady、Articraft 和 DexSim 的边界。`gradio_app.py` 只负责启动;界面、资产工作流、场景工作流和进程管理分散在专用模块中。
+
+## 架构总览
+
+```text
+gradio_app.py
+ │ 启动、队列、allowed_paths
+ ▼
+app_services.py(兼容门面)
+ ▼
+app_ui.py ───────────► app_asset_engine.py ───► SimReady CLI
+ │ 布局、模式和事件绑定 │ │
+ │ │ └──────────► app_articraft.py ───► Articraft CLI + Codex CLI
+ ▼
+app_workflows.py ────► EmbodiChain Scene Engine CLI + Viser
+ │ └► prompt2scene / action-agent pipeline + DexSim
+ ├──────────────► app_commands.py 命令构造
+ ├──────────────► app_processes.py 子进程、环境、日志和阶段检测
+ ├──────────────► app_state.py 共享 RuntimeState、锁和计时
+ ├──────────────► app_media.py 视频、数据集预览和日志归档
+ └──────────────► app_config.py UI 常量、路径推导和命令定义
+ └──────────────► app_env.py 部署配置读取
+ └──────────► ../.env 部署路径、端口和服务凭据
+```
+
+| 模块 | 职责 |
+| --- | --- |
+| `gradio_app.py` | 唯一启动入口;校验 `EMBODICHAIN_ROOT`,创建 Blocks,设置队列和本地文件访问路径。 |
+| `app_ui.py` | Demo/Debug 布局、引擎面板切换和回调绑定;不实现 pipeline。 |
+| `app_asset_engine.py` | SimReady 上传适配、输入/输出 GLB 预览、处理日志,以及 Asset engine 的 Articraft 标签页。 |
+| `app_articraft.py` | Articraft checkout/环境检查、外部记录创建、Codex 生成与校验、URDF bundle 和 Viser 关节预览。 |
+| `app_workflows.py` | Demo 的 prompt2scene/action-agent 工作流、独立 Scene Engine 工作流、GLB 预览、场景提升和 DexSim。 |
+| `app_processes.py` | 子进程环境、进程组终止、stdout 读取、Demo pipeline 阶段检测。 |
+| `app_state.py` | `RuntimeState`、互斥锁、进度阶段、运行 token 和耗时统计。 |
+| `app_commands.py` | prompt2scene、动作配置和 `run_agent` 的参数构造。 |
+| `app_media.py` | 观众视频、LeRobot 数据预览、组合视频和运行日志归档。 |
+| `app_config.py` | UI 文案、引擎模式、路径推导和 CLI 固定参数。 |
+| `app_env.py` | 从 `.env` 读取 Gradio、Articraft 和 SimReady 的部署值,并保留未配置时的默认值。 |
+| `../.env` | Gradio 与 Scene Engine 共用的路径、端口、LLM 和服务端点配置;不提交凭据。 |
+
+## 启动、路径和网络环境
+
+从本项目目录启动:
+
+```bash
+conda run -n embodichain python gradio_app.py
+```
+
+| 变量 | 默认值 | 用途 |
+| --- | --- | --- |
+| EmbodiChain root | 自动从 `embodichain/gen_sim/env.py` 的源码位置推导 | EmbodiChain 根目录;不再从 `.env` 配置。 |
+| `GRADIO_SERVER_NAME` | `0.0.0.0` | Gradio 监听地址。 |
+| `GRADIO_SERVER_PORT` | `7860` | Gradio 监听端口。 |
+| `SCENE_ENGINE_VISER_PORT` | `8080` | 独立 Scene Engine 的 Viser 端口。 |
+| `ARTICRAFT_VISER_PORT` | `8081` | Articraft 关节预览的 Viser 端口。 |
+| `ARTICRAFT_ROOT` | `<项目>/.articraft` | Articraft checkout。 |
+| `ARTICRAFT_CONDA_ENV` | `articraft` | 运行 Articraft CLI 的 Conda 环境。 |
+| `ARTICRAFT_OUTPUT_ROOT` | `<项目>/.debug_engine/articraft` | Articraft 记录、运行日志和导出 bundle。 |
+
+`demo.launch()` 仅开放 EmbodiChain 根目录、`assets/` 和 `.debug_engine/` 给浏览器读取。pipeline 子进程由 `build_pipeline_env()` 创建环境:它清除代理变量、设置 `NO_PROXY=no_proxy=*`、关闭 Gradio analytics,并把非空的 SimReady 配置映射为 `OPENAI_*`。这不会改写启动 Gradio 的父进程环境。
+
+## 页面与引擎
+
+顶部的 `Demo` / `Debug` 只切换可见面板,不会启动任务;切换后共享运行状态保留。Debug 有三个按钮:`Asset_engine`、`Scene_engine`、`Action_engine`。它们的实际输入和产物并不完全相同:
+
+| Engine | 输入 | 预览/下载 | 实际产物 | 是否启动 DexSim |
+| --- | --- | --- | --- | --- |
+| Asset engine / SimReady | 一个网格、可选材质附件、类别 | 输入 GLB、SimReady GLB、原始输出下载 | `.debug_engine/assets/runs//` | 否 |
+| Asset engine / Articulation | 文字、可选参考图 | URDF articulation 的 Viser、zip 下载 | `.debug_engine/articraft/` | 否 |
+| Scene engine | 一张图片 | Scene Engine 的 Viser | `.debug_engine/scenes//` | 否 |
+| Action engine | `current` Gym 场景、任务、机器人 | `current` 的 GLB 和 DexSim 视频 | EmbodiChain `gym_project/current` 与 `outputs/` | 是 |
+
+因此,Debug 的 Scene engine 是独立的图像条件场景生成器;它不会提升、复制或转换输出到 `gym_project/current`。Action engine 只消费 Demo/prompt2scene 工作流已经生成的 `current` Gym 场景。界面中的 “Scene engine” 文案表达的是所需场景类型,并不意味着独立 Scene Engine 输出已自动连到 Action engine。
+
+## Demo:端到端 Gym 场景和 DexSim
+
+Demo 提供 `Auto`、`Interact`、`Parallel Simulation` 三种运行状态,以及图像、任务、场景描述、生成模式、机器人、随机输入、视频和 GLB 预览。它们与顶部的 Demo/Debug 模式无关。
+
+`run_generate()` 是 Demo 的主入口。初始生成会在 staging 场景中运行 prompt2scene/action-agent pipeline,成功后才 promote 为固定的 `current`;随后默认启动 DexSim。编辑和仅改任务复用已有 `current`:
+
+```text
+Initial generation
+ image + task
+ → _gradio_pending_
+ → run_agent_pipeline --skip-run-agent
+ → fast_gym_config / agent_config / GLB previews
+ → promote 到 current
+ → run_agent(DexSim)
+
+Edit current scene
+ current + task + scene description
+ → 编辑 pipeline
+ → current
+ → run_agent(DexSim)
+
+Change task only
+ current + task
+ → generate_action_agent_config
+ → current
+ → run_agent(DexSim)
+```
+
+场景生成期间,工作流会从 `fast_gym_config.json` 构建场景 GLB,并将生成的对象 GLB 合并为对象预览。`launch_simulation=False` 是可用的工作流参数,但当前 Debug Scene panel 不调用这条 Demo 工作流;它调用独立的 `run_scene_engine()`。
+
+正式场景固定在:
+
+```text
+gym_project/current/
+gym_project/current/gym_export/
+gym_project/action_agent_pipeline/images/current.png
+gym_project/action_agent_pipeline/configs/current/
+ fast_gym_config.json
+ agent_config.json
+ gradio_scene/
+ scene_current.glb
+ initial_scene.glb
+ object_preview.glb
+```
+
+初始生成使用 `_gradio_pending_` 路径。提升失败或 pipeline 失败时,已有 `current` 保持不变;成功提升后会重写 staging 中的路径引用。`Reset` 会清理当前场景和 staging 产物;`Stop` 通过进程组终止正在运行的 pipeline 或 DexSim。
+
+## Asset engine
+
+### SimReady:单资产目录适配
+
+SimReady CLI 接收目录,而 Gradio 接收上传文件。上传文件会复制到隔离目录,文件名只保留 basename,重名追加序号,避免上传路径或重名影响处理:
+
+```text
+mesh + sidecar files
+ → .debug_engine/assets/runs//input/
+ → trimesh 导出 input_preview.glb
+ → SimReady CLI
+ → output/**/asset_simready.glb(优先)或 asset_simready.obj
+ → GLB 预览 + 原始文件下载
+```
+
+主网格支持 `.glb`、`.gltf`、`.obj`、`.ply`、`.stl`;可一并上传 `.mtl`、纹理和 `.bin` 等附件。执行命令为:
+
+```bash
+python -m embodichain.gen_sim.simready_pipeline.cli.start \
+ --input_dir \
+ --output_root \
+ --category
+```
+
+处理函数以 generator 持续返回最近的 stdout;完成时优先预览 `asset_simready.glb`,只有 OBJ 时再转为 GLB。此路径不依赖 DexSim。
+
+`Reset SimReady` 会清空上传、类别、预览、下载项和日志,并按进程组终止正在运行的 SimReady CLI 及其子进程。
+
+### Articulation:Articraft + Codex
+
+Articulation 标签页根据文本和可选参考图生成一个可下载的 articulated asset。先点击环境检查:若 `ARTICRAFT_ROOT` 不存在,应用会 clone `ARTICRAFT_REPOSITORY_URL`;随后检查 Conda、指定的 Articraft 环境和 Codex CLI。该操作会创建 checkout 和 `.debug_engine/articraft/` 中的输出目录,现有的非 Articraft 目录不会被覆盖。
+
+生成流程:
+
+```text
+description + optional image
+ → Articraft external init(创建 rec_ui_articraft_* 记录)
+ → 启动 Codex CLI,仅授权编辑该记录的 active model.py
+ → Articraft external check
+ └─ 旧版 CLI 无 check 时:compile --validate --strict-geom-qc + compile_report
+ → Articraft external finalize
+ → materialized model.urdf + meshes
+ → exports/.zip + Viser articulation preview
+```
+
+产物、记录和参考图均在 `ARTICRAFT_OUTPUT_ROOT` 下,不能直接当作 Demo 的 Gym 场景或 SimReady 资产;若要进入后续仿真,需要另行定义并实现转换/导入流程。Articraft Viser 每次成功预览会终止旧的 Articraft 预览进程,再以 `0.0.0.0:` 启动新进程。
+
+`Reset Articulation` 会清空描述、参考图、记录与下载结果,终止当前 Articraft/Codex 命令进程组,并关闭该面板启动的 Viser。
+
+## 独立 Scene engine 和 Viser
+
+Scene engine 只接收图像。上传图像会先进行 EXIF 归正并转为 RGB PNG,以 PNG 字节的 SHA-256 前 16 位作为目录名;相同图像会复用同一目录:
+
+```text
+image
+ → .debug_engine/scenes//input.png
+ → python -m embodichain scene-engine
+ --image
+ --output_root
+ → /scene_export/scene_config.json
+ → preview.py --viser --viser-host 0.0.0.0 --viser-port 8080
+ → Gradio iframe
+```
+
+当 `scene_export/scene_config.json` 存在且生成进程返回成功时,应用才启动 Viser。iframe 使用 Gradio 页面当前的协议和主机名转向 Viser 端口,因此从其他设备访问时,浏览器必须能访问该端口。每次新 Scene Engine 任务开始前会终止旧的 Scene Viser 进程。输出目录会显示在 UI 中,便于检查 hash 命名的场景导出。
+
+`Reset Scene Engine` 会清空图像、进度、输出目录和 iframe,并终止当前生成命令与 Scene Viser 的进程组;运行 token 会使已经失效的生成器停止回写界面。
+
+## Action engine:Gym 场景契约
+
+Action engine 不接收裸 GLB。普通 GLB 只有渲染数据,而 DexSim 还需要碰撞、物理参数、初始位姿、资源相对路径和 action 配置。当前实现的前置条件是:
+
+```text
+gym_project/current/gym_export/
+gym_project/action_agent_pipeline/configs/current/fast_gym_config.json
+gym_project/action_agent_pipeline/configs/current/agent_config.json
+```
+
+点击 `Load current scene` 只读取共享状态快照。点击 `Run DexSim` 会先检查任务、`current` 的 Gym/action 配置、运行占用和可导入的 `embodichain.gen_sim.action_agent_pipeline.cli.run_agent`,再以当前配置调用 `run_agent`。它不会因为新的任务文本重建动作图;任务改变时应在 Demo 里使用 `Change task only`,或者实现显式的配置再生成步骤。
+
+运行命令的核心参数为:
+
+```bash
+python -m embodichain.gen_sim.action_agent_pipeline.cli.run_agent \
+ --task_name current \
+ --gym_config <.../fast_gym_config.json> \
+ --agent_config <.../agent_config.json> \
+ --regenerate --renderer fast-rt --num_envs <1|9>
+```
+
+并行模式额外传入 arena 和数据保存过滤参数。`--robot-profile` 仅在通过 `run_agent --help` 探测到该参数时加入。DexSim 完成后会寻找 audience 视频和 LeRobot 数据集;单环境可组合两种预览视频。
+
+## 共享状态、并发和进度
+
+Demo、独立 Scene engine 和 Action engine 共享 `RuntimeState` 与 `runtime_lock`,其中包含运行 token、pipeline/DexSim/Scene Viser 进程、输入、预览、日志、阶段和计时。运行 token 用于丢弃过期线程的更新。Articraft Viser 使用单独的锁和进程引用;SimReady 使用自己的同步 generator。
+
+`demo.queue(default_concurrency_limit=1)` 将队列中的高成本回调串行化。Demo 的 `Timer(2.0)` 与 Action engine 的独立 `Timer(2.0)` 都读取同一共享状态。Scene Engine 和 Demo pipeline 因共享 `is_busy` 互斥;Asset/Articraft 面板不写入这一状态,但仍会受 Gradio 队列限制。
+
+共享阶段如下;独立 Scene Engine 将其日志映射到相同的进度条:
+
+```text
+idle → received → started → scene_intake → relations
+→ asset_generation → gym_export → config → preview → complete
+ └──────────────→ failed
+```
+
+## 环境前置条件与验证
+
+SimReady 需要 Blender、trimesh、LLM 配置以及可导入的:
+
+```text
+embodichain.gen_sim.simready_pipeline.cli.start
+```
+
+SimReady 的 OpenAI-compatible 设置来自环境变量,且不应写入 Git:
+
+```bash
+export SIMREADY_OPENAI_API_KEY=''
+export SIMREADY_OPENAI_MODEL=''
+export SIMREADY_OPENAI_BASE_URL=''
+```
+
+Demo/Action 需要 action-agent 模块,特别是:
+
+```text
+embodichain.gen_sim.action_agent_pipeline.cli.run_agent_pipeline
+embodichain.gen_sim.action_agent_pipeline.cli.generate_action_agent_config
+embodichain.gen_sim.action_agent_pipeline.cli.run_agent
+```
+
+独立 Scene engine 还需要:
+
+```text
+python -m embodichain scene-engine
+embodichain/gen_sim/scene_engine/cli/preview.py
+.env
+```
+
+Articulation 还需要 Git(首次 clone)、Conda、`ARTICRAFT_CONDA_ENV` 和 Codex CLI。生成请求会交给本机 Codex CLI 执行,因此只应提交可信请求。
+
+每次修改后至少执行:
+
+```bash
+python -m py_compile \
+ gradio_app.py app_config.py app_env.py app_state.py app_commands.py \
+ app_processes.py app_media.py app_workflows.py app_ui.py \
+ app_asset_engine.py app_articraft.py app_services.py
+
+env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \
+ -u http_proxy -u https_proxy -u all_proxy \
+ conda run -n embodichain python -c \
+ "from app_ui import build_demo; assert build_demo() is not None"
+```
+
+手动检查:
+
+1. SimReady 上传简单网格后能显示输入预览;执行后显示 SimReady 输出或明确错误。
+2. Articulation 环境检查能报告 checkout、Conda 和 Codex 状态;成功生成后有 zip、记录目录和 Viser 或明确的预览错误。
+3. Scene engine 从图像生成 `scene_export/scene_config.json`,并在 `8080` 显示 Viser;它不应改写 `gym_project/current`。
+4. Demo 初始生成成功后才替换 `current`;失败时旧场景仍可用。
+5. Action engine 在没有 `current` Gym/action 配置或缺少 CLI 时给出预检错误;任务更新后通过 Demo 的 `Change task only` 重建配置。
+6. Demo 的 Auto/Interact/Parallel Simulation 行为不因 Debug 面板切换而改变;Reset/Stop 能终止其对应的进程组。
diff --git a/embodichain/gen_sim/gradio_ui/random_input.py b/embodichain/gen_sim/gradio_ui/random_input.py
new file mode 100644
index 000000000..9ce553992
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/random_input.py
@@ -0,0 +1,542 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+import base64
+import json
+import os
+import uuid
+from dataclasses import asdict, dataclass, replace
+from pathlib import Path
+
+import numpy as np
+
+from embodichain.gen_sim.env import get_embodichain_root, load_gen_sim_env
+
+load_gen_sim_env()
+
+EMBODICHAIN_ROOT = get_embodichain_root()
+APP_ROOT = Path(__file__).resolve().parent
+IMAGE_DIR = Path(
+ os.environ.get(
+ "AUTO_IMAGE_DIR",
+ str(EMBODICHAIN_ROOT / "gym_project/action_agent_pipeline/auto_images"),
+ )
+).expanduser()
+AUTO_IMAGE_DIR_IS_CONFIGURED = "AUTO_IMAGE_DIR" in os.environ
+FALLBACK_IMAGE_DIR = (
+ EMBODICHAIN_ROOT / "gym_project/action_agent_pipeline/baseline_image_input"
+)
+PREBUILT_SCENE_DIR = Path(
+ os.environ.get("AUTO_PREBUILT_SCENE_DIR", str(APP_ROOT / "scenes"))
+).expanduser()
+GENERATED_IMAGE_DIR = Path(
+ os.environ.get("AUTO_GENERATED_IMAGE_DIR", "./tmp_img/auto")
+).expanduser()
+IMAGE_API_KEY = os.environ.get("AUTO_IMAGE_API_KEY", os.environ.get("ARK_API_KEY", ""))
+IMAGE_API_URL = os.environ.get(
+ "AUTO_IMAGE_API_URL",
+ "https://ark.cn-beijing.volces.com/api/v3",
+)
+IMAGE_MODEL = os.environ.get("AUTO_IMAGE_MODEL", "doubao-seedream-4-5-251128")
+IMAGE_SIZE = os.environ.get("AUTO_IMAGE_SIZE", "2848x1600")
+IMAGE_PROMPT = (
+ "以原图为基础参考,严格保持原图的相机视角、拍摄距离、透视关系、画面构图、背景环境、桌面材质、桌面纹理、光影方向、阴影位置和整体明暗关系。"
+ "原图中已有物体的类别、大小、形状、轮廓、空间位置、朝向、部件数量和结构比例必须保持不变。"
+ "如果提供了 Scene description,只根据该描述在桌面上新增对应背景物体;新增物体必须放在描述指定的位置,尺寸、透视、遮挡和阴影要与原图自然一致。"
+ "不要删除原有物体,不要移动原有物体,不要改变主任务物体的结构。高清细节,真实自然。"
+)
+
+TASK_DESCRIPTIONS: dict[tuple[int, int], str] = {
+ (0, 0): "用双臂把两侧的罐头和瓶子放到篮子里",
+ (0, 1): "用双臂把两侧的方块放到篮子里",
+ (0, 2): "用双臂把两侧的方块和纸杯放到篮子里",
+ (0, 3): "用双臂把两侧的方块和苹果放到篮子里",
+ (1, 0): "用双臂把塑料水盆往前移动",
+ (1, 1): "用双臂把木棍往前移动",
+ (1, 2): "用双臂往把苹果和魔方放入盘子,然后用双臂端起盘子",
+ (1, 3): "用双臂把托盘往前移动",
+ (2, 0): "用双臂把两侧的香蕉放到盘子里,然后用双臂端起盘子",
+ (2, 1): "用双臂把两侧的罐头扶正",
+ (2, 2): "用双臂把两侧的瓶子和罐头扶正",
+ (2, 3): "用双臂把两侧的罐头扶正",
+ (3, 0): "把桌面上的物体按照方块按照从右往左的顺序叠起来",
+ (3, 1): "把桌面上的物体按照右边的方块,左边的方块,纸杯的顺序叠起来",
+ (3, 2): "把纸杯叠放到爆米花桶上,把蓝色耳机叠放到爆米花桶上",
+ (3, 3): "把纸杯叠放到爆米花桶上,把固体胶叠放到爆米花桶上",
+ (4, 0): "把桌面上的方块摆成一排",
+ (4, 1): "把桌面上的物体按照瓶子,方块排成一排",
+ (4, 2): "把桌面上的罐头摆成一排",
+ (4, 3): "把桌面上的物体按照瓶子,罐头,方块的顺序摆成一排",
+}
+
+TASK_DESCRIPTIONS_EN: dict[tuple[int, int], str] = {
+ (
+ 0,
+ 0,
+ ): "Use both arms to place the cans and bottles on both sides into the basket.",
+ (0, 1): "Use both arms to place the blocks on both sides into the basket.",
+ (
+ 0,
+ 2,
+ ): "Use both arms to place the blocks and paper cups on both sides into the basket.",
+ (
+ 0,
+ 3,
+ ): "Use both arms to place the blocks and apples on both sides into the basket.",
+ (1, 0): "Use both arms to move the plastic basin forward.",
+ (1, 1): "Use both arms to move the wooden stick forward.",
+ (
+ 1,
+ 2,
+ ): "Use both arms to place the apple and Rubik's Cube onto the plate, then use both arms to lift the plate.",
+ (1, 3): "Use both arms to move the tray forward.",
+ (
+ 2,
+ 0,
+ ): "Use both arms to place the bananas on both sides onto the tray, then use both arms to lift the tray.",
+ (2, 1): "Use both arms to set the cans on both sides upright.",
+ (2, 2): "Use both arms to set the bottles and cans on both sides upright.",
+ (2, 3): "Use both arms to set the cans on both sides upright.",
+ (3, 0): "Stack the blocks on the table in order from right to left.",
+ (
+ 3,
+ 1,
+ ): "Stack the objects on the table in this order: right block, left block, paper cup.",
+ (
+ 3,
+ 2,
+ ): "Stack the paper cup on the popcorn bucket, then stack the blue headphones on the popcorn bucket.",
+ (
+ 3,
+ 3,
+ ): "Stack the paper cup on the popcorn bucket, then stack the glue stick on the popcorn bucket.",
+ (4, 0): "Arrange the blocks on the table in a row.",
+ (4, 1): "Arrange the objects on the table in a row in this order: bottle, block.",
+ (4, 2): "Arrange the cans on the table in a row.",
+ (
+ 4,
+ 3,
+ ): "Arrange the objects on the table in a row in this order: bottle, can, block.",
+}
+
+RELATION_PATTERN = {
+ (0, 0): ["at the left side of the can", "at the right side of the bottle"],
+ (0, 1): [
+ "at the left side of the left cheese cube",
+ "at the right side of the right cheese cube",
+ ],
+ (0, 2): ["at the left side of the cube", "at the right side of the cup"],
+ (0, 3): ["at the left side of the cube", "at the right side of the apple"],
+ (1, 0): [],
+ (1, 1): [],
+ (1, 2): [],
+ (1, 3): [],
+ (2, 0): [
+ "at the left side of the left bottle",
+ "at the right side of the right bottle",
+ ],
+ (2, 1): [
+ "at the left side of the left soda can",
+ "at the right side of the right soda can",
+ ],
+ (2, 2): ["at the left side of the bottle", "at the right side of the can"],
+ (2, 3): ["at the left side of the paper cup", "at the right side of the soda can"],
+ (3, 0): [],
+ (3, 1): [],
+ (3, 2): [],
+ (3, 3): [],
+ (4, 0): [],
+ (4, 1): [],
+ (4, 2): [],
+ (4, 3): [],
+}
+
+AREA_PATTERN = [
+ "at the left side of the table",
+ "at the right side of the table",
+ "at the front of the table",
+ "at the front right corner of the table",
+ "at the front left corner of the table",
+]
+
+OBJECT_LIST = [
+ "cup",
+ "potted plant",
+ "clock",
+ "book",
+ "pen",
+ "bottle",
+ "soda can",
+ "photo frame",
+ "apple",
+ "peach",
+ "bread",
+ "chocolate bar",
+ "cookie",
+ "penholder",
+ "desk lamp",
+ "stapler",
+ "headphones",
+ "desk calendar",
+ "eyeglasses",
+ "fan",
+ "bluetooth speaker",
+ "table mirror",
+ "computer mouse",
+ "keyboard",
+]
+
+CHINESE_OBJECT_NAMES = {
+ "cup": "杯子",
+ "potted plant": "盆栽",
+ "clock": "时钟",
+ "book": "书",
+ "bottle": "瓶子",
+ "soda can": "易拉罐",
+ "photo frame": "相框",
+ "apple": "苹果",
+ "peach": "桃子",
+ "bread": "小面包",
+ "chocolate bar": "巧克力棒",
+ "cookie": "饼干",
+ "penholder": "笔筒",
+ "desk lamp": "小台灯",
+ "stapler": "订书机",
+ "headphones": "耳机",
+ "small desk calendar": "小台历",
+ "eyeglasses": "眼镜",
+ "fan": "小风扇",
+ "bluetooth speaker": "蓝牙音箱",
+ "computer mouse": "鼠标",
+}
+
+
+CHINESE_SPATIAL_RELATIONS = {
+ "at the left side of the can": "罐头左侧",
+ "at the right side of the bottle": "瓶子右侧",
+ "at the left side of the left cheese cube": "左侧奶酪方块左侧",
+ "at the right side of the right cheese cube": "右侧奶酪方块右侧",
+ "at the left side of the cube": "方块左侧",
+ "at the right side of the cup": "杯子右侧",
+ "at the right side of the apple": "苹果右侧",
+ "at the left side of the left bottle": "左侧瓶子左侧",
+ "at the right side of the right bottle": "右侧瓶子右侧",
+ "at the left side of the left soda can": "左侧易拉罐左侧",
+ "at the right side of the right soda can": "右侧易拉罐右侧",
+ "at the left side of the bottle": "瓶子左侧",
+ "at the right side of the can": "罐头右侧",
+ "at the left side of the paper cup": "纸杯左侧",
+ "at the right side of the soda can": "易拉罐右侧",
+ "at the left side of the table": "桌子左侧",
+ "at the right side of the table": "桌子右侧",
+ "at the front of the table": "桌子前侧",
+ "at the front right corner of the table": "桌子右前角",
+ "at the front left corner of the table": "桌子左前角",
+ "on the table": "桌面上",
+}
+
+
+@dataclass(frozen=True)
+class AutoInput:
+ task_index: tuple[int, int]
+ base_image_path: Path
+ prebuilt_scene_dir: Path | None
+ image_path: Path | None
+ task_description: str
+ scene_description: str
+
+ def to_json_dict(self) -> dict[str, object]:
+ value = asdict(self)
+ value["task_index"] = list(self.task_index)
+ value["base_image_path"] = self.base_image_path.as_posix()
+ value["prebuilt_scene_dir"] = (
+ self.prebuilt_scene_dir.as_posix() if self.prebuilt_scene_dir else None
+ )
+ value["image_path"] = self.image_path.as_posix() if self.image_path else None
+ return value
+
+
+def task_id(task_index: tuple[int, int]) -> str:
+ return f"task{task_index[0]}_{task_index[1]}"
+
+
+def parse_task_id(value: str) -> tuple[int, int] | None:
+ stem = Path(value).stem
+ if not stem.startswith("task"):
+ return None
+ parts = stem[4:].split("_", maxsplit=1)
+ if len(parts) != 2:
+ return None
+ try:
+ return int(parts[0]), int(parts[1])
+ except ValueError:
+ return None
+
+
+def auto_image_directories() -> tuple[Path, ...]:
+ """Return image sources in precedence order for the Auto loop.
+
+ A user-supplied ``AUTO_IMAGE_DIR`` is authoritative. With the default
+ directory, retain compatibility with deployments that have the checked-in
+ ``baseline_image_input`` set but have not created ``auto_images`` yet.
+ """
+ directories = [IMAGE_DIR]
+ if not AUTO_IMAGE_DIR_IS_CONFIGURED and FALLBACK_IMAGE_DIR != IMAGE_DIR:
+ directories.append(FALLBACK_IMAGE_DIR)
+ return tuple(directories)
+
+
+def available_auto_task_indices() -> tuple[tuple[int, int], ...]:
+ """Return only task variants whose input image and clean scene can be resolved."""
+ return tuple(
+ task_index
+ for task_index in TASK_DESCRIPTIONS
+ if any(
+ (image_dir / f"{task_id(task_index)}.png").is_file()
+ for image_dir in auto_image_directories()
+ )
+ and get_prebuilt_scene_dir(task_index).is_dir()
+ )
+
+
+def random_task(rng: np.random.Generator) -> tuple[int, int]:
+ available_tasks = available_auto_task_indices()
+ if not available_tasks:
+ expected = ", ".join(str(path) for path in auto_image_directories())
+ raise FileNotFoundError(
+ "No Auto input images were found. Add task_.png "
+ f"files to: {expected}"
+ )
+ return available_tasks[int(rng.integers(0, len(available_tasks)))]
+
+
+def get_base_image_path(task_index: tuple[int, int]) -> Path:
+ filename = f"{task_id(task_index)}.png"
+ for image_dir in auto_image_directories():
+ candidate = image_dir / filename
+ if candidate.is_file():
+ return candidate
+ return IMAGE_DIR / filename
+
+
+def get_prebuilt_scene_dir(task_index: tuple[int, int]) -> Path:
+ return PREBUILT_SCENE_DIR / task_id(task_index)
+
+
+def get_task_description(task_index: tuple[int, int], *, language: str = "zh") -> str:
+ descriptions = TASK_DESCRIPTIONS_EN if language == "en" else TASK_DESCRIPTIONS
+ try:
+ return descriptions[task_index]
+ except KeyError as exc:
+ raise KeyError(f"No task description configured for task{task_index}") from exc
+
+
+def image_to_base64(path: Path) -> str:
+ ext = path.suffix.lower()
+ if ext in (".jpg", ".jpeg"):
+ mime = "image/jpeg"
+ elif ext == ".png":
+ mime = "image/png"
+ else:
+ raise ValueError(f"Not supported: {ext}, only jpg/jpeg/png are supported")
+ with path.open("rb") as file:
+ b64_str = base64.b64encode(file.read()).decode("utf-8")
+ return f"data:{mime};base64,{b64_str}"
+
+
+def build_image_prompt(scene_description: str = "") -> str:
+ scene_description = (scene_description or "").strip()
+ if not scene_description:
+ return IMAGE_PROMPT
+ return (
+ f"{IMAGE_PROMPT}\n\n"
+ "Scene description:\n"
+ f"{scene_description}\n\n"
+ "严格执行 Scene description 中的新增物体和空间位置要求。"
+ )
+
+
+def create_image_input(
+ task_index: tuple[int, int],
+ *,
+ scene_description: str = "",
+ output_dir: Path = GENERATED_IMAGE_DIR,
+) -> Path:
+ base_image_path = get_base_image_path(task_index)
+ if not base_image_path.is_file():
+ raise FileNotFoundError(f"Base auto image not found: {base_image_path}")
+
+ from volcenginesdkarkruntime import Ark
+ import requests
+
+ image_base64 = image_to_base64(base_image_path)
+ client = Ark(api_key=IMAGE_API_KEY, base_url=IMAGE_API_URL)
+ response = client.images.generate(
+ model=IMAGE_MODEL,
+ prompt=build_image_prompt(scene_description),
+ image=image_base64,
+ size=IMAGE_SIZE,
+ response_format="url",
+ watermark=False,
+ )
+ image_url = response.data[0].url
+ resp = requests.get(image_url, timeout=60)
+ resp.raise_for_status()
+ output_dir.mkdir(parents=True, exist_ok=True)
+ output_path = (
+ output_dir
+ / f"auto_task{task_index[0]}_{task_index[1]}_{uuid.uuid4().hex[:12]}.png"
+ )
+ output_path.write_bytes(resp.content)
+ return output_path
+
+
+def create_text_input(
+ task_index: tuple[int, int],
+ rng: np.random.Generator,
+ *,
+ language: str = "en",
+ min_background_objects: int = 0,
+) -> str:
+ text_parts: list[str] = []
+ if task_index[0] == 5 and min_background_objects == 0:
+ return ""
+
+ mu, sigma = 1.0, 1.0
+ raw = rng.normal(loc=mu, scale=sigma)
+ num_background_objects = int(np.clip(np.round(raw), 0, 3))
+ if min_background_objects > 0:
+ num_background_objects = max(num_background_objects, min_background_objects)
+
+ if num_background_objects == 0:
+ return ""
+
+ selected_objects = rng.choice(
+ OBJECT_LIST,
+ size=num_background_objects,
+ replace=False,
+ ).tolist()
+
+ spatial_candidates = []
+ spatial_candidates.extend(RELATION_PATTERN.get(task_index, []))
+ spatial_candidates.extend(AREA_PATTERN)
+ spatial_candidates.append("on the table")
+
+ for obj in selected_objects:
+ selected_spatial = rng.choice(spatial_candidates)
+ if language == "zh":
+ chinese_object = CHINESE_OBJECT_NAMES.get(obj, obj)
+ chinese_relation = CHINESE_SPATIAL_RELATIONS.get(
+ selected_spatial,
+ selected_spatial,
+ )
+ text_parts.append(f"将一个{chinese_object}放在{chinese_relation}。")
+ else:
+ article = "an" if obj[0].lower() in {"a", "e", "i", "o", "u"} else "a"
+ text_parts.append(f"Place {article} {obj} {selected_spatial}.")
+
+ return " ".join(text_parts)
+
+
+def generate_auto_scene_description(
+ *,
+ rng: np.random.Generator | None = None,
+ task_index: tuple[int, int] | None = None,
+ language: str = "en",
+ ensure_scene: bool = False,
+) -> str:
+ rng = rng or np.random.default_rng()
+ task_index = task_index or random_task(rng)
+ return create_text_input(
+ task_index,
+ rng,
+ language=language,
+ min_background_objects=1 if ensure_scene else 0,
+ )
+
+
+def generate_auto_text_input(
+ *,
+ rng: np.random.Generator | None = None,
+ task_index: tuple[int, int] | None = None,
+ language: str = "en",
+ ensure_scene: bool = False,
+ include_scene: bool = True,
+) -> AutoInput:
+ rng = rng or np.random.default_rng()
+ task_index = task_index or random_task(rng)
+ base_image_path = get_base_image_path(task_index)
+ prebuilt_scene_dir = get_prebuilt_scene_dir(task_index)
+ if not base_image_path.is_file():
+ raise FileNotFoundError(f"Base auto image not found: {base_image_path}")
+ if not prebuilt_scene_dir.is_dir():
+ raise FileNotFoundError(f"Prebuilt scene not found: {prebuilt_scene_dir}")
+ return AutoInput(
+ task_index=task_index,
+ base_image_path=base_image_path,
+ prebuilt_scene_dir=prebuilt_scene_dir,
+ image_path=None,
+ task_description=get_task_description(task_index, language=language),
+ scene_description=(
+ generate_auto_scene_description(
+ rng=rng,
+ task_index=task_index,
+ language=language,
+ ensure_scene=ensure_scene,
+ )
+ if include_scene
+ else ""
+ ),
+ )
+
+
+def generate_auto_image(
+ auto_input: AutoInput,
+ *,
+ output_dir: Path = GENERATED_IMAGE_DIR,
+) -> AutoInput:
+ image_path = create_image_input(
+ auto_input.task_index,
+ scene_description=auto_input.scene_description,
+ output_dir=output_dir,
+ )
+ return replace(auto_input, image_path=image_path)
+
+
+def generate_auto_input(
+ *,
+ rng: np.random.Generator | None = None,
+ task_index: tuple[int, int] | None = None,
+ output_dir: Path = GENERATED_IMAGE_DIR,
+ language: str = "en",
+) -> AutoInput:
+ auto_input = generate_auto_text_input(
+ rng=rng,
+ task_index=task_index,
+ language=language,
+ )
+ return generate_auto_image(auto_input, output_dir=output_dir)
+
+
+def main() -> None:
+ auto_input = generate_auto_input()
+ print(json.dumps(auto_input.to_json_dict(), ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/gen_sim/scene_engine/test_config.py b/tests/gen_sim/scene_engine/test_config.py
new file mode 100644
index 000000000..01914e144
--- /dev/null
+++ b/tests/gen_sim/scene_engine/test_config.py
@@ -0,0 +1,100 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from embodichain.gen_sim.scene_engine.cli import start
+from embodichain.gen_sim.scene_engine.configs import environment
+
+
+def test_read_scene_engine_env_values_reads_requested_keys(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ env_path = tmp_path / ".env"
+ env_path.write_text('OPENAI_MODEL="test-model"\nUNRELATED_VALUE=ignored\n')
+ monkeypatch.setattr(environment, "_SCENE_ENGINE_ENV_PATH", env_path)
+
+ assert environment.read_scene_engine_env_values("OPENAI_MODEL") == {
+ "OPENAI_MODEL": "test-model"
+ }
+
+
+def test_read_scene_engine_env_values_reports_missing_keys(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ env_path = tmp_path / ".env"
+ env_path.write_text("OPENAI_MODEL=test-model\n")
+ monkeypatch.setattr(environment, "_SCENE_ENGINE_ENV_PATH", env_path)
+
+ with pytest.raises(ValueError, match="OPENAI_API_KEY"):
+ environment.read_scene_engine_env_values("OPENAI_MODEL", "OPENAI_API_KEY")
+
+
+def test_scene_engine_help_exposes_only_runtime_arguments(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ with pytest.raises(SystemExit) as exc_info:
+ start.main(["--help"])
+
+ assert exc_info.value.code == 0
+ output = capsys.readouterr().out
+ assert "--image" in output
+ assert "--output_root" in output
+ assert "gen_sim/.env" in output
+ assert "--config" not in output
+
+
+def test_scene_engine_cli_forwards_validated_paths(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ image_path = tmp_path / "scene.png"
+ image_path.write_bytes(b"png")
+ captured: dict[str, Path] = {}
+
+ def generate_scene(*, image_path: Path, output_root: Path) -> None:
+ captured["image_path"] = image_path
+ captured["output_root"] = output_root
+
+ monkeypatch.setattr(start, "generate_scene_from_image", generate_scene)
+ output_root = tmp_path / "output"
+
+ start.cli_scene_engine(image_path, output_root)
+
+ assert captured == {
+ "image_path": image_path.resolve(),
+ "output_root": output_root.resolve(),
+ }
+
+
+@pytest.mark.parametrize("image_name", ["missing.png", "scene.gif"])
+def test_scene_engine_cli_rejects_invalid_image_inputs(
+ tmp_path: Path,
+ image_name: str,
+) -> None:
+ image_path = tmp_path / image_name
+ if image_path.suffix == ".gif":
+ image_path.write_bytes(b"gif")
+
+ with pytest.raises((FileNotFoundError, ValueError)):
+ start.cli_scene_engine(image_path, tmp_path / "output")