diff --git a/.gitignore b/.gitignore
index 69061763a..17b7d5c41 100644
--- a/.gitignore
+++ b/.gitignore
@@ -208,3 +208,9 @@ scripts/benchmark/rl/reports/*
.worktrees/
# Local gym project workspace
/gym_project/
+.gen_sim/
+
+# Local Gradio UI dependencies, generated Articraft records, and bytecode
+/embodichain/gen_sim/gradio_ui/.articraft/
+/embodichain/gen_sim/gradio_ui/.gen_sim/
+/embodichain/gen_sim/gradio_ui/__pycache__/
diff --git a/embodichain/gen_sim/.env.example b/embodichain/gen_sim/.env.example
new file mode 100644
index 000000000..ba46bef48
--- /dev/null
+++ b/embodichain/gen_sim/.env.example
@@ -0,0 +1,43 @@
+# 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="127.0.0.1"
+GRADIO_SERVER_PORT=7860
+# Both values are required when GRADIO_SERVER_NAME is not a loopback address.
+GRADIO_AUTH_USERNAME=""
+GRADIO_AUTH_PASSWORD=""
+SCENE_ENGINE_VISER_PORT=8080
+ARTICRAFT_VISER_PORT=8081
+ACTION_ENGINE_VISER_PORT=8082
+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..aee913a25
--- /dev/null
+++ b/embodichain/gen_sim/env.py
@@ -0,0 +1,108 @@
+# ----------------------------------------------------------------------------
+# 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 | None:
+ """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``.
+
+ Returns:
+ The configured or default dotenv path, or ``None`` when neither exists.
+ """
+ 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
+ return None
+
+
+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 env_path is None or 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..f1907845b
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_articraft.py
@@ -0,0 +1,1137 @@
+# ----------------------------------------------------------------------------
+# 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 socket
+import subprocess
+import sys
+import threading
+import time
+import uuid
+from collections.abc import Iterator
+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,
+ EMBODICHAIN_ROOT,
+ validate_gradio_artifact_root,
+)
+from app_processes import (
+ SessionProcessRegistry,
+ build_codex_env,
+ build_pipeline_env,
+ get_request_session_id,
+ kill_process_group,
+ read_process_output,
+ redact_sensitive_text,
+ register_managed_process,
+ start_pipeline,
+ terminate_process_group,
+)
+from embodichain.gen_sim.env import find_gen_sim_env_file
+
+__all__ = [
+ "build_articraft_panel",
+ "cleanup_articraft_session",
+ "configure_articraft_environment",
+ "generate_articraft_asset",
+ "reset_articraft_asset",
+ "stop_articraft_viser_preview",
+]
+
+_VISER_START_TIMEOUT_SECONDS = 15.0
+_ARTICRAFT_PYTHON_VERSION = "3.12"
+_CONDA_ENVIRONMENT_SETUP_TIMEOUT_SECONDS = 1_200
+_articraft_environment_lock = threading.Lock()
+_articraft_runs = SessionProcessRegistry()
+_ARTICRAFT_IDLE_PREVIEW = (
+ "
"
+ "The interactive Viser articulation preview will appear here after generation."
+ "
"
+)
+
+
+def _run_articraft_generation_check(
+ command: list[str], *, session_id: 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=build_pipeline_env(),
+ )
+ )
+ if not _articraft_runs.attach(session_id, token, process):
+ terminate_process_group(process)
+ return None
+ try:
+ stdout, _ = process.communicate(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ terminate_process_group(process)
+ raise
+ finally:
+ _articraft_runs.finish(session_id, token, process)
+ if not _articraft_runs.is_active(session_id, token):
+ return None
+ return subprocess.CompletedProcess(
+ command,
+ process.returncode,
+ redact_sensitive_text(stdout or ""),
+ )
+
+
+def reset_articraft_asset(request: gr.Request) -> tuple[Any, ...]:
+ """Clear Articraft state and stop only the requesting session's processes.
+
+ Args:
+ request: Gradio request for the browser session initiating Reset.
+
+ Returns:
+ Reset values for all Articraft panel widgets.
+ """
+ session_id = get_request_session_id(request)
+ cleanup_articraft_session(session_id)
+ return (
+ "**Environment:** not checked.",
+ "",
+ None,
+ None,
+ "",
+ "**Status:** waiting for a description.",
+ "",
+ _ARTICRAFT_IDLE_PREVIEW,
+ )
+
+
+def cleanup_articraft_session(session_id: str) -> None:
+ """Stop Articraft generation and preview processes for one session.
+
+ Args:
+ session_id: Stable Gradio session identifier.
+ """
+ _articraft_runs.reset(session_id, force=True)
+ stop_articraft_viser_preview(session_id, force=True)
+
+
+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] = []
+ isolation_error = _articraft_isolation_error()
+ if isolation_error:
+ errors.append(isolation_error)
+ elif 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 isolation_error := _articraft_isolation_error():
+ return False, isolation_error
+ 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 _articraft_isolation_error() -> str | None:
+ """Return an error when Codex roots could contain deployment secrets."""
+ checkout = ARTICRAFT_ROOT.expanduser().resolve()
+ repository = EMBODICHAIN_ROOT.resolve()
+ if checkout == repository or repository.is_relative_to(checkout):
+ return (
+ "ARTICRAFT_ROOT must be a dedicated nested or external Git checkout, "
+ "not the EmbodiChain repository or one of its parents."
+ )
+ env_path = find_gen_sim_env_file()
+ if env_path is not None and env_path.resolve().is_relative_to(checkout):
+ return "ARTICRAFT_ROOT must not contain the shared GenSim dotenv file."
+ try:
+ output_root = validate_gradio_artifact_root(ARTICRAFT_OUTPUT_ROOT)
+ except ValueError as exc:
+ return str(exc)
+ if env_path is not None and env_path.resolve().is_relative_to(output_root):
+ return "ARTICRAFT_OUTPUT_ROOT must not contain the shared GenSim dotenv file."
+ return None
+
+
+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, port: int) -> 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 an isolated Articraft Viser process for each Gradio session."""
+
+ def __init__(self, preferred_port: int) -> None:
+ self._preferred_port = preferred_port
+ self._lock = threading.Lock()
+ self._processes: dict[str, tuple[subprocess.Popen[str], int]] = {}
+
+ def start(self, session_id: str, urdf_path: Path, record_id: str) -> str:
+ """Replace one session's 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:
+ previous = self._processes.pop(session_id, None)
+ if previous is not None:
+ terminate_process_group(previous[0])
+ port = self._select_available_port()
+ process = start_pipeline(self._command(urdf_path, port))
+ if not self._wait_until_owned(process, port):
+ terminate_process_group(process)
+ raise RuntimeError("New Articraft Viser preview did not bind its port.")
+ self._processes[session_id] = (process, port)
+ return _articraft_viser_iframe(record_id, port)
+
+ def stop(self, session_id: str | None = None, *, force: bool = False) -> None:
+ """Stop one session's preview, or every preview during shutdown."""
+ with self._lock:
+ if session_id is None:
+ processes = tuple(
+ process for process, _port in self._processes.values()
+ )
+ self._processes.clear()
+ else:
+ current = self._processes.pop(session_id, None)
+ processes = () if current is None else (current[0],)
+ stop_process = kill_process_group if force else terminate_process_group
+ for process in processes:
+ stop_process(process)
+
+ def _command(self, urdf_path: Path, port: int) -> 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(port),
+ ]
+
+ def _wait_until_owned(self, process: subprocess.Popen[str], port: int) -> 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", port), timeout=0.2):
+ return True
+ except OSError:
+ pass
+ time.sleep(0.25)
+ return False
+
+ def _select_available_port(self) -> int:
+ if self._port_is_available(self._preferred_port):
+ return self._preferred_port
+ probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ probe.bind(("0.0.0.0", 0))
+ return int(probe.getsockname()[1])
+ finally:
+ probe.close()
+
+ @staticmethod
+ def _port_is_available(port: int) -> bool:
+ probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ probe.bind(("0.0.0.0", port))
+ except OSError:
+ return False
+ finally:
+ probe.close()
+ return True
+
+
+_articraft_viser_preview = _ArticraftViserPreview(ARTICRAFT_VISER_PORT)
+
+
+def stop_articraft_viser_preview(
+ session_id: str | None = None,
+ *,
+ force: bool = False,
+) -> 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.
+
+ Args:
+ session_id: Optional owning Gradio session. ``None`` stops all previews.
+ force: Whether to immediately send ``SIGKILL`` for interactive cleanup.
+ """
+ _articraft_viser_preview.stop(session_id, force=force)
+
+
+atexit.register(stop_articraft_viser_preview)
+
+
+def _start_articraft_viser_preview(
+ session_id: str, materialized: Path, record_id: str
+) -> str:
+ """Load the compiled URDF as an articulation and expose it through Viser."""
+ return _articraft_viser_preview.start(
+ session_id,
+ 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,
+ request: gr.Request,
+) -> Iterator[tuple[Any, ...]]:
+ """Initialize a record, let Codex author it, and expose one result bundle.
+
+ Args:
+ prompt_value: Requested articulated-object description.
+ image_value: Optional Gradio reference-image value.
+ request: Gradio request identifying the owning browser session.
+
+ Yields:
+ Updated artifact, status, log, and Viser preview values for the panel.
+ """
+ session_id = get_request_session_id(request)
+ token = _articraft_runs.begin(session_id)
+ prompt = (prompt_value or "").strip()
+ if not prompt:
+ if _articraft_runs.is_active(session_id, 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_runs.is_active(session_id, 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,
+ session_id=session_id,
+ 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_runs.is_active(session_id, token):
+ yield None, "", f"**Setup failed:** {exc}", "\n".join(log_lines), ""
+ return
+
+ if not _articraft_runs.is_active(session_id, token):
+ return
+
+ final_message = run_root / "codex_final_message.txt"
+ codex_command = [
+ codex,
+ "exec",
+ "--sandbox",
+ "workspace-write",
+ "--ephemeral",
+ "--ignore-user-config",
+ "--ignore-rules",
+ "-c",
+ 'web_search="disabled"',
+ "--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=build_codex_env(),
+ )
+ )
+ if not _articraft_runs.attach(session_id, token, process):
+ terminate_process_group(process)
+ return
+ except Exception as exc:
+ if _articraft_runs.is_active(session_id, 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),
+ kwargs={"redact_sensitive": True},
+ daemon=True,
+ )
+ reader.start()
+ while process.poll() is None:
+ if not _articraft_runs.is_active(session_id, 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:
+ _articraft_runs.finish(session_id, token, process)
+
+ if not _articraft_runs.is_active(session_id, 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" + redact_sensitive_text(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,
+ session_id=session_id,
+ 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,
+ session_id=session_id,
+ 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,
+ session_id=session_id,
+ 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_runs.is_active(session_id, 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(
+ session_id,
+ 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..b3bd2a7be
--- /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 the engine workspace.
+
+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 sys
+import time
+import uuid
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any, Iterable
+
+import gradio as gr
+import trimesh
+
+from app_articraft import build_articraft_panel, cleanup_articraft_session
+from app_config import GEN_SIM_ASSET_ROOT, SIMREADY_MESH_SUFFIXES
+from app_processes import (
+ SessionProcessRegistry,
+ get_request_session_id,
+ read_process_output,
+ start_pipeline,
+ terminate_process_group,
+)
+
+__all__ = [
+ "build_asset_engine_panel",
+ "cleanup_asset_engine_session",
+ "prepare_asset_input_preview",
+ "reset_simready_asset",
+ "run_simready_asset",
+]
+
+_simready_runs = SessionProcessRegistry()
+_SIMREADY_IDLE_STATUS = "**Status:** waiting for an asset."
+
+
+def reset_simready_asset(
+ request: gr.Request,
+) -> tuple[None, str, None, None, None, str, str]:
+ """Clear SimReady widgets and stop only the requesting session's run.
+
+ Args:
+ request: Gradio request for the browser session initiating Reset.
+
+ Returns:
+ Reset values for the SimReady panel widgets.
+ """
+ _simready_runs.reset(get_request_session_id(request), force=True)
+ return None, "rigid_object", None, None, None, _SIMREADY_IDLE_STATUS, ""
+
+
+def cleanup_asset_engine_session(request: gr.Request) -> None:
+ """Stop Asset-engine subprocesses owned by a disconnected session.
+
+ Args:
+ request: Gradio request for the disconnecting browser session.
+ """
+ session_id = get_request_session_id(request)
+ _simready_runs.reset(session_id, force=True)
+ cleanup_articraft_session(session_id)
+
+
+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 = GEN_SIM_ASSET_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,
+ request: gr.Request,
+) -> Iterator[tuple[Any, ...]]:
+ """Run one upstream SimReady job and stream concise subprocess progress.
+
+ Args:
+ upload_value: Gradio upload value containing the asset and sidecars.
+ category: SimReady asset category.
+ request: Gradio request identifying the owning browser session.
+
+ Yields:
+ Updated preview, output, status, and log values for the panel.
+ """
+ session_id = get_request_session_id(request)
+ token = _simready_runs.begin(session_id)
+ category = (category or "").strip()
+ if not category:
+ if _simready_runs.is_active(session_id, token):
+ yield None, None, None, "**Input error:** enter an asset category.", ""
+ return
+ try:
+ uploads = _as_paths(upload_value)
+ _mesh_path(uploads)
+ run_root = GEN_SIM_ASSET_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_runs.is_active(session_id, 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_runs.is_active(session_id, token):
+ return
+ yield input_preview.as_posix(), None, None, "**SimReady is running…**", "\n".join(
+ log_lines
+ )
+
+ try:
+ process = start_pipeline(command, use_simready_llm=True)
+ except Exception as exc:
+ if _simready_runs.is_active(session_id, token):
+ yield input_preview.as_posix(), None, None, f"**Pipeline start failed:** {exc}", "\n".join(
+ log_lines
+ )
+ return
+
+ if not _simready_runs.attach(session_id, token, 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_runs.is_active(session_id, 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_runs.is_active(session_id, 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:
+ _simready_runs.finish(session_id, token, process)
+
+
+def build_asset_engine_panel() -> dict[str, Any]:
+ """Create the 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..1c14861b2
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_commands.py
@@ -0,0 +1,67 @@
+# ----------------------------------------------------------------------------
+# 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 builder for the Action engine."""
+
+from __future__ import annotations
+
+import sys
+
+from app_config import (
+ AGENT_CONFIG,
+ COMMANDS,
+ FAST_GYM_CONFIG,
+ ROBOT_PROFILE_FRANKA,
+ ROBOT_PROFILE_UR5,
+ ROBOT_PROFILE_UR10,
+ SCENE_ID,
+)
+
+__all__ = ["build_run_agent_command"]
+
+
+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 build_run_agent_command(
+ *,
+ robot_profile: str | None = None,
+ supports_robot_profile: bool = False,
+) -> list[str]:
+ """Build the DexSim command for the existing ``current`` Gym scene."""
+ agent = COMMANDS["agent"]
+ command = [
+ sys.executable,
+ "-m",
+ agent["module"],
+ "--task_name",
+ SCENE_ID,
+ "--gym_config",
+ str(FAST_GYM_CONFIG),
+ "--agent_config",
+ str(AGENT_CONFIG),
+ *agent["base_args"],
+ "--num_envs",
+ agent["single_num_envs"],
+ ]
+ 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..06f696e01
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_config.py
@@ -0,0 +1,98 @@
+# ----------------------------------------------------------------------------
+# 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 for the engine-only Gradio application."""
+
+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"
+GEN_SIM_ROOT = APP_ROOT / ".gen_sim"
+GEN_SIM_ASSET_ROOT = GEN_SIM_ROOT / "assets"
+GEN_SIM_SCENE_ROOT = GEN_SIM_ROOT / "scenes"
+
+SCENE_ID = "current"
+GYM_PROJECT_ROOT = app_env.EMBODICHAIN_ROOT / "gym_project"
+ACTION_AGENT_ROOT = GYM_PROJECT_ROOT / "action_agent_pipeline"
+CONFIG_DIR = ACTION_AGENT_ROOT / "configs" / SCENE_ID
+FAST_GYM_CONFIG = CONFIG_DIR / "fast_gym_config.json"
+AGENT_CONFIG = CONFIG_DIR / "agent_config.json"
+
+OUTPUTS_DIR = app_env.EMBODICHAIN_ROOT / "outputs"
+VIDEO_SUFFIXES = {".mp4", ".avi", ".mov", ".mkv", ".webm"}
+
+PROCESS_STOP_TIMEOUT_S = 8.0
+DEFAULT_CONCURRENCY_LIMIT = 1
+
+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_MESH_SUFFIXES = {".glb", ".gltf", ".obj", ".ply", ".stl"}
+
+LANGUAGE_EN = "en"
+UI_TEXT = {
+ LANGUAGE_EN: {
+ "robot": "Robot",
+ "input_image": "Input image",
+ "single_video_preview": "DexSim Video Preview",
+ "current_task": "Current task",
+ "progress": "Progress",
+ }
+}
+
+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
+
+COMMANDS = {
+ "agent": {
+ "module": "embodichain.gen_sim.action_agent_pipeline.cli.run_agent",
+ "help_args": ("--help",),
+ "base_args": ("--regenerate", "--renderer", "fast-rt"),
+ "single_num_envs": "1",
+ },
+ "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, "Scene generation started"),
+ "scene_intake": (20, "Scene understanding"),
+ "relations": (35, "Scene segmentation"),
+ "asset_generation": (55, "Geometry generation"),
+ "gym_export": (75, "Scene export"),
+ "preview": (90, "Preview generation"),
+ "complete": (100, "Complete"),
+ "failed": (100, "Failed"),
+}
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..cbba7de1f
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_env.py
@@ -0,0 +1,216 @@
+# ----------------------------------------------------------------------------
+# 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__ = [
+ "ACTION_ENGINE_VISER_PORT",
+ "ARTICRAFT_CONDA_ENV",
+ "ARTICRAFT_OUTPUT_ROOT",
+ "ARTICRAFT_REPOSITORY_URL",
+ "ARTICRAFT_ROOT",
+ "ARTICRAFT_VISER_PORT",
+ "DIRECT_NO_PROXY_VALUE",
+ "EMBODICHAIN_ROOT",
+ "GRADIO_AUTH_PASSWORD",
+ "GRADIO_AUTH_USERNAME",
+ "PROXY_ENV_KEYS",
+ "SCENE_ENGINE_VISER_PORT",
+ "SERVER_NAME",
+ "SERVER_PORT",
+ "SIMREADY_OPENAI_API_KEY",
+ "SIMREADY_OPENAI_BASE_URL",
+ "SIMREADY_OPENAI_MODEL",
+ "build_gradio_allowed_paths",
+ "build_gradio_blocked_paths",
+ "configure_direct_network_env",
+ "configure_simready_llm_env",
+ "get_gradio_auth",
+ "validate_gradio_artifact_root",
+]
+
+load_gen_sim_env()
+
+APP_ROOT = Path(__file__).resolve().parent
+GEN_SIM_ROOT = APP_ROOT / ".gen_sim"
+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(GEN_SIM_ROOT / "articraft"))
+).expanduser()
+SCENE_ENGINE_VISER_PORT = int(_getenv("SCENE_ENGINE_VISER_PORT", "8080"))
+ARTICRAFT_VISER_PORT = int(_getenv("ARTICRAFT_VISER_PORT", "8081"))
+ACTION_ENGINE_VISER_PORT = int(_getenv("ACTION_ENGINE_VISER_PORT", "8082"))
+SERVER_NAME = _getenv("GRADIO_SERVER_NAME", "127.0.0.1")
+SERVER_PORT = int(_getenv("GRADIO_SERVER_PORT", "7860"))
+GRADIO_AUTH_USERNAME = _getenv("GRADIO_AUTH_USERNAME", "")
+GRADIO_AUTH_PASSWORD = _getenv("GRADIO_AUTH_PASSWORD", "")
+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
+
+
+def get_gradio_auth(
+ server_name: str = SERVER_NAME,
+ username: str = GRADIO_AUTH_USERNAME,
+ password: str = GRADIO_AUTH_PASSWORD,
+) -> tuple[str, str] | None:
+ """Validate deployment exposure and return Gradio credentials.
+
+ Args:
+ server_name: Interface address used by the Gradio server.
+ username: Optional HTTP basic-auth username.
+ password: Optional HTTP basic-auth password.
+
+ Returns:
+ A ``(username, password)`` tuple, or ``None`` for a local-only server.
+
+ Raises:
+ ValueError: If credentials are incomplete or a non-loopback server has
+ no authentication configured.
+ """
+ has_username = bool(username)
+ has_password = bool(password)
+ if has_username != has_password:
+ raise ValueError(
+ "Set both GRADIO_AUTH_USERNAME and GRADIO_AUTH_PASSWORD, or neither."
+ )
+ if has_username and has_password:
+ return username, password
+ if server_name.strip().lower() not in {"127.0.0.1", "localhost", "::1"}:
+ raise ValueError(
+ "A non-loopback GRADIO_SERVER_NAME requires Gradio authentication."
+ )
+ return None
+
+
+def build_gradio_allowed_paths(*roots: Path) -> list[str]:
+ """Resolve the explicit static and generated roots Gradio may serve.
+
+ Args:
+ *roots: Static-resource or generated-artifact directories.
+
+ Returns:
+ Sorted, de-duplicated absolute path strings.
+ """
+ return sorted({str(path.expanduser().resolve()) for path in roots})
+
+
+def build_gradio_blocked_paths(env_path: Path | None) -> list[str]:
+ """Resolve repository metadata and dotenv paths Gradio must never serve.
+
+ Args:
+ env_path: Active shared dotenv path, if one exists.
+
+ Returns:
+ Sorted, de-duplicated absolute path strings.
+ """
+ blocked = {
+ EMBODICHAIN_ROOT / ".env",
+ EMBODICHAIN_ROOT / ".git",
+ EMBODICHAIN_ROOT / "embodichain" / "gen_sim" / ".env",
+ }
+ if env_path is not None:
+ blocked.add(env_path)
+ return build_gradio_allowed_paths(*blocked)
+
+
+def validate_gradio_artifact_root(root: Path) -> Path:
+ """Reject an artifact setting broad enough to expose the repository.
+
+ Args:
+ root: Configured directory containing generated artifacts.
+
+ Returns:
+ The normalized artifact directory.
+
+ Raises:
+ ValueError: If the directory is the repository or one of its ancestors.
+ """
+ resolved_root = root.expanduser().resolve()
+ repository = EMBODICHAIN_ROOT.resolve()
+ if resolved_root == repository or repository.is_relative_to(resolved_root):
+ raise ValueError(
+ "ARTICRAFT_OUTPUT_ROOT must be a dedicated artifact directory, not "
+ "the EmbodiChain repository or one of its parents."
+ )
+ return resolved_root
+
+
+# Gradio imports its HTTP client during application module loading. Remove the
+# same unsupported or credential-bearing proxies that child pipelines exclude
+# before importing any view module.
+configure_direct_network_env()
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..417498de7
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_media.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.
+# ----------------------------------------------------------------------------
+
+"""Media helpers used by the Action and Articulation engines."""
+
+from __future__ import annotations
+
+import argparse
+from collections.abc import Sequence
+from pathlib import Path
+
+from app_config import OUTPUTS_DIR, VIDEO_SUFFIXES
+
+__all__ = [
+ "articraft_viser_preview_cli",
+ "latest_audience_output_video",
+ "run_articraft_viser_preview",
+]
+
+
+def _collect_audience_output_videos() -> list[Path]:
+ if not OUTPUTS_DIR.is_dir():
+ return []
+ videos = [
+ path
+ for path in OUTPUTS_DIR.rglob("*")
+ if path.is_file() and path.suffix.lower() in VIDEO_SUFFIXES
+ ]
+ audience_videos = [
+ path
+ for path in videos
+ if "audience" in path.relative_to(OUTPUTS_DIR).as_posix().lower()
+ ]
+ return audience_videos or videos
+
+
+def latest_audience_output_video(min_mtime_ns: int | None = None) -> Path | None:
+ """Return the newest DexSim video created after the requested time."""
+ 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 run_articraft_viser_preview(args: argparse.Namespace) -> None:
+ """Load an Articraft URDF and publish its initial topology to Viser."""
+ 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."""
+ 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..3744fb219
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_processes.py
@@ -0,0 +1,555 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Subprocess execution and lifecycle management for the engine workspace."""
+
+from __future__ import annotations
+
+import os
+import queue
+import signal
+import subprocess
+import sys
+import threading
+import time
+import uuid
+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,
+)
+
+__all__ = [
+ "SessionProcessRegistry",
+ "build_codex_env",
+ "build_pipeline_env",
+ "build_run_agent_command",
+ "force_stop_all_child_processes",
+ "get_request_session_id",
+ "kill_process_group",
+ "read_process_output",
+ "register_managed_process",
+ "run_agent_cli_supports_robot_profile",
+ "start_pipeline",
+ "terminate_process_group",
+ "redact_sensitive_text",
+]
+
+_RUN_AGENT_SUPPORTS_ROBOT_PROFILE: bool | None = None
+_managed_processes: dict[int, subprocess.Popen[str]] = {}
+_managed_processes_lock = threading.Lock()
+_shutdown_requested = False
+_PROCESS_KILL_REAP_TIMEOUT_S = 0.1
+_CODEX_ENV_ALLOWLIST = {
+ "CODEX_HOME",
+ "COLORTERM",
+ "HOME",
+ "LANG",
+ "LC_ALL",
+ "LC_CTYPE",
+ "LOGNAME",
+ "PATH",
+ "REQUESTS_CA_BUNDLE",
+ "SHELL",
+ "SSL_CERT_DIR",
+ "SSL_CERT_FILE",
+ "TEMP",
+ "TERM",
+ "TMP",
+ "TMPDIR",
+ "USER",
+ "XDG_CACHE_HOME",
+ "XDG_CONFIG_HOME",
+ "XDG_DATA_HOME",
+}
+_SENSITIVE_ENV_MARKERS = (
+ "API_KEY",
+ "CREDENTIAL",
+ "PASSWORD",
+ "SECRET",
+ "TOKEN",
+)
+
+
+def get_request_session_id(request: object) -> str:
+ """Return the stable session identifier supplied by Gradio.
+
+ Args:
+ request: Gradio request object injected into an event callback.
+
+ Returns:
+ The non-empty Gradio session hash.
+
+ Raises:
+ RuntimeError: If the callback was invoked without a session hash.
+ """
+ session_id = getattr(request, "session_hash", None)
+ if not isinstance(session_id, str) or not session_id:
+ raise RuntimeError("This operation requires an active Gradio session.")
+ return session_id
+
+
+class SessionProcessRegistry:
+ """Track one replaceable subprocess for each Gradio session.
+
+ A registry instance belongs to one workflow, such as SimReady or Articraft.
+ Resetting one session can therefore never invalidate or terminate another
+ session's run.
+ """
+
+ def __init__(self) -> None:
+ self._lock = threading.Lock()
+ self._runs: dict[str, tuple[str, subprocess.Popen[str] | None]] = {}
+
+ def begin(self, session_id: str) -> str:
+ """Start a new logical run for one session.
+
+ Args:
+ session_id: Stable Gradio session identifier.
+
+ Returns:
+ A new ownership token for the run.
+ """
+ token = uuid.uuid4().hex
+ with self._lock:
+ previous = self._runs.get(session_id)
+ self._runs[session_id] = (token, None)
+ if previous is not None and previous[1] is not None:
+ terminate_process_group(previous[1])
+ return token
+
+ def is_active(
+ self,
+ session_id: str,
+ token: str,
+ process: subprocess.Popen[str] | None = None,
+ ) -> bool:
+ """Return whether a run still owns its session slot.
+
+ Args:
+ session_id: Stable Gradio session identifier.
+ token: Token returned by :meth:`begin`.
+ process: Optional process that must also match the registered child.
+
+ Returns:
+ ``True`` when the token and optional process still match.
+ """
+ with self._lock:
+ current = self._runs.get(session_id)
+ return (
+ current is not None
+ and current[0] == token
+ and (process is None or current[1] is process)
+ )
+
+ def attach(
+ self,
+ session_id: str,
+ token: str,
+ process: subprocess.Popen[str],
+ ) -> bool:
+ """Attach a subprocess to an active session run.
+
+ Args:
+ session_id: Stable Gradio session identifier.
+ token: Token returned by :meth:`begin`.
+ process: Managed subprocess started for the run.
+
+ Returns:
+ ``True`` if the token still owns the session slot.
+ """
+ with self._lock:
+ current = self._runs.get(session_id)
+ if current is None or current[0] != token:
+ return False
+ self._runs[session_id] = (token, process)
+ return True
+
+ def finish(
+ self,
+ session_id: str,
+ token: str,
+ process: subprocess.Popen[str],
+ ) -> None:
+ """Clear a finished subprocess while keeping its logical run active.
+
+ Args:
+ session_id: Stable Gradio session identifier.
+ token: Token returned by :meth:`begin`.
+ process: Subprocess that has finished.
+ """
+ with self._lock:
+ current = self._runs.get(session_id)
+ if current == (token, process):
+ self._runs[session_id] = (token, None)
+
+ def reset(self, session_id: str, *, force: bool = False) -> None:
+ """Invalidate and stop only one session's process.
+
+ Args:
+ session_id: Stable Gradio session identifier.
+ force: Whether to send ``SIGKILL`` immediately instead of allowing
+ a graceful shutdown period.
+ """
+ with self._lock:
+ current = self._runs.pop(session_id, None)
+ if current is not None and current[1] is not None:
+ stop_process = kill_process_group if force else terminate_process_group
+ stop_process(current[1])
+
+ def reset_all(self) -> None:
+ """Invalidate and terminate every process tracked by this registry."""
+ with self._lock:
+ runs = tuple(self._runs.values())
+ self._runs.clear()
+ for _token, process in runs:
+ if process is not None:
+ terminate_process_group(process)
+
+
+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(*, robot_profile: str | None = None) -> list[str]:
+ """Build the Action-engine command for the existing current scene."""
+ from app_commands import build_run_agent_command as build_command
+
+ return build_command(
+ robot_profile=robot_profile,
+ supports_robot_profile=run_agent_cli_supports_robot_profile(),
+ )
+
+
+def start_pipeline(
+ command: list[str], *, use_simready_llm: bool = False
+) -> subprocess.Popen[str]:
+ """Start a managed pipeline subprocess with its scoped dotenv settings.
+
+ Args:
+ command: Command and arguments to execute.
+ use_simready_llm: Whether to map the dotenv ``SIMREADY_OPENAI_*`` values
+ to the upstream SimReady CLI's ``OPENAI_*`` variable names.
+
+ Returns:
+ The registered subprocess.
+ """
+ env = build_pipeline_env(use_simready_llm=use_simready_llm)
+ 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(*, use_simready_llm: bool = False) -> dict[str, str]:
+ """Build a child environment from the shared GenSim dotenv configuration.
+
+ Args:
+ use_simready_llm: Whether to apply the SimReady-specific LLM mapping.
+
+ Returns:
+ A copy of the loaded process environment configured for the child.
+ """
+ env = os.environ.copy()
+ configure_direct_network_env(env)
+ if use_simready_llm:
+ configure_simready_llm_env(env)
+ return env
+
+
+def build_codex_env() -> dict[str, str]:
+ """Build a credential-minimized environment for user-directed Codex runs.
+
+ The Codex CLI may still use its own login state through ``CODEX_HOME`` or
+ the normal user configuration directory, but GenSim service credentials
+ and dotenv-specific settings are not inherited by the command sandbox.
+
+ Returns:
+ An allowlisted child-process environment.
+
+ .. attention::
+ Deployments that authenticate Codex exclusively through
+ ``OPENAI_API_KEY`` must use ``codex login`` or another isolated Codex
+ credential store instead. Passing the server key to a user-directed
+ process would recreate the disclosure boundary this function removes.
+ """
+ return {
+ key: value
+ for key, value in os.environ.items()
+ if key in _CODEX_ENV_ALLOWLIST and value
+ }
+
+
+def redact_sensitive_text(text: str) -> str:
+ """Replace known environment credential values in UI-bound output.
+
+ Args:
+ text: Subprocess output or a final message that may contain credentials.
+
+ Returns:
+ Text with non-trivial sensitive environment values replaced.
+ """
+ redacted = text
+ for key, value in os.environ.items():
+ upper_key = key.upper()
+ if (
+ value
+ and len(value) >= 4
+ and any(marker in upper_key for marker in _SENSITIVE_ENV_MARKERS)
+ ):
+ redacted = redacted.replace(value, "[REDACTED]")
+ return redacted
+
+
+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:
+ """Gracefully stop a process group, escalating after the shutdown timeout."""
+ 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 kill_process_group(process: subprocess.Popen[str]) -> None:
+ """Immediately send ``SIGKILL`` to a UI-owned subprocess group.
+
+ This path is intended for interactive Reset and Stop actions whose contract
+ is to discard the active run. Application shutdown continues to use
+ :func:`terminate_process_group` so child processes retain a graceful cleanup
+ window.
+
+ Args:
+ process: Group-leading subprocess created with ``start_new_session``.
+ """
+ try:
+ if process.poll() is not None:
+ return
+ try:
+ os.killpg(process.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ return
+ except Exception:
+ process.kill()
+ try:
+ process.wait(timeout=_PROCESS_KILL_REAP_TIMEOUT_S)
+ except subprocess.TimeoutExpired:
+ # SIGKILL has already been delivered. Do not hold the UI callback
+ # open for an uninterruptible process; its monitor can reap it.
+ pass
+ finally:
+ _unregister_managed_process(process)
+
+
+def read_process_output(
+ process: subprocess.Popen[str],
+ output_queue: queue.Queue[str],
+ log_path: Path | None = None,
+ *,
+ redact_sensitive: bool = False,
+) -> None:
+ """Forward merged subprocess output to the UI queue and an optional log.
+
+ Args:
+ process: Child process whose merged stdout should be consumed.
+ output_queue: Destination for individual output lines.
+ log_path: Optional file receiving the same output.
+ redact_sensitive: Whether to redact known environment credentials before
+ forwarding or persisting each line.
+ """
+ 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_line = redact_sensitive_text(line) if redact_sensitive else line
+ output_queue.put(output_line.rstrip())
+ if log_file is not None:
+ log_file.write(output_line)
+ if not output_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..a3956eb4a
--- /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_app
+
+__all__ = ["build_app"]
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..d16c68b05
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_state.py
@@ -0,0 +1,99 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Per-session runtime state for the Scene and Action engines."""
+
+from __future__ import annotations
+
+import threading
+from collections import deque
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from app_config import PHASE_DEFINITIONS
+
+__all__ = [
+ "PHASES",
+ "Phase",
+ "RuntimeState",
+ "SessionRuntimeRegistry",
+ "runtime_lock",
+ "runtime_registry",
+ "set_runtime_phase_locked",
+]
+
+
+@dataclass(frozen=True)
+class Phase:
+ progress: int
+ label: str
+
+
+PHASES = {key: Phase(*value) for key, value in PHASE_DEFINITIONS.items()}
+
+
+@dataclass
+class RuntimeState:
+ """Mutable Scene/Action UI state owned by one Gradio session."""
+
+ is_busy: bool = False
+ scene_engine_is_running: bool = False
+ phase_key: str = "idle"
+ status: str = "Idle."
+ task_text: str = ""
+ image_path: Path | None = None
+ video_path: Path | None = None
+ last_sent_video_signature: tuple[str, int] | None = None
+ last_error: str | None = None
+ log_lines: deque[str] = field(default_factory=deque)
+
+
+class SessionRuntimeRegistry:
+ """Own one Scene/Action UI runtime for each Gradio session hash."""
+
+ def __init__(self) -> None:
+ self._lock = threading.Lock()
+ self._states: dict[str, RuntimeState] = {}
+
+ def get(self, session_id: str) -> RuntimeState:
+ """Return the existing state for a session, creating it when absent.
+
+ Args:
+ session_id: Stable Gradio session identifier.
+
+ Returns:
+ Runtime state owned exclusively by ``session_id``.
+ """
+ with self._lock:
+ return self._states.setdefault(session_id, RuntimeState())
+
+ def reset(self, session_id: str) -> None:
+ """Discard only one session's UI runtime state.
+
+ Args:
+ session_id: Stable Gradio session identifier.
+ """
+ with self._lock:
+ self._states.pop(session_id, None)
+
+
+runtime_registry = SessionRuntimeRegistry()
+runtime_lock = threading.Lock()
+
+
+def set_runtime_phase_locked(runtime: RuntimeState, new_phase_key: str) -> None:
+ """Set the current UI phase while the caller holds ``runtime_lock``."""
+ runtime.phase_key = new_phase_key
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..2869bc46a
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_ui.py
@@ -0,0 +1,300 @@
+# ----------------------------------------------------------------------------
+# 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 for the engine workspace.
+
+The workflow layer supplies all callbacks; this module only owns presentation
+and wires components to those callbacks.
+"""
+
+from __future__ import annotations
+
+import gradio as gr
+
+from app_asset_engine import build_asset_engine_panel, cleanup_asset_engine_session
+from app_config import (
+ DEBUG_ENGINE_ACTION,
+ DEBUG_ENGINE_ASSET,
+ DEBUG_ENGINE_SCENE,
+ DEBUG_ENGINES,
+ DEFAULT_ROBOT_PROFILE,
+ DEXFORCE_LOGO,
+ LANGUAGE_EN,
+ ROBOT_PROFILES,
+ UI_TEXT,
+)
+from app_processes import get_request_session_id
+from app_workflows import (
+ cleanup_workflow_session,
+ format_status,
+ preview_saved_scene,
+ refresh_saved_scenes,
+ reset_scene_engine,
+ run_action_engine_from_current,
+ run_scene_engine,
+ stop_action_engine,
+ ui_snapshot,
+)
+
+__all__ = ["build_app"]
+
+
+def select_engine(selected_engine: str):
+ """Show the selected engine panel without starting a 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(request: gr.Request) -> tuple[object, ...]:
+ """Adapt this session's runtime snapshot to the Action status widgets."""
+ session_id = get_request_session_id(request)
+ video, task, progress, status, _initial, _edited, _objects = ui_snapshot(session_id)
+ return video, task, progress, status
+
+
+def run_action_engine_panel(
+ task_text: str,
+ robot_profile: str | None,
+ request: gr.Request,
+) -> tuple[object, ...]:
+ """Run the Action engine and return its latest UI snapshot."""
+ video, task, progress, status, _initial, _edited, _objects = (
+ run_action_engine_from_current(task_text, robot_profile, request)
+ )
+ return video, task, progress, status
+
+
+def cleanup_app_session(request: gr.Request) -> None:
+ """Stop every engine process owned by a disconnected Gradio session."""
+ cleanup_workflow_session(request)
+ cleanup_asset_engine_session(request)
+
+
+def build_app() -> gr.Blocks:
+ """Build the engine-only Gradio application."""
+ with gr.Blocks(title="EmbodiChain Gradio") as app:
+ if DEXFORCE_LOGO.is_file():
+ with gr.Row(equal_height=True):
+ gr.Image(
+ value=str(DEXFORCE_LOGO),
+ show_label=False,
+ container=False,
+ height=58,
+ width=183,
+ )
+
+ with gr.Row():
+ 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")
+
+ 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):
+ scene_image = gr.Image(
+ label=UI_TEXT[LANGUAGE_EN]["input_image"],
+ sources=["upload", "webcam"],
+ type="filepath",
+ format="png",
+ height=300,
+ )
+ with gr.Row():
+ scene_run = gr.Button("Generate scene", variant="primary")
+ scene_reset = gr.Button("Reset Scene Engine", variant="stop")
+ with gr.Column(scale=2):
+ scene_progress = gr.Slider(
+ 0,
+ 100,
+ value=0,
+ step=1,
+ label=UI_TEXT[LANGUAGE_EN]["progress"],
+ interactive=False,
+ )
+ scene_status = gr.Markdown(format_status("Idle."))
+ scene_output = gr.Textbox(
+ label="Scene output directory (hash-named)",
+ interactive=False,
+ )
+ 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\n"
+ "Select a generated Scene Engine export to inspect it in Viser. "
+ "Scene selection is currently independent from DexSim execution."
+ )
+ with gr.Row():
+ with gr.Column(scale=1):
+ action_scene_list = gr.Dropdown(
+ choices=[],
+ value=None,
+ label="Generated scenes",
+ info="Complete scenes stored under .gen_sim/scenes.",
+ )
+ action_scene_refresh = gr.Button("Refresh scenes")
+ action_scene_status = gr.Markdown(
+ "**Scene list:** open Action engine or refresh to load scenes."
+ )
+ action_task = gr.Textbox(
+ label="Task description",
+ placeholder="e.g. Put the bottle on the table",
+ )
+ action_robot = gr.Radio(
+ choices=ROBOT_PROFILES,
+ value=DEFAULT_ROBOT_PROFILE,
+ label=UI_TEXT[LANGUAGE_EN]["robot"],
+ )
+ with gr.Row():
+ action_run = gr.Button("Run DexSim", variant="primary")
+ action_stop = gr.Button(
+ "Stop Action Engine",
+ variant="stop",
+ )
+ with gr.Column(scale=2):
+ action_scene = gr.HTML(
+ ""
+ "Select a generated scene to preview it."
+ "
"
+ )
+ action_video = gr.Video(
+ label=UI_TEXT[LANGUAGE_EN]["single_video_preview"],
+ height=320,
+ autoplay=True,
+ loop=True,
+ )
+ action_current_task = gr.Textbox(
+ label=UI_TEXT[LANGUAGE_EN]["current_task"],
+ interactive=False,
+ )
+ action_progress = gr.Slider(
+ 0,
+ 100,
+ value=0,
+ step=1,
+ label=UI_TEXT[LANGUAGE_EN]["progress"],
+ interactive=False,
+ )
+ action_status = gr.Markdown(
+ format_status("Load or generate a scene first.")
+ )
+ action_refresh_timer = gr.Timer(2.0)
+
+ for engine, button in zip(
+ (engine for engine, _label in DEBUG_ENGINES),
+ (asset_engine_button, scene_engine_button, action_engine_button),
+ ):
+ button.click(
+ select_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,
+ )
+
+ action_engine_button.click(
+ refresh_saved_scenes,
+ inputs=[action_scene_list],
+ outputs=[action_scene_list, action_scene_status],
+ queue=False,
+ )
+
+ scene_run.click(
+ run_scene_engine,
+ inputs=[scene_image],
+ outputs=[scene_progress, scene_status, scene_output, scene_preview],
+ )
+ scene_reset.click(
+ reset_scene_engine,
+ outputs=[
+ scene_image,
+ scene_progress,
+ scene_status,
+ scene_output,
+ scene_preview,
+ ],
+ queue=False,
+ )
+ action_scene_refresh.click(
+ refresh_saved_scenes,
+ inputs=[action_scene_list],
+ outputs=[action_scene_list, action_scene_status],
+ queue=False,
+ )
+ action_scene_list.change(
+ preview_saved_scene,
+ inputs=[action_scene_list],
+ outputs=[action_scene, action_scene_status],
+ )
+ action_run.click(
+ run_action_engine_panel,
+ inputs=[action_task, action_robot],
+ outputs=[
+ action_video,
+ action_current_task,
+ action_progress,
+ action_status,
+ ],
+ )
+ action_stop.click(
+ stop_action_engine,
+ outputs=[
+ action_scene,
+ action_scene_status,
+ action_video,
+ action_current_task,
+ action_progress,
+ action_status,
+ ],
+ queue=False,
+ )
+ action_refresh_timer.tick(
+ action_engine_snapshot,
+ outputs=[
+ action_video,
+ action_current_task,
+ action_progress,
+ action_status,
+ ],
+ queue=False,
+ )
+
+ app.unload(cleanup_app_session)
+
+ return app
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..70df77e40
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/app_workflows.py
@@ -0,0 +1,905 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Scene- and Action-engine workflows for the Gradio workspace."""
+
+from __future__ import annotations
+
+import hashlib
+import html
+import importlib.util
+import io
+import json
+import queue
+import shutil
+import socket
+import subprocess
+import sys
+import threading
+import time
+from collections.abc import Iterator
+from pathlib import Path
+
+import gradio as gr
+import numpy as np
+from PIL import Image, ImageOps
+
+from app_config import (
+ AGENT_CONFIG,
+ COMMANDS,
+ GEN_SIM_ROOT,
+ GEN_SIM_SCENE_ROOT,
+ FAST_GYM_CONFIG,
+)
+from app_env import (
+ ACTION_ENGINE_VISER_PORT,
+ SCENE_ENGINE_VISER_PORT,
+ configure_direct_network_env,
+)
+from app_media import latest_audience_output_video
+from app_processes import (
+ SessionProcessRegistry,
+ build_run_agent_command,
+ get_request_session_id,
+ read_process_output,
+ start_pipeline,
+ terminate_process_group,
+)
+from app_state import (
+ PHASES,
+ Phase,
+ RuntimeState,
+ runtime_lock,
+ runtime_registry,
+ set_runtime_phase_locked,
+)
+
+__all__ = [
+ "cleanup_workflow_session",
+ "format_status",
+ "preview_saved_scene",
+ "refresh_saved_scenes",
+ "reset_scene_engine",
+ "run_action_engine_from_current",
+ "run_scene_engine",
+ "stop_action_engine",
+ "ui_snapshot",
+]
+
+configure_direct_network_env()
+
+_scene_runs = SessionProcessRegistry()
+_action_runs = SessionProcessRegistry()
+_action_preview_runs = SessionProcessRegistry()
+_preview_start_lock = threading.Lock()
+
+_ACTION_IDLE_PREVIEW = (
+ ""
+ "Select a generated scene to preview it."
+ "
"
+)
+
+
+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 _scene_engine_phase_from_log(line: str, current_key: str) -> str:
+ """Map Scene Engine 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(
+ runtime: RuntimeState,
+ 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 = GEN_SIM_SCENE_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:
+ 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 _select_available_port(preferred_port: int) -> int:
+ """Return the preferred Viser port, or an ephemeral port when occupied."""
+ for port in (preferred_port, 0):
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
+ try:
+ listener.bind(("127.0.0.1", port))
+ except OSError:
+ continue
+ return int(listener.getsockname()[1])
+ raise RuntimeError("Could not allocate a local Viser port.")
+
+
+def _viser_iframe(port: int, scene_hash: str) -> str:
+ srcdoc = (
+ ""
+ )
+ return (
+ "Viser preview: "
+ f"{html.escape(scene_hash)}"
+ f"
"
+ )
+
+
+def _saved_scene_root(scene_name: str) -> Path:
+ """Resolve a scene-list value without allowing paths outside the store."""
+ if not scene_name or Path(scene_name).name != scene_name:
+ raise ValueError("Select a valid generated scene.")
+ scene_store = GEN_SIM_SCENE_ROOT.resolve()
+ scene_root = (scene_store / scene_name).resolve()
+ if scene_root.parent != scene_store:
+ raise ValueError("Selected scene must stay within the generated scene store.")
+ config_path = scene_root / "scene_export" / "scene_config.json"
+ if not config_path.is_file():
+ raise FileNotFoundError(f"Scene export is incomplete: {config_path}")
+ try:
+ scene_config = json.loads(config_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise ValueError(f"Scene config is invalid: {config_path}") from exc
+ if not isinstance(scene_config, dict) or scene_config.get("format") != (
+ "embodichain.scene-export/v1"
+ ):
+ raise ValueError(f"Unsupported scene export: {config_path}")
+ return scene_root
+
+
+def saved_scene_choices() -> list[tuple[str, str]]:
+ """List complete Scene Engine exports, newest first."""
+ if not GEN_SIM_SCENE_ROOT.is_dir():
+ return []
+ choices: list[tuple[int, str, str]] = []
+ for scene_root in GEN_SIM_SCENE_ROOT.iterdir():
+ if not scene_root.is_dir():
+ continue
+ config_path = scene_root / "scene_export" / "scene_config.json"
+ if not config_path.is_file():
+ continue
+ try:
+ scene_config = json.loads(config_path.read_text(encoding="utf-8"))
+ if not isinstance(scene_config, dict) or scene_config.get("format") != (
+ "embodichain.scene-export/v1"
+ ):
+ continue
+ scene_id = scene_config.get("scene_id")
+ label = (
+ f"{scene_root.name} · {scene_id}"
+ if isinstance(scene_id, str) and scene_id
+ else scene_root.name
+ )
+ modified_ns = config_path.stat().st_mtime_ns
+ except (OSError, json.JSONDecodeError):
+ continue
+ choices.append((modified_ns, label, scene_root.name))
+ choices.sort(reverse=True)
+ return [(label, value) for _modified_ns, label, value in choices]
+
+
+def refresh_saved_scenes(selected_scene: str | None = None):
+ """Refresh the Action-engine scene list without selecting a scene implicitly."""
+ choices = saved_scene_choices()
+ values = {value for _label, value in choices}
+ value = selected_scene if selected_scene in values else None
+ status = (
+ f"**Scene list:** {len(choices)} generated scene(s) available."
+ if choices
+ else "**Scene list:** no complete generated scenes found."
+ )
+ return gr.update(choices=choices, value=value), status
+
+
+def preview_saved_scene(
+ scene_name: str | None,
+ request: gr.Request,
+) -> tuple[str, str]:
+ """Start a session-owned Viser preview for one saved scene.
+
+ Args:
+ scene_name: Hash-named generated scene selected in the Action panel.
+ request: Gradio request carrying the owning session hash.
+
+ Returns:
+ Preview iframe HTML and a human-readable preview status.
+ """
+ session_id = get_request_session_id(request)
+ if not scene_name:
+ return _ACTION_IDLE_PREVIEW, "**Scene preview:** no scene selected."
+
+ try:
+ scene_root = _saved_scene_root(scene_name)
+ except (ValueError, FileNotFoundError) as exc:
+ return _ACTION_IDLE_PREVIEW, f"**Scene preview error:** {exc}"
+
+ with runtime_lock:
+ runtime = runtime_registry.get(session_id)
+ if runtime.scene_engine_is_running:
+ return (
+ _ACTION_IDLE_PREVIEW,
+ "**Scene preview:** Scene Engine is still running.",
+ )
+ token = _action_preview_runs.begin(session_id)
+
+ with _preview_start_lock:
+ port = _select_available_port(ACTION_ENGINE_VISER_PORT)
+ preview_command = [
+ sys.executable,
+ COMMANDS["scene_engine"]["preview_script"],
+ "--output_root",
+ str(scene_root),
+ "--viser",
+ "--viser-host",
+ "0.0.0.0",
+ "--viser-port",
+ str(port),
+ ]
+ try:
+ preview_process = start_pipeline(preview_command)
+ except Exception as exc:
+ return _ACTION_IDLE_PREVIEW, f"**Scene preview error:** {exc}"
+
+ if not _action_preview_runs.attach(session_id, token, preview_process):
+ terminate_process_group(preview_process)
+ return _ACTION_IDLE_PREVIEW, "**Scene preview:** request was superseded."
+ if not _wait_for_viser(port, preview_process):
+ terminate_process_group(preview_process)
+ _action_preview_runs.finish(session_id, token, preview_process)
+ return _ACTION_IDLE_PREVIEW, "**Scene preview error:** Viser did not start."
+
+ if not _action_preview_runs.is_active(session_id, token, preview_process):
+ terminate_process_group(preview_process)
+ return _ACTION_IDLE_PREVIEW, "**Scene preview:** request was superseded."
+
+ return (
+ _viser_iframe(port, scene_name),
+ f"**Scene preview:** `{scene_name}` is ready.",
+ )
+
+
+def reset_scene_engine(
+ request: gr.Request,
+) -> tuple[None, int, str, str, str]:
+ """Reset only the requesting session's Scene Engine state and processes.
+
+ Args:
+ request: Gradio request carrying the owning session hash.
+
+ Returns:
+ Reset values for the Scene Engine input, progress, status, output, and
+ preview widgets.
+ """
+ session_id = get_request_session_id(request)
+ with runtime_lock:
+ runtime = runtime_registry.get(session_id)
+ owns_runtime = runtime.scene_engine_is_running
+ action_running = runtime.is_busy and not owns_runtime
+ if owns_runtime:
+ runtime.is_busy = False
+ if not action_running:
+ set_runtime_phase_locked(runtime, "idle")
+ runtime.status = "Scene Engine reset."
+ runtime.last_error = None
+ runtime.log_lines.clear()
+ runtime.image_path = None
+ runtime.scene_engine_is_running = False
+ _scene_runs.reset(session_id, force=True)
+
+ message = (
+ "Scene Engine reset."
+ if not action_running
+ else "Scene Engine preview reset; Action Engine is still running."
+ )
+ return (
+ None,
+ PHASES["idle"].progress,
+ format_status(message),
+ "",
+ ""
+ "The Viser preview will appear here after generation."
+ "
",
+ )
+
+
+def run_scene_engine(
+ image_value: str | np.ndarray | Image.Image,
+ request: gr.Request,
+) -> Iterator[tuple[int, str, str | None, str]]:
+ """Generate one scene for the requesting Gradio session.
+
+ Args:
+ image_value: Uploaded image path, array, or PIL image.
+ request: Gradio request carrying the owning session hash.
+
+ Yields:
+ Progress, status, output directory, and Viser preview updates.
+ """
+ session_id = get_request_session_id(request)
+ output_root: Path | None = None
+ preview_html = ""
+ with runtime_lock:
+ runtime = runtime_registry.get(session_id)
+ if runtime.is_busy:
+ runtime.status = "Another engine is already running in this session."
+ runtime.last_error = runtime.status
+ busy_message = runtime.status
+ else:
+ token = _scene_runs.begin(session_id)
+ runtime.is_busy = True
+ runtime.scene_engine_is_running = True
+ set_runtime_phase_locked(runtime, "received")
+ runtime.status = "Preparing Scene Engine input."
+ runtime.last_error = None
+ runtime.log_lines.clear()
+ busy_message = None
+
+ if busy_message is not None:
+ yield _scene_engine_updates(runtime, 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 not _scene_runs.is_active(session_id, token):
+ return
+ runtime.is_busy = False
+ runtime.scene_engine_is_running = False
+ set_runtime_phase_locked(runtime, "failed")
+ runtime.status = f"Input error: {exc}"
+ runtime.last_error = str(exc)
+ yield _scene_engine_updates(runtime, output_root, preview_html)
+ return
+
+ with runtime_lock:
+ if not _scene_runs.is_active(session_id, token):
+ return
+ runtime.status = f"Image saved. Generating Scene Engine output {scene_hash}."
+ runtime.image_path = image_path
+
+ 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(runtime, output_root, preview_html)
+
+ try:
+ process = start_pipeline(command)
+ except Exception as exc:
+ with runtime_lock:
+ if not _scene_runs.is_active(session_id, token):
+ return
+ runtime.is_busy = False
+ runtime.scene_engine_is_running = False
+ set_runtime_phase_locked(runtime, "failed")
+ runtime.status = f"Scene Engine start failed: {exc}"
+ runtime.last_error = str(exc)
+ yield _scene_engine_updates(runtime, output_root, preview_html)
+ return
+
+ if not _scene_runs.attach(session_id, token, process):
+ terminate_process_group(process)
+ 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 not _scene_runs.is_active(session_id, token, process):
+ terminate_process_group(process)
+ return
+ set_runtime_phase_locked(runtime, "started")
+ runtime.status = "Scene Engine generation started."
+ reader.start()
+
+ while process.poll() is None:
+ drained = _drain_output_queue(output_queue)
+ with runtime_lock:
+ if not _scene_runs.is_active(session_id, token, process):
+ return
+ for line in drained:
+ runtime.log_lines.append(line)
+ set_runtime_phase_locked(
+ runtime,
+ _scene_engine_phase_from_log(line, runtime.phase_key),
+ )
+ if (output_root / "scene_export" / "scene_config.json").is_file():
+ set_runtime_phase_locked(runtime, "gym_export")
+ runtime.status = PHASES[runtime.phase_key].label + "."
+ yield _scene_engine_updates(runtime, output_root, preview_html)
+ time.sleep(0.5)
+
+ reader.join(timeout=1.0)
+ with runtime_lock:
+ if not _scene_runs.is_active(session_id, token, process):
+ return
+ for line in _drain_output_queue(output_queue):
+ runtime.log_lines.append(line)
+ set_runtime_phase_locked(
+ runtime,
+ _scene_engine_phase_from_log(line, runtime.phase_key),
+ )
+ _scene_runs.finish(session_id, token, process)
+
+ 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 not _scene_runs.is_active(session_id, token):
+ return
+ runtime.is_busy = False
+ runtime.scene_engine_is_running = False
+ set_runtime_phase_locked(runtime, "failed")
+ runtime.status = detail
+ runtime.last_error = detail
+ yield _scene_engine_updates(runtime, output_root, preview_html)
+ return
+
+ preview_error: str | None = None
+ with _preview_start_lock:
+ port = _select_available_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:
+ preview_error = f"Viser preview start failed: {exc}"
+ else:
+ if not _scene_runs.attach(session_id, token, preview_process):
+ terminate_process_group(preview_process)
+ return
+ with runtime_lock:
+ runtime.log_lines.append("$ " + " ".join(preview_command))
+ set_runtime_phase_locked(runtime, "preview")
+ runtime.status = "Starting Viser preview..."
+
+ if not _wait_for_viser(port, preview_process):
+ terminate_process_group(preview_process)
+ _scene_runs.finish(session_id, token, preview_process)
+ preview_error = "Viser preview did not start."
+
+ if preview_error is not None:
+ with runtime_lock:
+ if not _scene_runs.is_active(session_id, token):
+ return
+ runtime.is_busy = False
+ runtime.scene_engine_is_running = False
+ set_runtime_phase_locked(runtime, "failed")
+ runtime.status = preview_error
+ runtime.last_error = preview_error
+ yield _scene_engine_updates(runtime, output_root, preview_html)
+ return
+
+ preview_html = _viser_iframe(port, scene_hash)
+ with runtime_lock:
+ if not _scene_runs.is_active(session_id, token, preview_process):
+ terminate_process_group(preview_process)
+ return
+ runtime.is_busy = False
+ runtime.scene_engine_is_running = False
+ set_runtime_phase_locked(runtime, "complete")
+ runtime.status = "Scene generated successfully. Viser preview is ready."
+ runtime.last_error = None
+ yield _scene_engine_updates(runtime, output_root, preview_html)
+
+
+def _action_scene_is_available() -> bool:
+ return FAST_GYM_CONFIG.is_file() and AGENT_CONFIG.is_file()
+
+
+def _action_agent_cli_is_available() -> bool:
+ try:
+ return importlib.util.find_spec(COMMANDS["agent"]["module"]) is not None
+ except (ImportError, ModuleNotFoundError, ValueError):
+ return False
+
+
+def run_action_engine_from_current(
+ task_text: str,
+ robot_profile: str | None,
+ request: gr.Request,
+) -> tuple[object, ...]:
+ """Launch DexSim for the requesting Gradio session.
+
+ Args:
+ task_text: Natural-language task for the action agent.
+ robot_profile: Optional robot selection exposed by the UI.
+ request: Gradio request carrying the owning session hash.
+
+ Returns:
+ Current Action Engine widget values for the session.
+ """
+ session_id = get_request_session_id(request)
+ task_text = (task_text or "").strip()
+ with runtime_lock:
+ runtime = runtime_registry.get(session_id)
+ if not task_text:
+ failure = "Enter a task description first."
+ elif not _action_scene_is_available():
+ failure = "Current Gym scene/config is unavailable."
+ elif runtime.is_busy:
+ failure = "Another engine is already running in this session."
+ elif not _action_agent_cli_is_available():
+ failure = "Action-agent CLI is unavailable in this environment."
+ else:
+ failure = None
+ token = _action_runs.begin(session_id)
+ runtime.is_busy = True
+ runtime.task_text = task_text
+ runtime.status = "Starting DexSim action simulation..."
+ runtime.last_error = None
+ runtime.log_lines.clear()
+ set_runtime_phase_locked(runtime, "started")
+
+ if failure is not None:
+ runtime.status = failure
+ runtime.last_error = failure
+
+ if failure is None:
+ error = _launch_current_simulation(
+ session_id,
+ runtime,
+ token,
+ robot_profile=robot_profile,
+ )
+ if error:
+ with runtime_lock:
+ if _action_runs.is_active(session_id, token):
+ runtime.is_busy = False
+ set_runtime_phase_locked(runtime, "failed")
+ runtime.status = error
+ runtime.last_error = error
+ return ui_snapshot(session_id)
+
+
+def stop_action_engine(request: gr.Request) -> tuple[object, ...]:
+ """Stop only the requesting session's Action processes and reset its UI.
+
+ The DexSim process group includes any child processes it launches. The
+ separately managed Action scene preview is also stopped. Other sessions and
+ the requesting session's Scene and Asset processes remain untouched.
+
+ Args:
+ request: Gradio request for the browser session initiating Stop.
+
+ Returns:
+ Reset values for the Action preview, status, video, task, and progress
+ widgets.
+ """
+ session_id = get_request_session_id(request)
+ with runtime_lock:
+ runtime = runtime_registry.get(session_id)
+ _action_runs.reset(session_id, force=True)
+ _action_preview_runs.reset(session_id, force=True)
+ if not runtime.scene_engine_is_running:
+ runtime.is_busy = False
+ set_runtime_phase_locked(runtime, "idle")
+ runtime.status = "Action Engine stopped."
+ runtime.last_error = None
+ runtime.log_lines.clear()
+ runtime.task_text = ""
+ runtime.video_path = None
+ runtime.last_sent_video_signature = None
+
+ return (
+ _ACTION_IDLE_PREVIEW,
+ "**Scene preview:** Action Engine stopped.",
+ None,
+ "",
+ PHASES["idle"].progress,
+ format_status("Action Engine stopped."),
+ )
+
+
+def cleanup_workflow_session(request: gr.Request) -> None:
+ """Stop Scene and Action processes owned by a disconnected session.
+
+ Args:
+ request: Gradio unload request carrying the disconnected session hash.
+ """
+ session_id = get_request_session_id(request)
+ with runtime_lock:
+ _scene_runs.reset(session_id, force=True)
+ _action_runs.reset(session_id, force=True)
+ _action_preview_runs.reset(session_id, force=True)
+ runtime_registry.reset(session_id)
+
+
+def _launch_current_simulation(
+ session_id: str,
+ runtime: RuntimeState,
+ token: str,
+ *,
+ robot_profile: str | None = None,
+) -> str | None:
+ command = build_run_agent_command(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=(session_id, runtime, token, process, output_queue, reader, started_at_ns),
+ daemon=True,
+ )
+
+ if not _action_runs.attach(session_id, token, process):
+ terminate_process_group(process)
+ return None
+ with runtime_lock:
+ if not _action_runs.is_active(session_id, token, process):
+ terminate_process_group(process)
+ return None
+ runtime.log_lines.append("$ " + " ".join(command))
+
+ reader.start()
+ monitor.start()
+ return None
+
+
+def _monitor_simulation(
+ session_id: str,
+ runtime: RuntimeState,
+ token: str,
+ process: subprocess.Popen[str],
+ output_queue: queue.Queue[str],
+ reader: threading.Thread,
+ started_at_ns: int,
+) -> None:
+ while process.poll() is None:
+ _append_simulation_logs(
+ session_id,
+ runtime,
+ token,
+ process,
+ _drain_output_queue(output_queue),
+ )
+ if not _action_runs.is_active(session_id, token, process):
+ return
+ time.sleep(0.5)
+
+ reader.join(timeout=1.0)
+ _append_simulation_logs(
+ session_id,
+ runtime,
+ token,
+ process,
+ _drain_output_queue(output_queue),
+ )
+ if not _action_runs.is_active(session_id, token, process):
+ return
+ source_video = latest_audience_output_video(min_mtime_ns=started_at_ns)
+ display_video: Path | None = None
+ if source_video is not None:
+ destination = GEN_SIM_ROOT / "action_videos" / token / source_video.name
+ try:
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source_video, destination)
+ display_video = destination
+ except OSError as exc:
+ _append_simulation_logs(
+ session_id,
+ runtime,
+ token,
+ process,
+ [f"Could not copy the simulation preview into the workspace: {exc}"],
+ )
+
+ with runtime_lock:
+ if not _action_runs.is_active(session_id, token, process):
+ return
+ runtime.is_busy = False
+ runtime.video_path = display_video
+ if process.returncode == 0:
+ set_runtime_phase_locked(runtime, "complete")
+ runtime.status = "DexSim simulation finished successfully."
+ runtime.last_error = None
+ if display_video is None:
+ runtime.log_lines.append("No simulation preview video was found.")
+ else:
+ set_runtime_phase_locked(runtime, "failed")
+ runtime.status = f"DexSim exited with return code {process.returncode}."
+ runtime.last_error = runtime.status
+ _action_runs.finish(session_id, token, process)
+
+
+def _append_simulation_logs(
+ session_id: str,
+ runtime: RuntimeState,
+ token: str,
+ process: subprocess.Popen[str],
+ lines: list[str],
+) -> None:
+ if not lines:
+ return
+ with runtime_lock:
+ if _action_runs.is_active(session_id, token, process):
+ runtime.log_lines.extend(lines)
+
+
+def ui_snapshot(
+ session_id: str,
+ extra_status: str | None = None,
+) -> tuple[object, ...]:
+ """Return Action-engine widget values for one Gradio session.
+
+ Args:
+ session_id: Stable Gradio session identifier.
+ extra_status: Optional text appended to the stored status.
+
+ Returns:
+ Video, task, progress, status, and compatibility placeholder values.
+ """
+ with runtime_lock:
+ runtime = runtime_registry.get(session_id)
+ 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 video_signature != runtime.last_sent_video_signature:
+ runtime.last_sent_video_signature = video_signature
+ video_update = video_value
+ else:
+ video_update = gr.update()
+ 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,
+ ),
+ None,
+ None,
+ None,
+ )
+
+
+def format_status(
+ status_text: str,
+ *,
+ phase: Phase | None = None,
+ busy: bool = False,
+ last_error: str | None = None,
+) -> str:
+ """Format an engine status for display in Gradio."""
+ 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..17d0b79d1
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/gradio_app.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.
+# ----------------------------------------------------------------------------
+
+"""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,
+ DEFAULT_CONCURRENCY_LIMIT,
+ GEN_SIM_ROOT,
+)
+from app_env import (
+ ARTICRAFT_OUTPUT_ROOT,
+ EMBODICHAIN_ROOT,
+ SERVER_NAME,
+ SERVER_PORT,
+ build_gradio_allowed_paths,
+ build_gradio_blocked_paths,
+ get_gradio_auth,
+ validate_gradio_artifact_root,
+)
+from app_processes import force_stop_all_child_processes
+from app_services import build_app
+from embodichain.gen_sim.env import find_gen_sim_env_file
+
+__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 _allowed_paths() -> list[str]:
+ """Return only static assets and workspace-generated artifact roots."""
+ return build_gradio_allowed_paths(
+ ASSETS_DIR,
+ GEN_SIM_ROOT,
+ validate_gradio_artifact_root(ARTICRAFT_OUTPUT_ROOT),
+ )
+
+
+def _blocked_paths() -> list[str]:
+ """Return source-control and dotenv paths that Gradio must never serve."""
+ return build_gradio_blocked_paths(find_gen_sim_env_file())
+
+
+def main() -> None:
+ if not EMBODICHAIN_ROOT.is_dir():
+ raise FileNotFoundError(f"EmbodiChain root not found: {EMBODICHAIN_ROOT}")
+ app = build_app()
+ app.queue(default_concurrency_limit=DEFAULT_CONCURRENCY_LIMIT)
+ _install_shutdown_handlers()
+ try:
+ app.launch(
+ server_name=SERVER_NAME,
+ server_port=SERVER_PORT,
+ auth=get_gradio_auth(),
+ allowed_paths=_allowed_paths(),
+ blocked_paths=_blocked_paths(),
+ )
+ 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..ce3a41696
--- /dev/null
+++ b/embodichain/gen_sim/gradio_ui/gradio_visualization_architecture.md
@@ -0,0 +1,243 @@
+# Gradio 可视化系统架构
+
+本文档以当前代码为准,描述 Gradio 中的三个引擎,以及它们与 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
+ │ └► action-agent `run_agent` + 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` | 顶部图标、引擎面板切换和回调绑定;不实现 pipeline。 |
+| `app_asset_engine.py` | SimReady 上传适配、输入/输出 GLB 预览、处理日志,以及 Asset engine 的 Articraft 标签页。 |
+| `app_articraft.py` | Articraft checkout/环境检查、外部记录创建、Codex 生成与校验、URDF bundle 和 Viser 关节预览。 |
+| `app_workflows.py` | Scene Engine 工作流、Action Engine 的会话状态、Viser 预览和 DexSim。 |
+| `app_processes.py` | 子进程环境、进程组终止、stdout 读取和 pipeline 阶段检测。 |
+| `app_state.py` | `RuntimeState`、互斥锁、进度阶段、运行 token 和耗时统计。 |
+| `app_commands.py` | Action engine 的 `run_agent` 参数构造。 |
+| `app_media.py` | DexSim 观众视频发现和 Articraft Viser CLI 适配。 |
+| `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` | `127.0.0.1` | Gradio 监听地址;非回环地址必须启用认证。 |
+| `GRADIO_SERVER_PORT` | `7860` | Gradio 监听端口。 |
+| `GRADIO_AUTH_USERNAME` | 空 | 非本机部署的 Gradio 用户名。 |
+| `GRADIO_AUTH_PASSWORD` | 空 | 非本机部署的 Gradio 密码。 |
+| `SCENE_ENGINE_VISER_PORT` | `8080` | Scene Engine 的首选 Viser 端口;占用时为会话分配其他可用端口。 |
+| `ARTICRAFT_VISER_PORT` | `8081` | Articraft 关节预览的首选 Viser 端口;占用时为会话分配其他可用端口。 |
+| `ACTION_ENGINE_VISER_PORT` | `8082` | Action Engine 已保存场景预览的首选 Viser 端口;占用时为会话分配其他可用端口。 |
+| `ARTICRAFT_ROOT` | `<项目>/.articraft` | Articraft checkout。 |
+| `ARTICRAFT_CONDA_ENV` | `articraft` | 运行 Articraft CLI 的 Conda 环境。 |
+| `ARTICRAFT_OUTPUT_ROOT` | `<项目>/.gen_sim/articraft` | Articraft 记录、运行日志和导出 bundle。 |
+
+`app.launch()` 仅开放 UI 静态资源、`.gen_sim/` 生成物和配置的 Articraft 输出目录,并显式禁止 `.env`、`.git/` 等敏感路径。pipeline 子进程由 `build_pipeline_env()` 从共享 `.env` 创建环境:它清除代理变量、设置 `NO_PROXY=no_proxy=*` 并关闭 Gradio analytics。只有 SimReady 子进程会额外把非空的 `SIMREADY_OPENAI_*` 映射为其上游 CLI 需要的 `OPENAI_*`;Scene Engine、DexSim、Viser 和 Articraft 直接继承 `.env` 中的原始配置。Codex 作为用户指令驱动的子进程,使用独立登录状态和最小化环境,不继承 GenSim 服务凭据。
+
+## 页面与引擎
+
+页面顶部保留 DexForce 图标,并直接显示 `Asset_engine`、`Scene_engine`、`Action_engine` 三个入口,不再提供模式切换。它们的实际输入和产物并不完全相同:
+
+| Engine | 输入 | 预览/下载 | 实际产物 | 是否启动 DexSim |
+| --- | --- | --- | --- | --- |
+| Asset engine / SimReady | 一个网格、可选材质附件、类别 | 输入 GLB、SimReady GLB、原始输出下载 | `.gen_sim/assets/runs//` | 否 |
+| Asset engine / Articulation | 文字、可选参考图 | URDF articulation 的 Viser、zip 下载 | `.gen_sim/articraft/` | 否 |
+| Scene engine | 一张图片 | Scene Engine 的 Viser | `.gen_sim/scenes//` | 否 |
+| Action engine | 已生成场景列表、任务、机器人 | 选中场景的 Viser 和 DexSim 视频 | 场景预览来自 `.gen_sim/scenes/`;DexSim 暂沿用现有命令 | 是 |
+
+因此,Scene engine 是独立的图像条件场景生成器;它不会提升、复制或转换输出到 `gym_project/current`。Action engine 只消费已有的 `current` Gym 场景。界面中的 “Scene engine” 文案表达的是所需场景类型,并不意味着独立 Scene Engine 输出已自动连到 Action engine。
+
+Scene/Action 与 Asset engine 使用同一会话边界:Gradio 回调从 `request.session_hash` 取得会话 ID。每个 ID 拥有独立的 `RuntimeState`,Scene 生成/Viser、Action DexSim 和 Action Viser 分别由以该 ID 为键的进程 registry 管理。Reset、Stop、同会话新任务替换及页面卸载只会终止该会话的进程;其他浏览器会话的进程和 UI 状态不受影响。
+
+## Asset engine
+
+### SimReady:单资产目录适配
+
+SimReady CLI 接收目录,而 Gradio 接收上传文件。上传文件会复制到隔离目录,文件名只保留 basename,重名追加序号,避免上传路径或重名影响处理:
+
+```text
+mesh + sidecar files
+ → .gen_sim/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 和 `.gen_sim/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` 下,不能直接当作 Action engine 的 Gym 场景或 SimReady 资产;若要进入后续仿真,需要另行定义并实现转换/导入流程。Articraft Viser 按 Gradio 会话管理预览进程:首先尝试 `ARTICRAFT_VISER_PORT`,若已被占用则分配其他可用端口,不会查找或发送信号给占用端口的外部进程。
+
+`Reset Articulation` 会清空当前会话的描述、参考图、记录与下载结果,终止该会话的 Articraft/Codex 命令进程组,并关闭该会话启动的 Viser。
+
+## 独立 Scene engine 和 Viser
+
+Scene engine 只接收图像。上传图像会先进行 EXIF 归正并转为 RGB PNG,以 PNG 字节的 SHA-256 前 16 位作为目录名;相同图像会复用同一目录:
+
+```text
+image
+ → .gen_sim/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
+ → Gradio iframe
+```
+
+当 `scene_export/scene_config.json` 存在且生成进程返回成功时,应用才启动 Viser。iframe 使用 Gradio 页面当前的协议和主机名转向 Viser 端口,因此从其他设备访问时,浏览器必须能访问该端口。每个会话优先使用 `SCENE_ENGINE_VISER_PORT`,占用时选择其他可用端口;同会话的新 Scene Engine 任务会终止该会话旧的 Scene Viser,不会终止其他会话的预览。输出目录会显示在 UI 中,便于检查 hash 命名的场景导出。
+
+`Reset Scene Engine` 会清空当前会话的图像、进度、输出目录和 iframe,并终止该会话当前生成命令与 Scene Viser 的进程组;registry 运行 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
+```
+
+进入 Action engine 或点击 `Refresh scenes` 会扫描 `.gen_sim/scenes/`,只列出包含 `scene_export/scene_config.json` 的完整场景。列表不会自动选中场景;用户显式选择后,右侧通过 Viser 展示该场景。当前场景选择只负责可视化,尚未传递给 DexSim 命令。
+
+点击 `Run DexSim` 仍会检查任务、现有 `current` Gym/action 配置、运行占用和可导入的 `embodichain.gen_sim.action_agent_pipeline.cli.run_agent`,再以当前配置调用 `run_agent`。
+
+运行命令的核心参数为:
+
+```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
+```
+
+`--robot-profile` 仅在通过 `run_agent --help` 探测到该参数时加入。DexSim 完成后会寻找本次运行产生的 audience 视频并显示在 Action engine 中。
+
+`Stop Action Engine` 仅重置当前会话的 Action 进程 registry,终止该会话的 DexSim 进程组和已保存场景 Viser。它不终止同会话的 Scene Engine,也不影响其他浏览器会话。
+
+## 会话状态、并发和进度
+
+Scene engine 和 Action engine 的 UI 状态存放在 `SessionRuntimeRegistry`,键为 `request.session_hash`。`RuntimeState` 只包含当前会话的输入、预览、日志和阶段,不再保存服务器级全局进程引用。Scene 生成/Viser、Action DexSim 和 Action Viser 有独立的 `SessionProcessRegistry`;每个 registry 的 token 用于丢弃同会话内过期线程的更新。SimReady 和 Articraft 使用相同的会话键。Reset、Stop 和页面卸载只清理所属会话,并直接向该会话所属进程组发送 `SIGKILL`,不等待交互式任务优雅退出。Gradio 应用正常关闭仍统一清理全部已注册子进程,并保留 `SIGTERM` 宽限期后再升级为 `SIGKILL` 的原有逻辑。
+
+`app.queue(default_concurrency_limit=1)` 将队列中的高成本回调串行化。Action engine 的 `Timer(2.0)` 通过当前请求的 `session_hash` 只读取该会话状态。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=''
+```
+
+Action engine 只需要 action-agent 的运行模块:
+
+```text
+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 子进程不继承 `.env` 中的 API key、token 或密码,输出在返回浏览器前还会按已知敏感环境值脱敏。生成请求会交给本机 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_app; assert build_app() 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. Action engine 在没有 `current` Gym/action 配置或缺少 CLI 时给出预检错误。
diff --git a/pyproject.toml b/pyproject.toml
index ae17152e6..aae8fd9af 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -54,6 +54,7 @@ dependencies = [
[project.optional-dependencies]
gensim = [
"bpy",
+ "gradio>=6.17.3,<6.18",
"pyrender==0.1.45",
"requests",
"Pillow",
diff --git a/tests/gen_sim/__init__.py b/tests/gen_sim/__init__.py
new file mode 100644
index 000000000..355d915ff
--- /dev/null
+++ b/tests/gen_sim/__init__.py
@@ -0,0 +1,17 @@
+# ----------------------------------------------------------------------------
+# 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
diff --git a/tests/gen_sim/gradio_ui/__init__.py b/tests/gen_sim/gradio_ui/__init__.py
new file mode 100644
index 000000000..355d915ff
--- /dev/null
+++ b/tests/gen_sim/gradio_ui/__init__.py
@@ -0,0 +1,17 @@
+# ----------------------------------------------------------------------------
+# 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
diff --git a/tests/gen_sim/gradio_ui/test_app_articraft.py b/tests/gen_sim/gradio_ui/test_app_articraft.py
new file mode 100644
index 000000000..bfc68e1ca
--- /dev/null
+++ b/tests/gen_sim/gradio_ui/test_app_articraft.py
@@ -0,0 +1,59 @@
+# ----------------------------------------------------------------------------
+# 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 socket
+import sys
+from pathlib import Path
+
+import pytest
+
+GRADIO_UI_ROOT = (
+ Path(__file__).resolve().parents[3] / "embodichain" / "gen_sim" / "gradio_ui"
+)
+sys.path.insert(0, str(GRADIO_UI_ROOT))
+
+import app_articraft # noqa: E402
+
+
+def test_preview_selects_another_port_when_preferred_port_is_occupied() -> None:
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ listener.bind(("127.0.0.1", 0))
+ listener.listen()
+ occupied_port = int(listener.getsockname()[1])
+ previews = app_articraft._ArticraftViserPreview(occupied_port)
+ try:
+ selected_port = previews._select_available_port()
+ finally:
+ listener.close()
+
+ assert selected_port != occupied_port
+ assert selected_port > 0
+
+
+def test_codex_checkout_cannot_be_the_embodichain_repository(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ app_articraft,
+ "ARTICRAFT_ROOT",
+ app_articraft.EMBODICHAIN_ROOT,
+ )
+
+ assert "dedicated nested or external Git checkout" in (
+ app_articraft._articraft_isolation_error() or ""
+ )
diff --git a/tests/gen_sim/gradio_ui/test_app_env.py b/tests/gen_sim/gradio_ui/test_app_env.py
new file mode 100644
index 000000000..27231eb51
--- /dev/null
+++ b/tests/gen_sim/gradio_ui/test_app_env.py
@@ -0,0 +1,72 @@
+# ----------------------------------------------------------------------------
+# 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 sys
+from pathlib import Path
+
+import pytest
+
+GRADIO_UI_ROOT = (
+ Path(__file__).resolve().parents[3] / "embodichain" / "gen_sim" / "gradio_ui"
+)
+sys.path.insert(0, str(GRADIO_UI_ROOT))
+
+import app_env # noqa: E402
+
+
+def test_local_gradio_server_does_not_require_authentication() -> None:
+ assert app_env.get_gradio_auth("127.0.0.1", "", "") is None
+
+
+def test_remote_gradio_server_requires_authentication() -> None:
+ with pytest.raises(ValueError, match="requires Gradio authentication"):
+ app_env.get_gradio_auth("0.0.0.0", "", "")
+
+
+def test_remote_gradio_server_accepts_complete_credentials() -> None:
+ assert app_env.get_gradio_auth("0.0.0.0", "workspace", "secret") == (
+ "workspace",
+ "secret",
+ )
+
+
+def test_partial_gradio_credentials_are_rejected() -> None:
+ with pytest.raises(ValueError, match="Set both"):
+ app_env.get_gradio_auth("127.0.0.1", "workspace", "")
+
+
+def test_gradio_file_access_excludes_repository_and_blocks_dotenv(
+ tmp_path: Path,
+) -> None:
+ generated_root = tmp_path / "generated"
+ static_root = tmp_path / "static"
+ external_env = tmp_path / "deployment.env"
+
+ allowed = app_env.build_gradio_allowed_paths(generated_root, static_root)
+ blocked = app_env.build_gradio_blocked_paths(external_env)
+
+ assert str(app_env.EMBODICHAIN_ROOT.resolve()) not in allowed
+ assert str(generated_root.resolve()) in allowed
+ assert str(static_root.resolve()) in allowed
+ assert str(external_env.resolve()) in blocked
+ assert str((app_env.EMBODICHAIN_ROOT / ".git").resolve()) in blocked
+
+
+def test_repository_cannot_be_configured_as_artifact_root() -> None:
+ with pytest.raises(ValueError, match="dedicated artifact directory"):
+ app_env.validate_gradio_artifact_root(app_env.EMBODICHAIN_ROOT)
diff --git a/tests/gen_sim/scene_engine/__init__.py b/tests/gen_sim/scene_engine/__init__.py
new file mode 100644
index 000000000..355d915ff
--- /dev/null
+++ b/tests/gen_sim/scene_engine/__init__.py
@@ -0,0 +1,17 @@
+# ----------------------------------------------------------------------------
+# 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
diff --git a/tests/gen_sim/scene_engine/test_scene_engine_config.py b/tests/gen_sim/scene_engine/test_config.py
similarity index 100%
rename from tests/gen_sim/scene_engine/test_scene_engine_config.py
rename to tests/gen_sim/scene_engine/test_config.py
diff --git a/tests/gen_sim/simready_pipeline/__init__.py b/tests/gen_sim/simready_pipeline/__init__.py
new file mode 100644
index 000000000..355d915ff
--- /dev/null
+++ b/tests/gen_sim/simready_pipeline/__init__.py
@@ -0,0 +1,17 @@
+# ----------------------------------------------------------------------------
+# 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
diff --git a/tests/gen_sim/test_gen_sim_env.py b/tests/gen_sim/test_gen_sim_env.py
new file mode 100644
index 000000000..215a12813
--- /dev/null
+++ b/tests/gen_sim/test_gen_sim_env.py
@@ -0,0 +1,65 @@
+# ----------------------------------------------------------------------------
+# 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 import env as gen_sim_env
+
+
+def test_missing_default_env_file_is_optional(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ module_path = tmp_path / "embodichain" / "gen_sim" / "env.py"
+ monkeypatch.delenv("EMBODICHAIN_ENV_FILE", raising=False)
+ monkeypatch.setattr(gen_sim_env, "__file__", str(module_path))
+
+ assert gen_sim_env.find_gen_sim_env_file() is None
+ assert gen_sim_env.load_gen_sim_env({}) is None
+
+
+def test_missing_configured_env_file_is_optional(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ env_path = tmp_path / "missing.env"
+ monkeypatch.setenv("EMBODICHAIN_ENV_FILE", str(env_path))
+
+ assert gen_sim_env.find_gen_sim_env_file() == env_path.resolve()
+ assert gen_sim_env.load_gen_sim_env({}) is None
+
+
+def test_shell_values_take_precedence_over_dotenv(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ env_path = tmp_path / ".env"
+ env_path.write_text(
+ "OPENAI_MODEL=dotenv-model\nOPENAI_API_KEY=dotenv-key\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("EMBODICHAIN_ENV_FILE", str(env_path))
+ target_env = {"OPENAI_MODEL": "shell-model"}
+
+ assert gen_sim_env.load_gen_sim_env(target_env) == env_path.resolve()
+ assert target_env == {
+ "OPENAI_MODEL": "shell-model",
+ "OPENAI_API_KEY": "dotenv-key",
+ }