From 34a2a155d21b5a8bd286a86b18967c242d7871a8 Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Thu, 11 Jun 2026 02:27:23 -0700 Subject: [PATCH 01/17] Add graph review app for UnresolvedArenaEnvGraphSpec Adds a Streamlit-based live editor for reviewing and editing UnresolvedArenaEnvGraphSpec YAMLs. The review tool renders: - A mermaid.js spatial graph of initial-state constraints with anchor highlighting, task-constraint dashed edges, and objectReference dotted edges - A task table (index, kind, description, params) - A node card grid with type badges and per-node YAML stanzas The YAML editor auto-renders when the spec is valid and changed. Node cards display a two-letter placeholder thumbnail by default. Also adds YAML round-trip serialization to ArenaEnvGraphSpec and moves assert_unique_ids/assert_references_exist to __post_init__ so validation fires on every load-from-yaml/dict path. Signed-off-by: Qian Lin --- .../review_app.py | 275 +++++++++++ .../review_graph.py | 444 ++++++++++++++++++ .../environments/arena_env_graph_spec.py | 12 + setup.py | 6 + 4 files changed, 737 insertions(+) create mode 100644 isaaclab_arena/agentic_environment_generation/review_app.py create mode 100644 isaaclab_arena/agentic_environment_generation/review_graph.py diff --git a/isaaclab_arena/agentic_environment_generation/review_app.py b/isaaclab_arena/agentic_environment_generation/review_app.py new file mode 100644 index 000000000..1a156190a --- /dev/null +++ b/isaaclab_arena/agentic_environment_generation/review_app.py @@ -0,0 +1,275 @@ +# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Streamlit-backed live editor for ArenaEnvInitialGraphSpec YAMLs. + +Wraps :func:`isaaclab_arena.agentic_environment_generation.review_graph.render_html_for_spec` +in a two-pane Streamlit page so the user can edit the ``ArenaEnvInitialGraphSpec`` +YAML directly in the browser and see the visualization update automatically. + +Launch (always via the wrapper in review_graph.py — handles streamlit flags): + /isaac-sim/python.sh -m isaaclab_arena.agentic_environment_generation.review_graph \\ + --yaml path/to/spec.yaml + +Design: + * Left pane — ``streamlit-ace`` YAML editor + validation badge + Save button. + Validation runs on every rerun (i.e. after each editor blur). When the YAML + is valid and has changed since the last render, the visualization updates + automatically — no button click required. + * Right pane — sandboxed iframe with the rendered review HTML. +""" + +from __future__ import annotations + +import argparse +import traceback +import yaml +from dataclasses import dataclass +from pathlib import Path + +import streamlit as st + +from isaaclab_arena.agentic_environment_generation.review_graph import render_html_for_spec +from isaaclab_arena.environments.arena_env_graph_spec import ArenaEnvInitialGraphSpec + +# Visualization iframe height. Tuned so the graph + tasks + node grid all +# fit without an outer Streamlit scrollbar swallowing the inner one. +_IFRAME_HEIGHT_PX = 1100 + + +# --------------------------------------------------------------------------- +# Args + session-state init +# --------------------------------------------------------------------------- + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--yaml", + type=Path, + required=True, + help="Path to the ArenaEnvInitialGraphSpec YAML to open in the editor.", + ) + return parser.parse_args() + + +@dataclass +class _ValidationResult: + spec: ArenaEnvInitialGraphSpec | None + error: str | None + + @property + def is_valid(self) -> bool: + return self.spec is not None + + +def _validate_yaml_text(text: str) -> _ValidationResult: + try: + raw = yaml.safe_load(text) + spec = ArenaEnvInitialGraphSpec.model_validate(raw) + return _ValidationResult(spec=spec, error=None) + except Exception: + return _ValidationResult(spec=None, error=traceback.format_exc()) + + +def _initialize_state(yaml_path: Path) -> None: + """Seed ``st.session_state`` from disk exactly once per session. + + We key off ``_yaml_path`` so that if the user passes a different YAML on + a Streamlit reload (rare — usually the same), we reset cleanly. + """ + if st.session_state.get("_yaml_path") == str(yaml_path): + return + + original_text = yaml_path.read_text(encoding="utf-8") + + st.session_state["_yaml_path"] = str(yaml_path) + st.session_state["original_text"] = original_text + st.session_state["edited_text"] = original_text + # The text whose render is currently displayed. Starts == original so the + # first paint shows the on-disk file (and "Regenerate" is correctly + # disabled until the user edits something). + st.session_state["last_rendered_text"] = original_text + st.session_state["save_path"] = str(yaml_path) + + initial = _validate_yaml_text(original_text) + if not initial.is_valid: + # Defensive: if the on-disk file is already broken we still want to + # show *something*, but we won't pre-render it. The user fixes the + # YAML in the editor, then hits Regenerate. + st.session_state["rendered_html"] = _BROKEN_PLACEHOLDER_HTML + else: + st.session_state["rendered_html"] = render_html_for_spec(initial.spec) + + +# Tiny standalone HTML used when the on-disk YAML is itself invalid. +_BROKEN_PLACEHOLDER_HTML = """ +

No visualization yet — fix the YAML errors to auto-render.

+""" + + +# --------------------------------------------------------------------------- +# UI panels +# --------------------------------------------------------------------------- + + +def _render_validation_badge(validation: _ValidationResult) -> None: + """Show a green tick + summary, or a red cross + the raw exception text.""" + if validation.is_valid: + spec = validation.spec + st.success( + f"Valid spec — {spec.env_name} · {len(spec.nodes)} nodes · " + f"{len(spec.tasks)} tasks · initial state: {spec.initial_state_spec.id}", + icon="✅", + ) + else: + st.error(f"Invalid YAML\n\n```\n{validation.error}\n```", icon="🛑") + + +def _render_save_button(validation: _ValidationResult) -> None: + """Render the Save button. Disabled while the YAML is invalid.""" + can_save = validation.is_valid + save_path_str = st.session_state["save_path"] + + if st.button( + f"Save to {Path(save_path_str).name}", + disabled=not can_save, + use_container_width=True, + help=f"Writes the editor contents to {save_path_str}. Disabled while YAML is invalid.", + ): + try: + Path(save_path_str).write_text(st.session_state["edited_text"], encoding="utf-8") + # Update "original" so future comparisons are against the saved file. + st.session_state["original_text"] = st.session_state["edited_text"] + st.toast(f"Saved → {save_path_str}", icon="💾") + except OSError as exc: + st.error(f"Save failed: {exc}", icon="🛑") + + with st.expander("Change save location", expanded=False): + new_path = st.text_input( + "Save path", + value=save_path_str, + key="save_path_input", + help="Defaults to the YAML file passed via --yaml.", + ) + if new_path and new_path != save_path_str: + st.session_state["save_path"] = new_path + + +def _render_editor_panel(yaml_path: Path) -> _ValidationResult: + """Left pane. Returns the validation result for the current editor text. + + Returning the validation result (rather than stashing it in session_state) + keeps the data flow inside one render pass and avoids a stale-state class + of bug where the badge and the buttons disagree. + """ + # Lazy import so the module is importable from environments that don't + # have streamlit-ace installed yet (we surface a clean error message + # rather than ImportError at module load). + try: + from streamlit_ace import st_ace # noqa: PLC0415 + except ImportError as exc: + # See review_graph._serve_live_editor for why --user --ignore-installed + # is required inside the isaaclab_arena container. + st.error( + "`streamlit-ace` is not installed. Inside the isaaclab_arena container run:\n" + "`python -m pip install --user --ignore-installed streamlit-ace`\n\n" + f"Underlying error: {exc}", + icon="🛑", + ) + st.stop() + + st.subheader("YAML editor") + st.caption(f"Source: `{yaml_path}`") + + # ``auto_update=False`` commits on blur / Ctrl+Enter rather than on every + # keystroke, showing an "Apply" button in the editor toolbar. + new_text = st_ace( + value=st.session_state["edited_text"], + language="yaml", + theme="monokai", + keybinding="vscode", + font_size=13, + tab_size=2, + show_gutter=True, + show_print_margin=False, + wrap=False, + auto_update=False, + min_lines=30, + # Key bound to the YAML path so swapping --yaml between sessions + # forces ace to remount with the new content. + key=f"ace_editor::{yaml_path}", + ) + if new_text is not None: + st.session_state["edited_text"] = new_text + + validation = _validate_yaml_text(st.session_state["edited_text"]) + _render_validation_badge(validation) + + # Auto-render whenever the YAML is valid and has changed since the last + # render. This runs before the right pane is drawn, so the updated HTML + # is already in session_state when the iframe is mounted — no extra rerun + # needed. + edited_since_render = st.session_state["edited_text"] != st.session_state["last_rendered_text"] + if validation.is_valid and edited_since_render: + with st.spinner("Rendering visualization…"): + st.session_state["rendered_html"] = render_html_for_spec(validation.spec) + st.session_state["last_rendered_text"] = st.session_state["edited_text"] + st.toast("Visualization updated.", icon="🔄") + + _render_save_button(validation) + return validation + + +def _render_visualization_panel() -> None: + """Right pane — iframe-mount the cached rendered HTML.""" + st.subheader("Visualization") + st.caption("Updates automatically when the YAML is valid.") + + # ``st.components.v1.html`` wraps the payload in a sandboxed iframe, which + # is what we want — the mermaid CDN script and the static CSS stay + # isolated from Streamlit's own DOM. + st.components.v1.html( + st.session_state["rendered_html"], + height=_IFRAME_HEIGHT_PX, + scrolling=True, + ) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + st.set_page_config( + page_title="ArenaEnvInitialGraphSpec live editor", + layout="wide", + initial_sidebar_state="collapsed", + ) + + args = _parse_args() + yaml_path = args.yaml.resolve() + if not yaml_path.exists(): + st.error(f"YAML file not found: {yaml_path}", icon="🛑") + st.stop() + + _initialize_state(yaml_path) + + st.markdown("### ArenaEnvInitialGraphSpec live editor") + left, right = st.columns([2, 3], gap="large") + with left: + _render_editor_panel(yaml_path) + with right: + _render_visualization_panel() + + +# Streamlit invokes the script top-level on every rerun, so we run main() +# unconditionally. The standard ``if __name__ == "__main__"`` guard would +# also work under ``streamlit run`` but is unnecessary — this module is only +# ever loaded as the Streamlit entrypoint. +main() diff --git a/isaaclab_arena/agentic_environment_generation/review_graph.py b/isaaclab_arena/agentic_environment_generation/review_graph.py new file mode 100644 index 000000000..0bf2ecbda --- /dev/null +++ b/isaaclab_arena/agentic_environment_generation/review_graph.py @@ -0,0 +1,444 @@ +# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""ArenaEnvInitialGraphSpec review tool — Streamlit live editor. + +The CLI is a thin launcher: it boots the Streamlit app in ``review_app.py``. + +The tool accepts an ``ArenaEnvInitialGraphSpec`` YAML as input. The user +edits the spec directly inside the Streamlit editor and the preview updates +in real time. + +Three panels (dark dashboard style) inside the embedded view: + * Top-left — graph diagram (mermaid.js, CDN-loaded) of the initial-state + spatial constraints. Anchor nodes are highlighted; constraints without + a reference (is_anchor / position_limits / at_pose / ...) are listed below + the graph rather than rendered as self-loops. + * Bottom-left — task table (index, kind, description, params). + * Right — node card grid: type badge, asset name, and the per-node YAML + stanza. + +Usage: + /isaac-sim/python.sh -m isaaclab_arena.agentic_environment_generation.review_graph \\ + --yaml isaaclab_arena/tests/test_data/pick_and_place_maple_table_init_env_graph.yaml + + # Custom port: + /isaac-sim/python.sh -m isaaclab_arena.agentic_environment_generation.review_graph \\ + --yaml --port 8600 + +Public API used by ``review_app.py``: + * :func:`render_html_for_spec` — full HTML payload for a spec. +""" + +from __future__ import annotations + +import argparse +import html as html_lib +import os +import re +import subprocess +import sys +import yaml +from pathlib import Path + +from isaaclab_arena.environments.arena_env_graph_spec import ArenaEnvInitialGraphSpec +from isaaclab_arena.environments.arena_env_graph_types import ArenaEnvGraphNodeSpec, ArenaEnvGraphStateSpec + + +def main() -> None: + """CLI entry point — argparse parses the user's flags, then we hand off + to Streamlit. The actual interactive UI lives in ``review_app.py``. + """ + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--yaml", + type=Path, + required=True, + help="Path to an ArenaEnvInitialGraphSpec YAML file. The Streamlit app will open it for live editing.", + ) + parser.add_argument( + "--port", + type=int, + default=8501, + help="Streamlit server port (default: 8501).", + ) + args = parser.parse_args() + _serve_live_editor(args.yaml, port=args.port) + + +def _serve_live_editor(yaml_path: Path, port: int = 8501) -> None: + """Spawn ``streamlit run review_app.py -- --yaml `` and wait. + + We resolve ``review_app.py`` next to this file rather than going through + ``-m`` so Streamlit picks the path up cleanly (``streamlit run`` doesn't + accept module dotted-paths). + """ + app_path = Path(__file__).with_name("review_app.py") + if not app_path.exists(): + raise FileNotFoundError(f"Streamlit app not found at {app_path} — installation is incomplete.") + + cmd = [ + sys.executable, + "-m", + "streamlit", + "run", + str(app_path), + "--server.port", + str(port), + # Skip the email prompt the first time Streamlit runs in a fresh + # container — the live editor is a developer tool, not a hosted + # service, and an interactive prompt would block automation. + "--browser.gatherUsageStats", + "false", + # File watcher is a footgun here: Kit's ``SimulationApp`` boot is + # tens of seconds; we don't want Streamlit silently rerunning the + # script (and reissuing the cached_resource init) every time we + # save a source file during development. The user can still hit "R" + # in the browser to force a rerun if they want. + "--server.fileWatcherType", + "none", + "--", + "--yaml", + str(yaml_path.resolve()), + ] + + # Inherit env so the Streamlit subprocess sees PYTHONPATH / isaac-sim + # site-packages exactly the same way we do. + print(f"[review_graph] launching Streamlit live editor: {' '.join(cmd)}", file=sys.stderr) + try: + subprocess.run(cmd, env=os.environ.copy(), check=True) + except FileNotFoundError as exc: + # The plain ``pip install streamlit`` fails inside the isaaclab_arena + # container because streamlit≥1.30 needs uvicorn>=0.30 but Kit ships + # a bundled uvicorn==0.29 under a read-only /isaac-sim/extscache path. + # ``--user --ignore-installed`` sidesteps the rollback by writing + # everything to ~/.local (which is earlier on sys.path than extscache). + raise SystemExit( + "Streamlit is not installed. Inside the isaaclab_arena container run:\n" + " python -m pip install --user --ignore-installed streamlit streamlit-ace" + ) from exc + except KeyboardInterrupt: + # Normal exit path — user hit Ctrl-C in the terminal. + pass + + +# --------------------------------------------------------------------------- +# Public API consumed by review_app.py +# --------------------------------------------------------------------------- + + +def render_html_for_spec(spec: ArenaEnvInitialGraphSpec) -> str: + """Render the review HTML for ``spec`` with placeholder node thumbnails. + + Thin public alias of :func:`_render_html` so external entry points don't + have to reach into a private name. + """ + return _render_html(spec) + + +# --------------------------------------------------------------------------- +# Top-level HTML +# --------------------------------------------------------------------------- + + +def _render_html(spec: ArenaEnvInitialGraphSpec) -> str: + initial_state = spec.initial_state_spec + return f""" + + + +{html_lib.escape(spec.env_name)} — graph review + + + + +
+

{html_lib.escape(spec.env_name)}

+

{len(spec.nodes)} nodes · {len(spec.tasks)} tasks · initial state: {html_lib.escape(initial_state.id)}

+
+
+
+

Spatial graph (initial state: {html_lib.escape(initial_state.id)})

+
{_render_mermaid(spec, initial_state)}
+ {_render_unary_constraints(initial_state)} +
+
+

Tasks

+ {_render_tasks_table(spec)} +
+
+

Nodes

+
{_render_node_cards(spec)}
+
+
+ + + +""" + + +# --------------------------------------------------------------------------- +# Mermaid graph rendering +# --------------------------------------------------------------------------- + + +def _render_mermaid(spec: ArenaEnvInitialGraphSpec, state: ArenaEnvGraphStateSpec) -> str: + """Emit a left-to-right mermaid graph of spatial and task constraints. + + Binary spatial constraints (reference is set) are drawn as solid edges: + subject -->|kind| reference + + Unary spatial constraints (no reference) are omitted from the graph and + listed below it by :func:`_render_unary_constraints` so their params are + visible. + + Task constraints with a child are drawn as dashed edges: + parent -.->|type| child + + object_reference nodes are drawn with a dotted edge to their parent node: + ref_node -. ref .-> parent_node + """ + lines = ["graph LR"] + + anchor_ids: set[str] = set() + edge_nodes: set[str] = set() + + # --- Spatial constraints (binary only) --- + for c in state.spatial_constraints: + kind = c.kind + if kind == "is_anchor": + anchor_ids.add(c.subject) + if c.reference is not None: + lines.append( + f" {_mermaid_id(c.subject)}[{_mermaid_label(c.subject)}]" + f" -->|{kind}| " + f"{_mermaid_id(c.reference)}[{_mermaid_label(c.reference)}]" + ) + edge_nodes.add(c.subject) + edge_nodes.add(c.reference) + + # --- Task constraints (dashed edges, binary only) --- + for tc in state.task_constraints: + if tc.child is not None: + lines.append( + f" {_mermaid_id(tc.parent)}[{_mermaid_label(tc.parent)}]" + f" -.->|{_mermaid_label(tc.type.value)}| " + f"{_mermaid_id(tc.child)}[{_mermaid_label(tc.child)}]" + ) + edge_nodes.add(tc.parent) + edge_nodes.add(tc.child) + + # Include every node from the spec so disconnected ones still appear. + for node in spec.nodes: + if node.id not in edge_nodes: + lines.append(f" {_mermaid_id(node.id)}[{_mermaid_label(node.id)}]") + + # --- object_reference → parent edges (dotted, structural) --- + # Use bare node IDs (no label re-declaration) — all nodes are already + # declared above either in constraint edges or in the disconnected-node block. + nodes_by_id = spec.nodes_by_id + for node in spec.nodes: + if node.type.value == "object_reference" and node.parent is not None: + if node.parent in nodes_by_id: + lines.append(f" {_mermaid_id(node.id)} -.->|ref| {_mermaid_id(node.parent)}") + + # Anchor highlight. + for anchor_id in anchor_ids: + lines.append(f" style {_mermaid_id(anchor_id)} fill:#3a7d44,color:#fff,stroke:#7fd17f,stroke-width:2px") + + # Color nodes by type for quick visual scanning. + type_palette = { + "background": ("#3a4f7a", "#7aa0d8"), + "embodiment": ("#7a3a3a", "#d87a7a"), + "object": ("#7a6b3a", "#d8c47a"), + "object_reference": ("#6b3a7a", "#c47ad8"), + "lighting": ("#3a7a7a", "#7ad8d8"), + } + for node in spec.nodes: + if node.id in anchor_ids: + continue # anchor style wins + fill, stroke = type_palette.get(node.type.value, ("#3a3d44", "#888")) + lines.append(f" style {_mermaid_id(node.id)} fill:{fill},color:#fff,stroke:{stroke}") + + return "\n".join(lines) + + +_MERMAID_ID_SAFE = re.compile(r"[^A-Za-z0-9_]") + + +def _mermaid_id(s: str) -> str: + """Mermaid node identifiers must be alphanumeric / underscore.""" + return _MERMAID_ID_SAFE.sub("_", s) + + +def _mermaid_label(s: str) -> str: + """Escape mermaid-significant characters inside node labels.""" + return s.replace('"', """).replace("|", "|") + + +def _render_unary_constraints(state: ArenaEnvGraphStateSpec) -> str: + """List constraints without a reference below the graph (anchors, position_limits, ...).""" + rows = [] + for c in state.spatial_constraints: + if c.reference is not None: + continue + params = ( + f' {html_lib.escape(yaml.safe_dump(c.params, default_flow_style=True).rstrip())}' + if c.params + else "" + ) + rows.append( + f'
  • {html_lib.escape(c.kind)}' + f" on {html_lib.escape(c.subject)}{params}
  • " + ) + if not rows: + return "" + return ( + f'
    Unary constraints ({len(rows)})' + f'
      {"".join(rows)}
    ' + ) + + +# --------------------------------------------------------------------------- +# Tasks panel +# --------------------------------------------------------------------------- + + +def _render_tasks_table(spec: ArenaEnvInitialGraphSpec) -> str: + if not spec.tasks: + return "

    No tasks defined.

    " + rows = [] + for i, t in enumerate(spec.tasks): + params_str = yaml.safe_dump(t.params, sort_keys=False).rstrip() if t.params else "(empty)" + desc = html_lib.escape(t.description or "") + rows.append( + "" + f"{i}" + f'{html_lib.escape(t.kind)}' + f"{desc}" + f"
    {html_lib.escape(params_str)}
    " + "" + ) + return ( + "" + "" + f"{''.join(rows)}" + "
    #kinddescriptionparams
    " + ) + + +# --------------------------------------------------------------------------- +# Node cards +# --------------------------------------------------------------------------- + + +def _render_node_cards(spec: ArenaEnvInitialGraphSpec) -> str: + return "\n".join(_render_one_node_card(node) for node in spec.nodes) + + +def _render_one_node_card(node: ArenaEnvGraphNodeSpec) -> str: + node_dict = node.model_dump(mode="json", exclude_none=True) + node_yaml = yaml.safe_dump(node_dict, sort_keys=False).rstrip() + thumb = _render_node_thumbnail(node) + return f"""
    + {thumb} +
    +
    {html_lib.escape(node.id)}
    + {html_lib.escape(node.type.value)} +
    +
    {html_lib.escape(node_yaml)}
    +
    """ + + +def _render_node_thumbnail(node: ArenaEnvGraphNodeSpec) -> str: + """Per-node placeholder thumbnail — two-letter initial.""" + initial = (node.name[:2] if node.name else "?").upper() + return f"""
    + {html_lib.escape(initial)} + {html_lib.escape(node.name)} +
    """ + + +# --------------------------------------------------------------------------- +# Styling +# --------------------------------------------------------------------------- + +_CSS = """ +:root { + --bg: #15181d; + --bg-elev: #1d2128; + --bg-elev2: #262b34; + --border: #2f343d; + --fg: #e4e6eb; + --fg-muted: #8a9099; + --accent: #7fd17f; +} +* { box-sizing: border-box; } +body { margin: 0; padding: 24px; font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); color: var(--fg); } +header { margin-bottom: 16px; } +header h1 { margin: 0; font-size: 28px; font-weight: 700; } +header .sub { margin: 4px 0 0; color: var(--fg-muted); font-size: 13px; } +main { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; + grid-template-areas: "graph nodes" "tasks nodes"; gap: 16px; } +.graph-panel { grid-area: graph; } +.tasks-panel { grid-area: tasks; } +.nodes-panel { grid-area: nodes; } +.panel { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 8px; padding: 16px; } +.panel h2 { margin: 0 0 12px; font-size: 16px; font-weight: 600; letter-spacing: 0.02em; } +.panel h2 .muted { color: var(--fg-muted); font-weight: 400; font-size: 13px; } +code { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; + background: var(--bg-elev2); padding: 1px 6px; border-radius: 4px; } +pre { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; + background: var(--bg-elev2); padding: 10px 12px; border-radius: 6px; margin: 0; + white-space: pre-wrap; word-break: break-word; } +.muted { color: var(--fg-muted); } +.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; + font-weight: 600; letter-spacing: 0.03em; background: var(--bg-elev2); color: var(--fg); } +.badge.type-background { background: #3a4f7a; } +.badge.type-embodiment { background: #7a3a3a; } +.badge.type-object { background: #7a6b3a; } +.badge.type-object_reference { background: #6b3a7a; } +.badge.type-lighting { background: #3a7a7a; } +.badge.type-is_anchor { background: #3a7d44; } +.badge.type-position_limits, .badge.type-at_pose, .badge.type-at_position { background: #6b3a7a; } +.badge.type-task { background: #2f343d; border: 1px solid #4a5; color: var(--accent); } +.mermaid { background: var(--bg-elev2); padding: 8px; border-radius: 6px; min-height: 220px; + display: flex; align-items: center; justify-content: center; } +.unary { margin-top: 12px; } +.unary summary { cursor: pointer; color: var(--fg-muted); font-size: 13px; padding: 4px 0; } +.unary ul { margin: 8px 0 0; padding-left: 20px; list-style: disc; color: var(--fg); } +.unary li { padding: 3px 0; } +table.tasks { width: 100%; border-collapse: collapse; } +table.tasks th, table.tasks td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); + vertical-align: top; font-size: 12px; } +table.tasks th { color: var(--fg-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } +table.tasks pre { padding: 6px 8px; font-size: 11px; } +.node-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } +.node-card { background: var(--bg-elev2); border: 1px solid var(--border); border-radius: 8px; + padding: 12px; display: flex; flex-direction: column; gap: 10px; } +.node-card .thumb { aspect-ratio: 1 / 1; background: linear-gradient(135deg, #2a2f37, #1c2026); + border-radius: 6px; display: flex; flex-direction: column; + align-items: center; justify-content: center; color: var(--fg-muted); + position: relative; overflow: hidden; } +.node-card .thumb-rendered { background: #0e1115; } +.node-card .thumb-rendered img { width: 100%; height: 100%; object-fit: contain; display: block; } +.node-card .thumb-rendered .thumb-name { position: absolute; bottom: 0; left: 0; right: 0; + padding: 4px 6px; background: rgba(15, 17, 21, 0.78); + color: var(--fg); margin: 0; } +.thumb-initial { font-size: 36px; font-weight: 700; color: var(--fg); opacity: 0.6; + font-family: ui-monospace, monospace; } +.thumb-name { font-size: 10px; margin-top: 6px; padding: 0 8px; text-align: center; word-break: break-word; } +.node-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.node-id { font-family: ui-monospace, monospace; font-size: 13px; font-weight: 600; word-break: break-all; } +.node-yaml { font-size: 11px; } +""" + + +if __name__ == "__main__": + main() diff --git a/isaaclab_arena/environments/arena_env_graph_spec.py b/isaaclab_arena/environments/arena_env_graph_spec.py index 0dc3d6db6..c11c09656 100644 --- a/isaaclab_arena/environments/arena_env_graph_spec.py +++ b/isaaclab_arena/environments/arena_env_graph_spec.py @@ -83,6 +83,18 @@ def write_yaml(self, path: str | Path) -> None: with Path(path).open("w", encoding="utf-8") as f: yaml.safe_dump(self.to_dict(), f, sort_keys=False) + def to_yaml(self, path: str | Path) -> Path: + """Write this spec to ``path`` as YAML. Creates parent dirs as needed. + + Returns the resolved :class:`Path` written. Symmetric with + :meth:`from_yaml`. + """ + out_path = Path(path) + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", encoding="utf-8") as f: + yaml.safe_dump(self.to_dict(), f, sort_keys=False) + return out_path + @property def nodes_by_id(self) -> dict[str, ArenaEnvGraphNodeSpec]: return {node.id: node for node in self.nodes} diff --git a/setup.py b/setup.py index 582f669ec..4f86c948b 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,11 @@ "tenacity", ] +ENV_REVIEW_DEPS = [ + "streamlit>=1.30", + "streamlit-ace>=0.1.1", +] + setup( name="isaaclab_arena", version=ISAACLAB_ARENA_VERSION_NUMBER, @@ -46,6 +51,7 @@ install_requires=RUNTIME_DEPS, extras_require={ "dev": DEV_DEPS, + "env-review": ENV_REVIEW_DEPS, }, zip_safe=False, ) From 080749130dc5e8093db56c80da19d99571449fd8 Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Mon, 15 Jun 2026 01:34:43 -0700 Subject: [PATCH 02/17] fix copyright years --- isaaclab_arena/agentic_environment_generation/review_app.py | 2 +- isaaclab_arena/agentic_environment_generation/review_graph.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/isaaclab_arena/agentic_environment_generation/review_app.py b/isaaclab_arena/agentic_environment_generation/review_app.py index 1a156190a..1e51132d7 100644 --- a/isaaclab_arena/agentic_environment_generation/review_app.py +++ b/isaaclab_arena/agentic_environment_generation/review_app.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). # All rights reserved. # # SPDX-License-Identifier: Apache-2.0 diff --git a/isaaclab_arena/agentic_environment_generation/review_graph.py b/isaaclab_arena/agentic_environment_generation/review_graph.py index 0bf2ecbda..edee396ac 100644 --- a/isaaclab_arena/agentic_environment_generation/review_graph.py +++ b/isaaclab_arena/agentic_environment_generation/review_graph.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). # All rights reserved. # # SPDX-License-Identifier: Apache-2.0 From 07c9d7f737f5431c5ef504fa5e50d8fabeaebff8 Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Mon, 15 Jun 2026 01:35:22 -0700 Subject: [PATCH 03/17] Move folder --- .../agentic_environment_generation/review_app.py | 0 .../agentic_environment_generation/review_graph.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {isaaclab_arena => isaaclab_arena_examples}/agentic_environment_generation/review_app.py (100%) rename {isaaclab_arena => isaaclab_arena_examples}/agentic_environment_generation/review_graph.py (100%) diff --git a/isaaclab_arena/agentic_environment_generation/review_app.py b/isaaclab_arena_examples/agentic_environment_generation/review_app.py similarity index 100% rename from isaaclab_arena/agentic_environment_generation/review_app.py rename to isaaclab_arena_examples/agentic_environment_generation/review_app.py diff --git a/isaaclab_arena/agentic_environment_generation/review_graph.py b/isaaclab_arena_examples/agentic_environment_generation/review_graph.py similarity index 100% rename from isaaclab_arena/agentic_environment_generation/review_graph.py rename to isaaclab_arena_examples/agentic_environment_generation/review_graph.py From f84bb1c3882c16a0b3b639f15c7a58c9c19437db Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Mon, 15 Jun 2026 01:37:28 -0700 Subject: [PATCH 04/17] Simplify setup --- setup.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/setup.py b/setup.py index 4f86c948b..c411f629b 100644 --- a/setup.py +++ b/setup.py @@ -26,9 +26,6 @@ "jupyter", "debugpy", "tenacity", -] - -ENV_REVIEW_DEPS = [ "streamlit>=1.30", "streamlit-ace>=0.1.1", ] @@ -51,7 +48,6 @@ install_requires=RUNTIME_DEPS, extras_require={ "dev": DEV_DEPS, - "env-review": ENV_REVIEW_DEPS, }, zip_safe=False, ) From 19ab3dfd6cb28cbab106d59233e5e6f8ec3c0dcf Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Mon, 15 Jun 2026 02:32:54 -0700 Subject: [PATCH 05/17] Refactor graph review tool into review_gui package. Replace monolithic review_graph.py and review_app.py with server, streamlit_ui, and a render subpackage; node thumbnails remain placeholders until SimApp lands. Signed-off-by: Qian Lin --- .../review_graph.py | 444 ------------------ .../review_gui/__init__.py | 6 + .../review_gui/render/__init__.py | 6 + .../review_gui/render/html_document.py | 54 +++ .../review_gui/render/mermaid_graph.py | 96 ++++ .../review_gui/render/panels.py | 80 ++++ .../review_gui/render/styles.py | 75 +++ .../review_gui/render/thumbnails.py | 20 + .../review_gui/server.py | 85 ++++ .../streamlit_ui.py} | 151 ++---- 10 files changed, 466 insertions(+), 551 deletions(-) delete mode 100644 isaaclab_arena_examples/agentic_environment_generation/review_graph.py create mode 100644 isaaclab_arena_examples/agentic_environment_generation/review_gui/__init__.py create mode 100644 isaaclab_arena_examples/agentic_environment_generation/review_gui/render/__init__.py create mode 100644 isaaclab_arena_examples/agentic_environment_generation/review_gui/render/html_document.py create mode 100644 isaaclab_arena_examples/agentic_environment_generation/review_gui/render/mermaid_graph.py create mode 100644 isaaclab_arena_examples/agentic_environment_generation/review_gui/render/panels.py create mode 100644 isaaclab_arena_examples/agentic_environment_generation/review_gui/render/styles.py create mode 100644 isaaclab_arena_examples/agentic_environment_generation/review_gui/render/thumbnails.py create mode 100644 isaaclab_arena_examples/agentic_environment_generation/review_gui/server.py rename isaaclab_arena_examples/agentic_environment_generation/{review_app.py => review_gui/streamlit_ui.py} (53%) diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_graph.py b/isaaclab_arena_examples/agentic_environment_generation/review_graph.py deleted file mode 100644 index edee396ac..000000000 --- a/isaaclab_arena_examples/agentic_environment_generation/review_graph.py +++ /dev/null @@ -1,444 +0,0 @@ -# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -"""ArenaEnvInitialGraphSpec review tool — Streamlit live editor. - -The CLI is a thin launcher: it boots the Streamlit app in ``review_app.py``. - -The tool accepts an ``ArenaEnvInitialGraphSpec`` YAML as input. The user -edits the spec directly inside the Streamlit editor and the preview updates -in real time. - -Three panels (dark dashboard style) inside the embedded view: - * Top-left — graph diagram (mermaid.js, CDN-loaded) of the initial-state - spatial constraints. Anchor nodes are highlighted; constraints without - a reference (is_anchor / position_limits / at_pose / ...) are listed below - the graph rather than rendered as self-loops. - * Bottom-left — task table (index, kind, description, params). - * Right — node card grid: type badge, asset name, and the per-node YAML - stanza. - -Usage: - /isaac-sim/python.sh -m isaaclab_arena.agentic_environment_generation.review_graph \\ - --yaml isaaclab_arena/tests/test_data/pick_and_place_maple_table_init_env_graph.yaml - - # Custom port: - /isaac-sim/python.sh -m isaaclab_arena.agentic_environment_generation.review_graph \\ - --yaml --port 8600 - -Public API used by ``review_app.py``: - * :func:`render_html_for_spec` — full HTML payload for a spec. -""" - -from __future__ import annotations - -import argparse -import html as html_lib -import os -import re -import subprocess -import sys -import yaml -from pathlib import Path - -from isaaclab_arena.environments.arena_env_graph_spec import ArenaEnvInitialGraphSpec -from isaaclab_arena.environments.arena_env_graph_types import ArenaEnvGraphNodeSpec, ArenaEnvGraphStateSpec - - -def main() -> None: - """CLI entry point — argparse parses the user's flags, then we hand off - to Streamlit. The actual interactive UI lives in ``review_app.py``. - """ - parser = argparse.ArgumentParser( - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--yaml", - type=Path, - required=True, - help="Path to an ArenaEnvInitialGraphSpec YAML file. The Streamlit app will open it for live editing.", - ) - parser.add_argument( - "--port", - type=int, - default=8501, - help="Streamlit server port (default: 8501).", - ) - args = parser.parse_args() - _serve_live_editor(args.yaml, port=args.port) - - -def _serve_live_editor(yaml_path: Path, port: int = 8501) -> None: - """Spawn ``streamlit run review_app.py -- --yaml `` and wait. - - We resolve ``review_app.py`` next to this file rather than going through - ``-m`` so Streamlit picks the path up cleanly (``streamlit run`` doesn't - accept module dotted-paths). - """ - app_path = Path(__file__).with_name("review_app.py") - if not app_path.exists(): - raise FileNotFoundError(f"Streamlit app not found at {app_path} — installation is incomplete.") - - cmd = [ - sys.executable, - "-m", - "streamlit", - "run", - str(app_path), - "--server.port", - str(port), - # Skip the email prompt the first time Streamlit runs in a fresh - # container — the live editor is a developer tool, not a hosted - # service, and an interactive prompt would block automation. - "--browser.gatherUsageStats", - "false", - # File watcher is a footgun here: Kit's ``SimulationApp`` boot is - # tens of seconds; we don't want Streamlit silently rerunning the - # script (and reissuing the cached_resource init) every time we - # save a source file during development. The user can still hit "R" - # in the browser to force a rerun if they want. - "--server.fileWatcherType", - "none", - "--", - "--yaml", - str(yaml_path.resolve()), - ] - - # Inherit env so the Streamlit subprocess sees PYTHONPATH / isaac-sim - # site-packages exactly the same way we do. - print(f"[review_graph] launching Streamlit live editor: {' '.join(cmd)}", file=sys.stderr) - try: - subprocess.run(cmd, env=os.environ.copy(), check=True) - except FileNotFoundError as exc: - # The plain ``pip install streamlit`` fails inside the isaaclab_arena - # container because streamlit≥1.30 needs uvicorn>=0.30 but Kit ships - # a bundled uvicorn==0.29 under a read-only /isaac-sim/extscache path. - # ``--user --ignore-installed`` sidesteps the rollback by writing - # everything to ~/.local (which is earlier on sys.path than extscache). - raise SystemExit( - "Streamlit is not installed. Inside the isaaclab_arena container run:\n" - " python -m pip install --user --ignore-installed streamlit streamlit-ace" - ) from exc - except KeyboardInterrupt: - # Normal exit path — user hit Ctrl-C in the terminal. - pass - - -# --------------------------------------------------------------------------- -# Public API consumed by review_app.py -# --------------------------------------------------------------------------- - - -def render_html_for_spec(spec: ArenaEnvInitialGraphSpec) -> str: - """Render the review HTML for ``spec`` with placeholder node thumbnails. - - Thin public alias of :func:`_render_html` so external entry points don't - have to reach into a private name. - """ - return _render_html(spec) - - -# --------------------------------------------------------------------------- -# Top-level HTML -# --------------------------------------------------------------------------- - - -def _render_html(spec: ArenaEnvInitialGraphSpec) -> str: - initial_state = spec.initial_state_spec - return f""" - - - -{html_lib.escape(spec.env_name)} — graph review - - - - -
    -

    {html_lib.escape(spec.env_name)}

    -

    {len(spec.nodes)} nodes · {len(spec.tasks)} tasks · initial state: {html_lib.escape(initial_state.id)}

    -
    -
    -
    -

    Spatial graph (initial state: {html_lib.escape(initial_state.id)})

    -
    {_render_mermaid(spec, initial_state)}
    - {_render_unary_constraints(initial_state)} -
    -
    -

    Tasks

    - {_render_tasks_table(spec)} -
    -
    -

    Nodes

    -
    {_render_node_cards(spec)}
    -
    -
    - - - -""" - - -# --------------------------------------------------------------------------- -# Mermaid graph rendering -# --------------------------------------------------------------------------- - - -def _render_mermaid(spec: ArenaEnvInitialGraphSpec, state: ArenaEnvGraphStateSpec) -> str: - """Emit a left-to-right mermaid graph of spatial and task constraints. - - Binary spatial constraints (reference is set) are drawn as solid edges: - subject -->|kind| reference - - Unary spatial constraints (no reference) are omitted from the graph and - listed below it by :func:`_render_unary_constraints` so their params are - visible. - - Task constraints with a child are drawn as dashed edges: - parent -.->|type| child - - object_reference nodes are drawn with a dotted edge to their parent node: - ref_node -. ref .-> parent_node - """ - lines = ["graph LR"] - - anchor_ids: set[str] = set() - edge_nodes: set[str] = set() - - # --- Spatial constraints (binary only) --- - for c in state.spatial_constraints: - kind = c.kind - if kind == "is_anchor": - anchor_ids.add(c.subject) - if c.reference is not None: - lines.append( - f" {_mermaid_id(c.subject)}[{_mermaid_label(c.subject)}]" - f" -->|{kind}| " - f"{_mermaid_id(c.reference)}[{_mermaid_label(c.reference)}]" - ) - edge_nodes.add(c.subject) - edge_nodes.add(c.reference) - - # --- Task constraints (dashed edges, binary only) --- - for tc in state.task_constraints: - if tc.child is not None: - lines.append( - f" {_mermaid_id(tc.parent)}[{_mermaid_label(tc.parent)}]" - f" -.->|{_mermaid_label(tc.type.value)}| " - f"{_mermaid_id(tc.child)}[{_mermaid_label(tc.child)}]" - ) - edge_nodes.add(tc.parent) - edge_nodes.add(tc.child) - - # Include every node from the spec so disconnected ones still appear. - for node in spec.nodes: - if node.id not in edge_nodes: - lines.append(f" {_mermaid_id(node.id)}[{_mermaid_label(node.id)}]") - - # --- object_reference → parent edges (dotted, structural) --- - # Use bare node IDs (no label re-declaration) — all nodes are already - # declared above either in constraint edges or in the disconnected-node block. - nodes_by_id = spec.nodes_by_id - for node in spec.nodes: - if node.type.value == "object_reference" and node.parent is not None: - if node.parent in nodes_by_id: - lines.append(f" {_mermaid_id(node.id)} -.->|ref| {_mermaid_id(node.parent)}") - - # Anchor highlight. - for anchor_id in anchor_ids: - lines.append(f" style {_mermaid_id(anchor_id)} fill:#3a7d44,color:#fff,stroke:#7fd17f,stroke-width:2px") - - # Color nodes by type for quick visual scanning. - type_palette = { - "background": ("#3a4f7a", "#7aa0d8"), - "embodiment": ("#7a3a3a", "#d87a7a"), - "object": ("#7a6b3a", "#d8c47a"), - "object_reference": ("#6b3a7a", "#c47ad8"), - "lighting": ("#3a7a7a", "#7ad8d8"), - } - for node in spec.nodes: - if node.id in anchor_ids: - continue # anchor style wins - fill, stroke = type_palette.get(node.type.value, ("#3a3d44", "#888")) - lines.append(f" style {_mermaid_id(node.id)} fill:{fill},color:#fff,stroke:{stroke}") - - return "\n".join(lines) - - -_MERMAID_ID_SAFE = re.compile(r"[^A-Za-z0-9_]") - - -def _mermaid_id(s: str) -> str: - """Mermaid node identifiers must be alphanumeric / underscore.""" - return _MERMAID_ID_SAFE.sub("_", s) - - -def _mermaid_label(s: str) -> str: - """Escape mermaid-significant characters inside node labels.""" - return s.replace('"', """).replace("|", "|") - - -def _render_unary_constraints(state: ArenaEnvGraphStateSpec) -> str: - """List constraints without a reference below the graph (anchors, position_limits, ...).""" - rows = [] - for c in state.spatial_constraints: - if c.reference is not None: - continue - params = ( - f' {html_lib.escape(yaml.safe_dump(c.params, default_flow_style=True).rstrip())}' - if c.params - else "" - ) - rows.append( - f'
  • {html_lib.escape(c.kind)}' - f" on {html_lib.escape(c.subject)}{params}
  • " - ) - if not rows: - return "" - return ( - f'
    Unary constraints ({len(rows)})' - f'
      {"".join(rows)}
    ' - ) - - -# --------------------------------------------------------------------------- -# Tasks panel -# --------------------------------------------------------------------------- - - -def _render_tasks_table(spec: ArenaEnvInitialGraphSpec) -> str: - if not spec.tasks: - return "

    No tasks defined.

    " - rows = [] - for i, t in enumerate(spec.tasks): - params_str = yaml.safe_dump(t.params, sort_keys=False).rstrip() if t.params else "(empty)" - desc = html_lib.escape(t.description or "") - rows.append( - "" - f"{i}" - f'{html_lib.escape(t.kind)}' - f"{desc}" - f"
    {html_lib.escape(params_str)}
    " - "" - ) - return ( - "" - "" - f"{''.join(rows)}" - "
    #kinddescriptionparams
    " - ) - - -# --------------------------------------------------------------------------- -# Node cards -# --------------------------------------------------------------------------- - - -def _render_node_cards(spec: ArenaEnvInitialGraphSpec) -> str: - return "\n".join(_render_one_node_card(node) for node in spec.nodes) - - -def _render_one_node_card(node: ArenaEnvGraphNodeSpec) -> str: - node_dict = node.model_dump(mode="json", exclude_none=True) - node_yaml = yaml.safe_dump(node_dict, sort_keys=False).rstrip() - thumb = _render_node_thumbnail(node) - return f"""
    - {thumb} -
    -
    {html_lib.escape(node.id)}
    - {html_lib.escape(node.type.value)} -
    -
    {html_lib.escape(node_yaml)}
    -
    """ - - -def _render_node_thumbnail(node: ArenaEnvGraphNodeSpec) -> str: - """Per-node placeholder thumbnail — two-letter initial.""" - initial = (node.name[:2] if node.name else "?").upper() - return f"""
    - {html_lib.escape(initial)} - {html_lib.escape(node.name)} -
    """ - - -# --------------------------------------------------------------------------- -# Styling -# --------------------------------------------------------------------------- - -_CSS = """ -:root { - --bg: #15181d; - --bg-elev: #1d2128; - --bg-elev2: #262b34; - --border: #2f343d; - --fg: #e4e6eb; - --fg-muted: #8a9099; - --accent: #7fd17f; -} -* { box-sizing: border-box; } -body { margin: 0; padding: 24px; font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--bg); color: var(--fg); } -header { margin-bottom: 16px; } -header h1 { margin: 0; font-size: 28px; font-weight: 700; } -header .sub { margin: 4px 0 0; color: var(--fg-muted); font-size: 13px; } -main { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; - grid-template-areas: "graph nodes" "tasks nodes"; gap: 16px; } -.graph-panel { grid-area: graph; } -.tasks-panel { grid-area: tasks; } -.nodes-panel { grid-area: nodes; } -.panel { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 8px; padding: 16px; } -.panel h2 { margin: 0 0 12px; font-size: 16px; font-weight: 600; letter-spacing: 0.02em; } -.panel h2 .muted { color: var(--fg-muted); font-weight: 400; font-size: 13px; } -code { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; - background: var(--bg-elev2); padding: 1px 6px; border-radius: 4px; } -pre { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; - background: var(--bg-elev2); padding: 10px 12px; border-radius: 6px; margin: 0; - white-space: pre-wrap; word-break: break-word; } -.muted { color: var(--fg-muted); } -.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; - font-weight: 600; letter-spacing: 0.03em; background: var(--bg-elev2); color: var(--fg); } -.badge.type-background { background: #3a4f7a; } -.badge.type-embodiment { background: #7a3a3a; } -.badge.type-object { background: #7a6b3a; } -.badge.type-object_reference { background: #6b3a7a; } -.badge.type-lighting { background: #3a7a7a; } -.badge.type-is_anchor { background: #3a7d44; } -.badge.type-position_limits, .badge.type-at_pose, .badge.type-at_position { background: #6b3a7a; } -.badge.type-task { background: #2f343d; border: 1px solid #4a5; color: var(--accent); } -.mermaid { background: var(--bg-elev2); padding: 8px; border-radius: 6px; min-height: 220px; - display: flex; align-items: center; justify-content: center; } -.unary { margin-top: 12px; } -.unary summary { cursor: pointer; color: var(--fg-muted); font-size: 13px; padding: 4px 0; } -.unary ul { margin: 8px 0 0; padding-left: 20px; list-style: disc; color: var(--fg); } -.unary li { padding: 3px 0; } -table.tasks { width: 100%; border-collapse: collapse; } -table.tasks th, table.tasks td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); - vertical-align: top; font-size: 12px; } -table.tasks th { color: var(--fg-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } -table.tasks pre { padding: 6px 8px; font-size: 11px; } -.node-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } -.node-card { background: var(--bg-elev2); border: 1px solid var(--border); border-radius: 8px; - padding: 12px; display: flex; flex-direction: column; gap: 10px; } -.node-card .thumb { aspect-ratio: 1 / 1; background: linear-gradient(135deg, #2a2f37, #1c2026); - border-radius: 6px; display: flex; flex-direction: column; - align-items: center; justify-content: center; color: var(--fg-muted); - position: relative; overflow: hidden; } -.node-card .thumb-rendered { background: #0e1115; } -.node-card .thumb-rendered img { width: 100%; height: 100%; object-fit: contain; display: block; } -.node-card .thumb-rendered .thumb-name { position: absolute; bottom: 0; left: 0; right: 0; - padding: 4px 6px; background: rgba(15, 17, 21, 0.78); - color: var(--fg); margin: 0; } -.thumb-initial { font-size: 36px; font-weight: 700; color: var(--fg); opacity: 0.6; - font-family: ui-monospace, monospace; } -.thumb-name { font-size: 10px; margin-top: 6px; padding: 0 8px; text-align: center; word-break: break-word; } -.node-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; } -.node-id { font-family: ui-monospace, monospace; font-size: 13px; font-weight: 600; word-break: break-all; } -.node-yaml { font-size: 11px; } -""" - - -if __name__ == "__main__": - main() diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/__init__.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/__init__.py new file mode 100644 index 000000000..821e672b4 --- /dev/null +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Live editor and HTML dashboard for :class:`~isaaclab_arena.environments.arena_env_graph_spec.ArenaEnvInitialGraphSpec`.""" diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/__init__.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/__init__.py new file mode 100644 index 000000000..2fd363882 --- /dev/null +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""HTML rendering backend for the initial-graph review dashboard.""" diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/html_document.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/html_document.py new file mode 100644 index 000000000..d83c6b275 --- /dev/null +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/html_document.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import html as html_lib + +from isaaclab_arena.environments.arena_env_graph_spec import ArenaEnvInitialGraphSpec +from isaaclab_arena_examples.agentic_environment_generation.review_gui.render.mermaid_graph import render_mermaid_graph +from isaaclab_arena_examples.agentic_environment_generation.review_gui.render.panels import ( + render_node_cards, + render_tasks_table, + render_unary_constraints, +) +from isaaclab_arena_examples.agentic_environment_generation.review_gui.render.styles import DASHBOARD_CSS + + +def render_html_for_spec(spec: ArenaEnvInitialGraphSpec) -> str: + """Render the self-contained review HTML dashboard for ``spec``.""" + initial_state = spec.initial_state_spec + return f""" + + + +{html_lib.escape(spec.env_name)} — graph review + + + + +
    +

    {html_lib.escape(spec.env_name)}

    +

    {len(spec.nodes)} nodes · {len(spec.tasks)} tasks · initial state: {html_lib.escape(initial_state.id)}

    +
    +
    +
    +

    Spatial graph (initial state: {html_lib.escape(initial_state.id)})

    +
    {render_mermaid_graph(spec, initial_state)}
    + {render_unary_constraints(initial_state)} +
    +
    +

    Tasks

    + {render_tasks_table(spec)} +
    +
    +

    Nodes

    +
    {render_node_cards(spec)}
    +
    +
    + + + +""" diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/mermaid_graph.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/mermaid_graph.py new file mode 100644 index 000000000..21b57ce48 --- /dev/null +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/mermaid_graph.py @@ -0,0 +1,96 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import re + +from isaaclab_arena.environments.arena_env_graph_spec import ArenaEnvInitialGraphSpec +from isaaclab_arena.environments.arena_env_graph_types import ArenaEnvGraphStateSpec + +_MERMAID_ID_SAFE = re.compile(r"[^A-Za-z0-9_]") + + +def render_mermaid_graph(spec: ArenaEnvInitialGraphSpec, state: ArenaEnvGraphStateSpec) -> str: + """Emit a left-to-right mermaid graph of spatial and task constraints. + + Binary spatial constraints (reference is set) are drawn as solid edges: + subject -->|kind| reference + + Unary spatial constraints (no reference) are omitted from the graph and + listed below it by :func:`render_unary_constraints` so their params are + visible. + + Task constraints with a child are drawn as dashed edges: + parent -.->|type| child + + object_reference nodes are drawn with a dotted edge to their parent node: + ref_node -. ref .-> parent_node + """ + lines = ["graph LR"] + + anchor_ids: set[str] = set() + edge_nodes: set[str] = set() + + for constraint in state.spatial_constraints: + kind = constraint.kind + if kind == "is_anchor": + anchor_ids.add(constraint.subject) + if constraint.reference is not None: + lines.append( + f" {_mermaid_id(constraint.subject)}[{_mermaid_label(constraint.subject)}]" + f" -->|{kind}| " + f"{_mermaid_id(constraint.reference)}[{_mermaid_label(constraint.reference)}]" + ) + edge_nodes.add(constraint.subject) + edge_nodes.add(constraint.reference) + + for task_constraint in state.task_constraints: + if task_constraint.child is not None: + lines.append( + f" {_mermaid_id(task_constraint.parent)}[{_mermaid_label(task_constraint.parent)}]" + f" -.->|{_mermaid_label(task_constraint.type.value)}| " + f"{_mermaid_id(task_constraint.child)}[{_mermaid_label(task_constraint.child)}]" + ) + edge_nodes.add(task_constraint.parent) + edge_nodes.add(task_constraint.child) + + for node in spec.nodes: + if node.id not in edge_nodes: + lines.append(f" {_mermaid_id(node.id)}[{_mermaid_label(node.id)}]") + + nodes_by_id = spec.nodes_by_id + for node in spec.nodes: + if node.type.value == "object_reference" and node.parent is not None: + if node.parent in nodes_by_id: + lines.append(f" {_mermaid_id(node.id)} -.->|ref| {_mermaid_id(node.parent)}") + + for anchor_id in anchor_ids: + lines.append(f" style {_mermaid_id(anchor_id)} fill:#3a7d44,color:#fff,stroke:#7fd17f,stroke-width:2px") + + type_palette = { + "background": ("#3a4f7a", "#7aa0d8"), + "embodiment": ("#7a3a3a", "#d87a7a"), + "object": ("#7a6b3a", "#d8c47a"), + "object_reference": ("#6b3a7a", "#c47ad8"), + "lighting": ("#3a7a7a", "#7ad8d8"), + } + for node in spec.nodes: + if node.id in anchor_ids: + continue + fill, stroke = type_palette.get(node.type.value, ("#3a3d44", "#888")) + lines.append(f" style {_mermaid_id(node.id)} fill:{fill},color:#fff,stroke:{stroke}") + + return "\n".join(lines) + + +def _mermaid_id(value: str) -> str: + """Mermaid node identifiers must be alphanumeric / underscore.""" + return _MERMAID_ID_SAFE.sub("_", value) + + +def _mermaid_label(value: str) -> str: + """Escape mermaid-significant characters inside node labels.""" + return value.replace('"', """).replace("|", "|") diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/panels.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/panels.py new file mode 100644 index 000000000..901b4c331 --- /dev/null +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/panels.py @@ -0,0 +1,80 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import html as html_lib +import yaml + +from isaaclab_arena.environments.arena_env_graph_spec import ArenaEnvInitialGraphSpec +from isaaclab_arena.environments.arena_env_graph_types import ArenaEnvGraphNodeSpec, ArenaEnvGraphStateSpec +from isaaclab_arena_examples.agentic_environment_generation.review_gui.render.thumbnails import ( + render_placeholder_thumbnail, +) + + +def render_unary_constraints(state: ArenaEnvGraphStateSpec) -> str: + """List constraints without a reference below the graph (anchors, position_limits, ...).""" + rows = [] + for constraint in state.spatial_constraints: + if constraint.reference is not None: + continue + params = ( + " {html_lib.escape(yaml.safe_dump(constraint.params, default_flow_style=True).rstrip())}' + if constraint.params + else "" + ) + rows.append( + f'
  • {html_lib.escape(constraint.kind)}' + f" on {html_lib.escape(constraint.subject)}{params}
  • " + ) + if not rows: + return "" + return ( + f'
    Unary constraints ({len(rows)})' + f'
      {"".join(rows)}
    ' + ) + + +def render_tasks_table(spec: ArenaEnvInitialGraphSpec) -> str: + if not spec.tasks: + return "

    No tasks defined.

    " + rows = [] + for index, task in enumerate(spec.tasks): + params_str = yaml.safe_dump(task.params, sort_keys=False).rstrip() if task.params else "(empty)" + description = html_lib.escape(task.description or "") + rows.append( + "" + f"{index}" + f'{html_lib.escape(task.kind)}' + f"{description}" + f"
    {html_lib.escape(params_str)}
    " + "" + ) + return ( + "" + "" + f"{''.join(rows)}" + "
    #kinddescriptionparams
    " + ) + + +def render_node_cards(spec: ArenaEnvInitialGraphSpec) -> str: + return "\n".join(render_node_card(node) for node in spec.nodes) + + +def render_node_card(node: ArenaEnvGraphNodeSpec) -> str: + node_dict = node.model_dump(mode="json", exclude_none=True) + node_yaml = yaml.safe_dump(node_dict, sort_keys=False).rstrip() + thumb = render_placeholder_thumbnail(node) + return f"""
    + {thumb} +
    +
    {html_lib.escape(node.id)}
    + {html_lib.escape(node.type.value)} +
    +
    {html_lib.escape(node_yaml)}
    +
    """ diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/styles.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/styles.py new file mode 100644 index 000000000..279ad349d --- /dev/null +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/styles.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +DASHBOARD_CSS = """ +:root { + --bg: #15181d; + --bg-elev: #1d2128; + --bg-elev2: #262b34; + --border: #2f343d; + --fg: #e4e6eb; + --fg-muted: #8a9099; + --accent: #7fd17f; +} +* { box-sizing: border-box; } +body { margin: 0; padding: 24px; font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); color: var(--fg); } +header { margin-bottom: 16px; } +header h1 { margin: 0; font-size: 28px; font-weight: 700; } +header .sub { margin: 4px 0 0; color: var(--fg-muted); font-size: 13px; } +main { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; + grid-template-areas: "graph nodes" "tasks nodes"; gap: 16px; } +.graph-panel { grid-area: graph; } +.tasks-panel { grid-area: tasks; } +.nodes-panel { grid-area: nodes; } +.panel { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 8px; padding: 16px; } +.panel h2 { margin: 0 0 12px; font-size: 16px; font-weight: 600; letter-spacing: 0.02em; } +.panel h2 .muted { color: var(--fg-muted); font-weight: 400; font-size: 13px; } +code { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; + background: var(--bg-elev2); padding: 1px 6px; border-radius: 4px; } +pre { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; + background: var(--bg-elev2); padding: 10px 12px; border-radius: 6px; margin: 0; + white-space: pre-wrap; word-break: break-word; } +.muted { color: var(--fg-muted); } +.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; + font-weight: 600; letter-spacing: 0.03em; background: var(--bg-elev2); color: var(--fg); } +.badge.type-background { background: #3a4f7a; } +.badge.type-embodiment { background: #7a3a3a; } +.badge.type-object { background: #7a6b3a; } +.badge.type-object_reference { background: #6b3a7a; } +.badge.type-lighting { background: #3a7a7a; } +.badge.type-is_anchor { background: #3a7d44; } +.badge.type-position_limits, .badge.type-at_pose, .badge.type-at_position { background: #6b3a7a; } +.badge.type-task { background: #2f343d; border: 1px solid #4a5; color: var(--accent); } +.mermaid { background: var(--bg-elev2); padding: 8px; border-radius: 6px; min-height: 220px; + display: flex; align-items: center; justify-content: center; } +.unary { margin-top: 12px; } +.unary summary { cursor: pointer; color: var(--fg-muted); font-size: 13px; padding: 4px 0; } +.unary ul { margin: 8px 0 0; padding-left: 20px; list-style: disc; color: var(--fg); } +.unary li { padding: 3px 0; } +table.tasks { width: 100%; border-collapse: collapse; } +table.tasks th, table.tasks td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); + vertical-align: top; font-size: 12px; } +table.tasks th { color: var(--fg-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } +table.tasks pre { padding: 6px 8px; font-size: 11px; } +.node-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } +.node-card { background: var(--bg-elev2); border: 1px solid var(--border); border-radius: 8px; + padding: 12px; display: flex; flex-direction: column; gap: 10px; } +.node-card .thumb { aspect-ratio: 1 / 1; background: linear-gradient(135deg, #2a2f37, #1c2026); + border-radius: 6px; display: flex; flex-direction: column; + align-items: center; justify-content: center; color: var(--fg-muted); + position: relative; overflow: hidden; } +.node-card .thumb-rendered { background: #0e1115; } +.node-card .thumb-rendered img { width: 100%; height: 100%; object-fit: contain; display: block; } +.node-card .thumb-rendered .thumb-name { position: absolute; bottom: 0; left: 0; right: 0; + padding: 4px 6px; background: rgba(15, 17, 21, 0.78); + color: var(--fg); margin: 0; } +.thumb-initial { font-size: 36px; font-weight: 700; color: var(--fg); opacity: 0.6; + font-family: ui-monospace, monospace; } +.thumb-name { font-size: 10px; margin-top: 6px; padding: 0 8px; text-align: center; word-break: break-word; } +.node-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.node-id { font-family: ui-monospace, monospace; font-size: 13px; font-weight: 600; word-break: break-all; } +.node-yaml { font-size: 11px; } +""" diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/thumbnails.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/thumbnails.py new file mode 100644 index 000000000..f2742ee8f --- /dev/null +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/thumbnails.py @@ -0,0 +1,20 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import html as html_lib + +from isaaclab_arena.environments.arena_env_graph_types import ArenaEnvGraphNodeSpec + + +# TODO(qianl): Replace placeholder thumbnails with sim-rendered snapshots . +def render_placeholder_thumbnail(node: ArenaEnvGraphNodeSpec) -> str: + """Per-node placeholder thumbnail — two-letter initial.""" + initial = (node.name[:2] if node.name else "?").upper() + return f"""
    + {html_lib.escape(initial)} + {html_lib.escape(node.name)} +
    """ diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/server.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/server.py new file mode 100644 index 000000000..4212cf6a7 --- /dev/null +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/server.py @@ -0,0 +1,85 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""CLI launcher for the ArenaEnvInitialGraphSpec live editor. + +Spawns Streamlit with :mod:`~isaaclab_arena_examples.agentic_environment_generation.review_gui.streamlit_ui`. + +Usage: + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server \\ + --yaml isaaclab_arena/tests/test_data/pick_and_place_maple_table_init_env_graph.yaml + + # Custom port: + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server \\ + --yaml --port 8600 +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--yaml", + type=Path, + required=True, + help="Path to an ArenaEnvInitialGraphSpec YAML file.", + ) + parser.add_argument( + "--port", + type=int, + default=8501, + help="Streamlit server port (default: 8501).", + ) + args = parser.parse_args() + serve_live_editor(args.yaml, port=args.port) + + +def serve_live_editor(yaml_path: Path, port: int = 8501) -> None: + """Spawn ``streamlit run streamlit_ui.py -- --yaml `` and wait.""" + app_path = Path(__file__).with_name("streamlit_ui.py") + if not app_path.exists(): + raise FileNotFoundError(f"Streamlit app not found at {app_path} — installation is incomplete.") + + cmd = [ + sys.executable, + "-m", + "streamlit", + "run", + str(app_path), + "--server.port", + str(port), + "--browser.gatherUsageStats", + "false", + "--server.fileWatcherType", + "none", + "--", + "--yaml", + str(yaml_path.resolve()), + ] + + print(f"[review_gui] launching Streamlit live editor: {' '.join(cmd)}", file=sys.stderr) + try: + subprocess.run(cmd, env=os.environ.copy(), check=True) + except FileNotFoundError as exc: + raise SystemExit( + "Streamlit is not installed. Inside the isaaclab_arena container run:\n" + " python -m pip install --user --ignore-installed streamlit streamlit-ace" + ) from exc + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_app.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py similarity index 53% rename from isaaclab_arena_examples/agentic_environment_generation/review_app.py rename to isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py index 1e51132d7..c234d14f3 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_app.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py @@ -3,22 +3,12 @@ # # SPDX-License-Identifier: Apache-2.0 -"""Streamlit-backed live editor for ArenaEnvInitialGraphSpec YAMLs. - -Wraps :func:`isaaclab_arena.agentic_environment_generation.review_graph.render_html_for_spec` -in a two-pane Streamlit page so the user can edit the ``ArenaEnvInitialGraphSpec`` -YAML directly in the browser and see the visualization update automatically. - -Launch (always via the wrapper in review_graph.py — handles streamlit flags): - /isaac-sim/python.sh -m isaaclab_arena.agentic_environment_generation.review_graph \\ - --yaml path/to/spec.yaml - -Design: - * Left pane — ``streamlit-ace`` YAML editor + validation badge + Save button. - Validation runs on every rerun (i.e. after each editor blur). When the YAML - is valid and has changed since the last render, the visualization updates - automatically — no button click required. - * Right pane — sandboxed iframe with the rendered review HTML. +"""Streamlit UI for the initial-graph live editor. + +Launch via :mod:`~isaaclab_arena_examples.agentic_environment_generation.review_gui.server`: + + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server \\ + --yaml isaaclab_arena/tests/test_data/pick_and_place_maple_table_init_env_graph.yaml """ from __future__ import annotations @@ -31,32 +21,24 @@ import streamlit as st -from isaaclab_arena.agentic_environment_generation.review_graph import render_html_for_spec from isaaclab_arena.environments.arena_env_graph_spec import ArenaEnvInitialGraphSpec +from isaaclab_arena_examples.agentic_environment_generation.review_gui.render.html_document import render_html_for_spec # Visualization iframe height. Tuned so the graph + tasks + node grid all # fit without an outer Streamlit scrollbar swallowing the inner one. _IFRAME_HEIGHT_PX = 1100 - -# --------------------------------------------------------------------------- -# Args + session-state init -# --------------------------------------------------------------------------- - - -def _parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--yaml", - type=Path, - required=True, - help="Path to the ArenaEnvInitialGraphSpec YAML to open in the editor.", - ) - return parser.parse_args() +_BROKEN_PLACEHOLDER_HTML = """ +

    No visualization yet — fix the YAML errors to auto-render.

    +""" @dataclass -class _ValidationResult: +class ValidationResult: + """Outcome of parsing and validating YAML text as an initial graph spec.""" + spec: ArenaEnvInitialGraphSpec | None error: str | None @@ -65,21 +47,29 @@ def is_valid(self) -> bool: return self.spec is not None -def _validate_yaml_text(text: str) -> _ValidationResult: +def validate_yaml_text(text: str) -> ValidationResult: + """Parse ``text`` as YAML and validate it as an :class:`ArenaEnvInitialGraphSpec`.""" try: raw = yaml.safe_load(text) spec = ArenaEnvInitialGraphSpec.model_validate(raw) - return _ValidationResult(spec=spec, error=None) + return ValidationResult(spec=spec, error=None) except Exception: - return _ValidationResult(spec=None, error=traceback.format_exc()) + return ValidationResult(spec=None, error=traceback.format_exc()) + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--yaml", + type=Path, + required=True, + help="Path to the ArenaEnvInitialGraphSpec YAML to open in the editor.", + ) + return parser.parse_args() -def _initialize_state(yaml_path: Path) -> None: - """Seed ``st.session_state`` from disk exactly once per session. - We key off ``_yaml_path`` so that if the user passes a different YAML on - a Streamlit reload (rare — usually the same), we reset cleanly. - """ +def initialize_state(yaml_path: Path) -> None: + """Seed ``st.session_state`` from disk exactly once per session.""" if st.session_state.get("_yaml_path") == str(yaml_path): return @@ -88,37 +78,17 @@ def _initialize_state(yaml_path: Path) -> None: st.session_state["_yaml_path"] = str(yaml_path) st.session_state["original_text"] = original_text st.session_state["edited_text"] = original_text - # The text whose render is currently displayed. Starts == original so the - # first paint shows the on-disk file (and "Regenerate" is correctly - # disabled until the user edits something). st.session_state["last_rendered_text"] = original_text st.session_state["save_path"] = str(yaml_path) - initial = _validate_yaml_text(original_text) + initial = validate_yaml_text(original_text) if not initial.is_valid: - # Defensive: if the on-disk file is already broken we still want to - # show *something*, but we won't pre-render it. The user fixes the - # YAML in the editor, then hits Regenerate. st.session_state["rendered_html"] = _BROKEN_PLACEHOLDER_HTML else: st.session_state["rendered_html"] = render_html_for_spec(initial.spec) -# Tiny standalone HTML used when the on-disk YAML is itself invalid. -_BROKEN_PLACEHOLDER_HTML = """ -

    No visualization yet — fix the YAML errors to auto-render.

    -""" - - -# --------------------------------------------------------------------------- -# UI panels -# --------------------------------------------------------------------------- - - -def _render_validation_badge(validation: _ValidationResult) -> None: - """Show a green tick + summary, or a red cross + the raw exception text.""" +def render_validation_badge(validation: ValidationResult) -> None: if validation.is_valid: spec = validation.spec st.success( @@ -130,8 +100,7 @@ def _render_validation_badge(validation: _ValidationResult) -> None: st.error(f"Invalid YAML\n\n```\n{validation.error}\n```", icon="🛑") -def _render_save_button(validation: _ValidationResult) -> None: - """Render the Save button. Disabled while the YAML is invalid.""" +def render_save_button(validation: ValidationResult) -> None: can_save = validation.is_valid save_path_str = st.session_state["save_path"] @@ -143,7 +112,6 @@ def _render_save_button(validation: _ValidationResult) -> None: ): try: Path(save_path_str).write_text(st.session_state["edited_text"], encoding="utf-8") - # Update "original" so future comparisons are against the saved file. st.session_state["original_text"] = st.session_state["edited_text"] st.toast(f"Saved → {save_path_str}", icon="💾") except OSError as exc: @@ -160,21 +128,10 @@ def _render_save_button(validation: _ValidationResult) -> None: st.session_state["save_path"] = new_path -def _render_editor_panel(yaml_path: Path) -> _ValidationResult: - """Left pane. Returns the validation result for the current editor text. - - Returning the validation result (rather than stashing it in session_state) - keeps the data flow inside one render pass and avoids a stale-state class - of bug where the badge and the buttons disagree. - """ - # Lazy import so the module is importable from environments that don't - # have streamlit-ace installed yet (we surface a clean error message - # rather than ImportError at module load). +def render_editor_panel(yaml_path: Path) -> ValidationResult: try: from streamlit_ace import st_ace # noqa: PLC0415 except ImportError as exc: - # See review_graph._serve_live_editor for why --user --ignore-installed - # is required inside the isaaclab_arena container. st.error( "`streamlit-ace` is not installed. Inside the isaaclab_arena container run:\n" "`python -m pip install --user --ignore-installed streamlit-ace`\n\n" @@ -186,8 +143,6 @@ def _render_editor_panel(yaml_path: Path) -> _ValidationResult: st.subheader("YAML editor") st.caption(f"Source: `{yaml_path}`") - # ``auto_update=False`` commits on blur / Ctrl+Enter rather than on every - # keystroke, showing an "Apply" button in the editor toolbar. new_text = st_ace( value=st.session_state["edited_text"], language="yaml", @@ -200,20 +155,14 @@ def _render_editor_panel(yaml_path: Path) -> _ValidationResult: wrap=False, auto_update=False, min_lines=30, - # Key bound to the YAML path so swapping --yaml between sessions - # forces ace to remount with the new content. key=f"ace_editor::{yaml_path}", ) if new_text is not None: st.session_state["edited_text"] = new_text - validation = _validate_yaml_text(st.session_state["edited_text"]) - _render_validation_badge(validation) + validation = validate_yaml_text(st.session_state["edited_text"]) + render_validation_badge(validation) - # Auto-render whenever the YAML is valid and has changed since the last - # render. This runs before the right pane is drawn, so the updated HTML - # is already in session_state when the iframe is mounted — no extra rerun - # needed. edited_since_render = st.session_state["edited_text"] != st.session_state["last_rendered_text"] if validation.is_valid and edited_since_render: with st.spinner("Rendering visualization…"): @@ -221,18 +170,14 @@ def _render_editor_panel(yaml_path: Path) -> _ValidationResult: st.session_state["last_rendered_text"] = st.session_state["edited_text"] st.toast("Visualization updated.", icon="🔄") - _render_save_button(validation) + render_save_button(validation) return validation -def _render_visualization_panel() -> None: - """Right pane — iframe-mount the cached rendered HTML.""" +def render_visualization_panel() -> None: st.subheader("Visualization") st.caption("Updates automatically when the YAML is valid.") - # ``st.components.v1.html`` wraps the payload in a sandboxed iframe, which - # is what we want — the mermaid CDN script and the static CSS stay - # isolated from Streamlit's own DOM. st.components.v1.html( st.session_state["rendered_html"], height=_IFRAME_HEIGHT_PX, @@ -240,11 +185,6 @@ def _render_visualization_panel() -> None: ) -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - - def main() -> None: st.set_page_config( page_title="ArenaEnvInitialGraphSpec live editor", @@ -252,24 +192,21 @@ def main() -> None: initial_sidebar_state="collapsed", ) - args = _parse_args() + args = parse_args() yaml_path = args.yaml.resolve() if not yaml_path.exists(): st.error(f"YAML file not found: {yaml_path}", icon="🛑") st.stop() - _initialize_state(yaml_path) + initialize_state(yaml_path) st.markdown("### ArenaEnvInitialGraphSpec live editor") left, right = st.columns([2, 3], gap="large") with left: - _render_editor_panel(yaml_path) + render_editor_panel(yaml_path) with right: - _render_visualization_panel() + render_visualization_panel() -# Streamlit invokes the script top-level on every rerun, so we run main() -# unconditionally. The standard ``if __name__ == "__main__"`` guard would -# also work under ``streamlit run`` but is unnecessary — this module is only -# ever loaded as the Streamlit entrypoint. +# Streamlit invokes the script top-level on every rerun. main() From 8d5ecabffca185d38e66f3adac68764afb12f51d Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Mon, 15 Jun 2026 03:34:24 -0700 Subject: [PATCH 06/17] Rename review_gui render module to dashboard. Replace html_document.render_html_for_spec with dashboard.render_dashboard_html. Signed-off-by: Qian Lin --- .../review_gui/render/{html_document.py => dashboard.py} | 4 ++-- .../review_gui/streamlit_ui.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename isaaclab_arena_examples/agentic_environment_generation/review_gui/render/{html_document.py => dashboard.py} (93%) diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/html_document.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/dashboard.py similarity index 93% rename from isaaclab_arena_examples/agentic_environment_generation/review_gui/render/html_document.py rename to isaaclab_arena_examples/agentic_environment_generation/review_gui/render/dashboard.py index d83c6b275..a4318c3d3 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/html_document.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/dashboard.py @@ -17,8 +17,8 @@ from isaaclab_arena_examples.agentic_environment_generation.review_gui.render.styles import DASHBOARD_CSS -def render_html_for_spec(spec: ArenaEnvInitialGraphSpec) -> str: - """Render the self-contained review HTML dashboard for ``spec``.""" +def render_dashboard_html(spec: ArenaEnvInitialGraphSpec) -> str: + """Render the self-contained review dashboard HTML for ``spec``.""" initial_state = spec.initial_state_spec return f""" diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py index c234d14f3..cbbab13ee 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py @@ -22,7 +22,7 @@ import streamlit as st from isaaclab_arena.environments.arena_env_graph_spec import ArenaEnvInitialGraphSpec -from isaaclab_arena_examples.agentic_environment_generation.review_gui.render.html_document import render_html_for_spec +from isaaclab_arena_examples.agentic_environment_generation.review_gui.render.dashboard import render_dashboard_html # Visualization iframe height. Tuned so the graph + tasks + node grid all # fit without an outer Streamlit scrollbar swallowing the inner one. @@ -85,7 +85,7 @@ def initialize_state(yaml_path: Path) -> None: if not initial.is_valid: st.session_state["rendered_html"] = _BROKEN_PLACEHOLDER_HTML else: - st.session_state["rendered_html"] = render_html_for_spec(initial.spec) + st.session_state["rendered_html"] = render_dashboard_html(initial.spec) def render_validation_badge(validation: ValidationResult) -> None: @@ -166,7 +166,7 @@ def render_editor_panel(yaml_path: Path) -> ValidationResult: edited_since_render = st.session_state["edited_text"] != st.session_state["last_rendered_text"] if validation.is_valid and edited_since_render: with st.spinner("Rendering visualization…"): - st.session_state["rendered_html"] = render_html_for_spec(validation.spec) + st.session_state["rendered_html"] = render_dashboard_html(validation.spec) st.session_state["last_rendered_text"] = st.session_state["edited_text"] st.toast("Visualization updated.", icon="🔄") From a1732002d69b4f23399fa06da7d5da2e735bf024 Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Mon, 15 Jun 2026 09:47:33 -0700 Subject: [PATCH 07/17] Reorder review GUI dashboard: nodes, graph with unary sidebar, tasks. Stack visualizer sections vertically with three node thumbnails per row and unary constraints beside the Mermaid spatial graph instead of below it. Signed-off-by: Qian Lin --- .../review_gui/render/dashboard.py | 18 ++++++++++----- .../review_gui/render/mermaid_graph.py | 2 +- .../review_gui/render/panels.py | 8 +++---- .../review_gui/render/styles.py | 22 +++++++++---------- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/dashboard.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/dashboard.py index a4318c3d3..39a135dd0 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/dashboard.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/dashboard.py @@ -34,19 +34,25 @@ def render_dashboard_html(spec: ArenaEnvInitialGraphSpec) -> str:

    {len(spec.nodes)} nodes · {len(spec.tasks)} tasks · initial state: {html_lib.escape(initial_state.id)}

    +
    +

    Nodes

    +
    {render_node_cards(spec)}
    +

    Spatial graph (initial state: {html_lib.escape(initial_state.id)})

    -
    {render_mermaid_graph(spec, initial_state)}
    - {render_unary_constraints(initial_state)} +
    +
    +
    {render_mermaid_graph(spec, initial_state)}
    +
    + +

    Tasks

    {render_tasks_table(spec)}
    -
    -

    Nodes

    -
    {render_node_cards(spec)}
    -
    diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/mermaid_graph.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/mermaid_graph.py index 21b57ce48..7ec9c3fb0 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/mermaid_graph.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/mermaid_graph.py @@ -20,7 +20,7 @@ def render_mermaid_graph(spec: ArenaEnvInitialGraphSpec, state: ArenaEnvGraphSta subject -->|kind| reference Unary spatial constraints (no reference) are omitted from the graph and - listed below it by :func:`render_unary_constraints` so their params are + listed to its right by :func:`render_unary_constraints` so their params are visible. Task constraints with a child are drawn as dashed edges: diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/panels.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/panels.py index 901b4c331..8cfbbdef0 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/panels.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/panels.py @@ -16,7 +16,7 @@ def render_unary_constraints(state: ArenaEnvGraphStateSpec) -> str: - """List constraints without a reference below the graph (anchors, position_limits, ...).""" + """List constraints without a reference beside the spatial graph.""" rows = [] for constraint in state.spatial_constraints: if constraint.reference is not None: @@ -32,10 +32,10 @@ def render_unary_constraints(state: ArenaEnvGraphStateSpec) -> str: f" on {html_lib.escape(constraint.subject)}{params}" ) if not rows: - return "" + return '

    No unary constraints.

    ' return ( - f'
    Unary constraints ({len(rows)})' - f'
      {"".join(rows)}
    ' + f'

    Unary constraints ({len(rows)})

    ' + f'
      {"".join(rows)}
    ' ) diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/styles.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/styles.py index 279ad349d..b2638549c 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/styles.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/styles.py @@ -19,14 +19,18 @@ header { margin-bottom: 16px; } header h1 { margin: 0; font-size: 28px; font-weight: 700; } header .sub { margin: 4px 0 0; color: var(--fg-muted); font-size: 13px; } -main { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; - grid-template-areas: "graph nodes" "tasks nodes"; gap: 16px; } -.graph-panel { grid-area: graph; } -.tasks-panel { grid-area: tasks; } -.nodes-panel { grid-area: nodes; } +main { display: flex; flex-direction: column; gap: 16px; } .panel { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 8px; padding: 16px; } .panel h2 { margin: 0 0 12px; font-size: 16px; font-weight: 600; letter-spacing: 0.02em; } .panel h2 .muted { color: var(--fg-muted); font-weight: 400; font-size: 13px; } +.graph-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(220px, 300px); gap: 16px; align-items: start; } +.graph-mermaid { min-width: 0; } +.graph-unary { background: var(--bg-elev2); border: 1px solid var(--border); border-radius: 6px; padding: 12px; } +.unary-heading { margin: 0 0 10px; font-size: 13px; font-weight: 600; letter-spacing: 0.02em; } +.unary-heading .muted { font-weight: 400; } +.unary-list { margin: 0; padding-left: 18px; list-style: disc; color: var(--fg); } +.unary-list li { padding: 4px 0; font-size: 12px; } +.unary-empty { margin: 0; font-size: 12px; } code { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; background: var(--bg-elev2); padding: 1px 6px; border-radius: 4px; } pre { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; @@ -44,17 +48,13 @@ .badge.type-position_limits, .badge.type-at_pose, .badge.type-at_position { background: #6b3a7a; } .badge.type-task { background: #2f343d; border: 1px solid #4a5; color: var(--accent); } .mermaid { background: var(--bg-elev2); padding: 8px; border-radius: 6px; min-height: 220px; - display: flex; align-items: center; justify-content: center; } -.unary { margin-top: 12px; } -.unary summary { cursor: pointer; color: var(--fg-muted); font-size: 13px; padding: 4px 0; } -.unary ul { margin: 8px 0 0; padding-left: 20px; list-style: disc; color: var(--fg); } -.unary li { padding: 3px 0; } + display: flex; align-items: center; justify-content: center; margin: 0; } table.tasks { width: 100%; border-collapse: collapse; } table.tasks th, table.tasks td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; font-size: 12px; } table.tasks th { color: var(--fg-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } table.tasks pre { padding: 6px 8px; font-size: 11px; } -.node-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } +.node-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } .node-card { background: var(--bg-elev2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; display: flex; flex-direction: column; gap: 10px; } .node-card .thumb { aspect-ratio: 1 / 1; background: linear-gradient(135deg, #2a2f37, #1c2026); From ab9251522070baab5fc12dcd88372978f022fbb8 Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Tue, 16 Jun 2026 08:24:20 -0700 Subject: [PATCH 08/17] Add prompt-driven generation to the review GUI and defer pxr on asset import. Wire LLM fetch plus in-process catalogue build and IntentCompiler into the live editor with optional --yaml, and lazy-load USD helpers so library registration avoids early pxr imports. Signed-off-by: Qian Lin --- .../environment_generation_agent.py | 32 ++- isaaclab_arena/assets/object.py | 13 +- isaaclab_arena/assets/object_library.py | 2 +- .../assets/object_spawn_defaults.py | 46 ++++ isaaclab_arena/assets/object_utils.py | 65 ++--- .../review_gui/server.py | 18 +- .../review_gui/streamlit_ui.py | 260 ++++++++++++++++-- 7 files changed, 361 insertions(+), 75 deletions(-) create mode 100644 isaaclab_arena/assets/object_spawn_defaults.py diff --git a/isaaclab_arena/agentic_environment_generation/environment_generation_agent.py b/isaaclab_arena/agentic_environment_generation/environment_generation_agent.py index b496f0cdc..cf191cf03 100644 --- a/isaaclab_arena/agentic_environment_generation/environment_generation_agent.py +++ b/isaaclab_arena/agentic_environment_generation/environment_generation_agent.py @@ -250,6 +250,33 @@ def generate_spec( A ``(EnvironmentIntentSpec, raw_response)`` tuple. The raw text is useful for debugging. """ + data, text = self.fetch_intent_from_prompt( + prompt, + asset_catalog=asset_catalog, + relation_catalog=relation_catalog, + task_catalog=task_catalog, + temperature=temperature, + max_tokens=max_tokens, + max_retries=max_retries, + ) + spec = EnvironmentIntentSpec.model_validate(data) + return spec, text + + def fetch_intent_from_prompt( + self, + prompt: str, + asset_catalog: AssetCatalogue | None = None, + relation_catalog: RelationCatalogue | None = None, + task_catalog: TaskCatalogue | None = None, + temperature: float = 0.2, + max_tokens: int = 4096, + max_retries: int = 3, + ) -> tuple[dict[str, Any], str]: + """Call the model and return parsed intent JSON without registry validation. + + Registry-backed :class:`EnvironmentIntentSpec` validation is left to the + caller (e.g. :class:`IntentCompiler` in the review GUI). + """ asset_catalog = asset_catalog or build_asset_catalogue() relation_catalog = relation_catalog or build_relation_catalogue() task_catalog = task_catalog or build_task_catalogue() @@ -268,7 +295,7 @@ def generate_spec( last_exc: Exception | None = None for attempt in range(1 + max_retries): if attempt > 0: - print(f"[generate_spec] retry {attempt}/{max_retries} after: {last_exc}", flush=True) + print(f"[fetch_intent_from_prompt] retry {attempt}/{max_retries} after: {last_exc}", flush=True) try: resp = self.client.chat.completions.create( @@ -299,8 +326,7 @@ def generate_spec( # (e.g. literal tabs) inside JSON strings — DeepSeek-v4-flash is known # to emit these. data = json.loads(text, strict=False) - spec = EnvironmentIntentSpec.model_validate(data) - return spec, text + return data, text except Exception as exc: last_exc = exc diff --git a/isaaclab_arena/assets/object.py b/isaaclab_arena/assets/object.py index e2a92af36..40e9d6089 100644 --- a/isaaclab_arena/assets/object.py +++ b/isaaclab_arena/assets/object.py @@ -11,12 +11,9 @@ from isaaclab.sim.spawners.spawner_cfg import SpawnerCfg from isaaclab_arena.assets.object_base import ObjectBase, ObjectType -from isaaclab_arena.assets.object_utils import detect_object_type from isaaclab_arena.relations.relations import RelationBase from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox, quaternion_to_90_deg_z_quarters from isaaclab_arena.utils.pose import Pose -from isaaclab_arena.utils.usd.rigid_bodies import find_shallowest_rigid_body -from isaaclab_arena.utils.usd_helpers import compute_local_bounding_box_from_usd, has_light, open_stage class Object(ObjectBase): @@ -49,6 +46,8 @@ def __init__( "object_type is None (indicating auto-detect) but usd_path is also None. usd_path is required to detect" " object type" ) + from isaaclab_arena.assets.object_utils import detect_object_type + object_type = detect_object_type(usd_path=usd_path) super().__init__(name=name, prim_path=prim_path, object_type=object_type, **kwargs) self.usd_path = usd_path @@ -69,6 +68,8 @@ def add_relation(self, relation: RelationBase) -> None: def get_bounding_box(self) -> AxisAlignedBoundingBox: """Get local bounding box (relative to object origin).""" + from isaaclab_arena.utils.usd_helpers import compute_local_bounding_box_from_usd + assert self.usd_path is not None if self.bounding_box is None: self.bounding_box = compute_local_bounding_box_from_usd(self.usd_path, self.scale) @@ -88,6 +89,8 @@ def get_world_bounding_box(self) -> AxisAlignedBoundingBox: return local_bbox.rotated_90_around_z(quarters).translated(self.initial_pose.position_xyz) def get_corners(self, pos: torch.Tensor) -> torch.Tensor: + from isaaclab_arena.utils.usd_helpers import compute_local_bounding_box_from_usd + assert self.usd_path is not None if self.bounding_box is None: self.bounding_box = compute_local_bounding_box_from_usd(self.usd_path, self.scale) @@ -107,6 +110,8 @@ def enable_reset_pose(self) -> None: def get_contact_sensor_cfg( self, contact_against_object: ObjectBase | None = None, usd_path: str | None = None ) -> ContactSensorCfg: + from isaaclab_arena.utils.usd.rigid_bodies import find_shallowest_rigid_body + assert self.object_type == ObjectType.RIGID, "Contact sensor is only supported for rigid objects" # We override this function from the parent class because in some assets, the rigid body # is not at the root of the USD file. To be robust to this, we find the shallowest rigid body @@ -177,6 +182,8 @@ def _generate_articulation_cfg(self) -> ArticulationCfg: return self._add_initial_pose_to_cfg(object_cfg) def _generate_base_cfg(self) -> AssetBaseCfg: + from isaaclab_arena.utils.usd_helpers import has_light, open_stage + assert self.object_type == ObjectType.BASE if self.spawner_cfg is None: with open_stage(self.usd_path) as stage: diff --git a/isaaclab_arena/assets/object_library.py b/isaaclab_arena/assets/object_library.py index e7bf8c82a..ad565e2ac 100644 --- a/isaaclab_arena/assets/object_library.py +++ b/isaaclab_arena/assets/object_library.py @@ -22,7 +22,7 @@ from isaaclab_arena.assets.lightwheel_lazy import LightwheelLazyPath from isaaclab_arena.assets.object import Object from isaaclab_arena.assets.object_base import ObjectType -from isaaclab_arena.assets.object_utils import ( +from isaaclab_arena.assets.object_spawn_defaults import ( EMPTY_ARTICULATION_INIT_STATE_CFG, RIGID_BODY_PROPS_HIGH_PRECISION, RIGID_BODY_PROPS_MEDIUM_PRECISION, diff --git a/isaaclab_arena/assets/object_spawn_defaults.py b/isaaclab_arena/assets/object_spawn_defaults.py new file mode 100644 index 000000000..4bf52e3f9 --- /dev/null +++ b/isaaclab_arena/assets/object_spawn_defaults.py @@ -0,0 +1,46 @@ +# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Spawn/physics defaults for library objects (no USD / pxr imports).""" + +import isaaclab.sim as sim_utils +from isaaclab.assets import ArticulationCfg + +# Predefined rigid body property configurations for assembly tasks +# High iteration count for precision tasks (peg/hole insertion) +RIGID_BODY_PROPS_HIGH_PRECISION = sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + max_depenetration_velocity=5.0, + linear_damping=0.0, + angular_damping=0.0, + max_linear_velocity=1000.0, + max_angular_velocity=3666.0, + enable_gyroscopic_forces=True, + solver_position_iteration_count=192, + solver_velocity_iteration_count=1, + max_contact_impulse=1e32, +) + +# Standard iteration count for gear mesh tasks +RIGID_BODY_PROPS_MEDIUM_PRECISION = sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + max_depenetration_velocity=5.0, + linear_damping=0.0, + angular_damping=0.0, + max_linear_velocity=1000.0, + max_angular_velocity=3666.0, + enable_gyroscopic_forces=True, + solver_position_iteration_count=32, + solver_velocity_iteration_count=32, + max_contact_impulse=1e32, +) + +# Initial state configuration for articulations without joints (e.g., rigid bodies treated as articulations). +# We explicitly set joint_pos and joint_vel to empty dicts to avoid the default pattern {".*": 0.0} in ArticulationCfg.InitialStateCfg, +# which would fail to match when there are no joints in the articulation. +EMPTY_ARTICULATION_INIT_STATE_CFG = ArticulationCfg.InitialStateCfg( + joint_pos={}, + joint_vel={}, +) diff --git a/isaaclab_arena/assets/object_utils.py b/isaaclab_arena/assets/object_utils.py index 0b0d2c289..84445f794 100644 --- a/isaaclab_arena/assets/object_utils.py +++ b/isaaclab_arena/assets/object_utils.py @@ -4,15 +4,28 @@ # SPDX-License-Identifier: Apache-2.0 -import isaaclab.sim as sim_utils -from isaaclab.assets import ArticulationCfg -from pxr import Usd +from typing import TYPE_CHECKING from isaaclab_arena.assets.object_base import ObjectType -from isaaclab_arena.utils.usd_helpers import get_prim_depth, is_articulation_root, is_rigid_body +from isaaclab_arena.assets.object_spawn_defaults import ( + EMPTY_ARTICULATION_INIT_STATE_CFG, + RIGID_BODY_PROPS_HIGH_PRECISION, + RIGID_BODY_PROPS_MEDIUM_PRECISION, +) + +if TYPE_CHECKING: + from pxr import Usd + +# Re-export spawn defaults for backward compatibility. +__all__ = [ + "EMPTY_ARTICULATION_INIT_STATE_CFG", + "RIGID_BODY_PROPS_HIGH_PRECISION", + "RIGID_BODY_PROPS_MEDIUM_PRECISION", + "detect_object_type", +] -def detect_object_type(usd_path: str | None = None, stage: Usd.Stage | None = None) -> ObjectType: +def detect_object_type(usd_path: str | None = None, stage: "Usd.Stage | None" = None) -> ObjectType: """Detect the object type of the asset Goes through the USD tree and detects the object type. The detection is based @@ -28,6 +41,10 @@ def detect_object_type(usd_path: str | None = None, stage: Usd.Stage | None = No Returns: The object type of the asset. """ + from pxr import Usd + + from isaaclab_arena.utils.usd_helpers import get_prim_depth, is_articulation_root, is_rigid_body + assert usd_path is not None or stage is not None, "Either usd_path or stage must be provided" assert usd_path is None or stage is None, "Either usd_path or stage must be provided" if usd_path is not None: @@ -62,41 +79,3 @@ def detect_object_type(usd_path: str | None = None, stage: Usd.Stage | None = No return ObjectType.ARTICULATION else: raise ValueError("This should not happen. There is an unknown USD type in the tree.") - - -# Predefined rigid body property configurations for assembly tasks -# High iteration count for precision tasks (peg/hole insertion) -RIGID_BODY_PROPS_HIGH_PRECISION = sim_utils.RigidBodyPropertiesCfg( - disable_gravity=False, - max_depenetration_velocity=5.0, - linear_damping=0.0, - angular_damping=0.0, - max_linear_velocity=1000.0, - max_angular_velocity=3666.0, - enable_gyroscopic_forces=True, - solver_position_iteration_count=192, - solver_velocity_iteration_count=1, - max_contact_impulse=1e32, -) - -# Standard iteration count for gear mesh tasks -RIGID_BODY_PROPS_MEDIUM_PRECISION = sim_utils.RigidBodyPropertiesCfg( - disable_gravity=False, - max_depenetration_velocity=5.0, - linear_damping=0.0, - angular_damping=0.0, - max_linear_velocity=1000.0, - max_angular_velocity=3666.0, - enable_gyroscopic_forces=True, - solver_position_iteration_count=32, - solver_velocity_iteration_count=32, - max_contact_impulse=1e32, -) - -# Initial state configuration for articulations without joints (e.g., rigid bodies treated as articulations). -# We explicitly set joint_pos and joint_vel to empty dicts to avoid the default pattern {".*": 0.0} in ArticulationCfg.InitialStateCfg, -# which would fail to match when there are no joints in the articulation. -EMPTY_ARTICULATION_INIT_STATE_CFG = ArticulationCfg.InitialStateCfg( - joint_pos={}, - joint_vel={}, -) diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/server.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/server.py index 4212cf6a7..ea0ea2bdd 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/server.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/server.py @@ -11,6 +11,12 @@ /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server \\ --yaml isaaclab_arena/tests/test_data/pick_and_place_maple_table_init_env_graph.yaml + # Prompt-only (empty editor until you generate or paste YAML): + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server + + # Prompt-only (empty editor until you generate or paste YAML): + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server + # Custom port: /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server \\ --yaml --port 8600 @@ -33,8 +39,8 @@ def main() -> None: parser.add_argument( "--yaml", type=Path, - required=True, - help="Path to an ArenaEnvInitialGraphSpec YAML file.", + default=None, + help="Optional ArenaEnvInitialGraphSpec YAML to open in the editor.", ) parser.add_argument( "--port", @@ -46,8 +52,8 @@ def main() -> None: serve_live_editor(args.yaml, port=args.port) -def serve_live_editor(yaml_path: Path, port: int = 8501) -> None: - """Spawn ``streamlit run streamlit_ui.py -- --yaml `` and wait.""" +def serve_live_editor(yaml_path: Path | None, port: int = 8501) -> None: + """Spawn ``streamlit run streamlit_ui.py`` and wait.""" app_path = Path(__file__).with_name("streamlit_ui.py") if not app_path.exists(): raise FileNotFoundError(f"Streamlit app not found at {app_path} — installation is incomplete.") @@ -65,9 +71,9 @@ def serve_live_editor(yaml_path: Path, port: int = 8501) -> None: "--server.fileWatcherType", "none", "--", - "--yaml", - str(yaml_path.resolve()), ] + if yaml_path is not None: + cmd.extend(["--yaml", str(yaml_path.resolve())]) print(f"[review_gui] launching Streamlit live editor: {' '.join(cmd)}", file=sys.stderr) try: diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py index cbbab13ee..eeb477080 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py @@ -9,6 +9,12 @@ /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server \\ --yaml isaaclab_arena/tests/test_data/pick_and_place_maple_table_init_env_graph.yaml + + # Prompt-only (empty editor until you generate or paste YAML): + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server + +Natural-language generation calls the LLM from Streamlit (``NV_API_KEY``) and +compiles the returned intent in-process with :class:`IntentCompiler`. """ from __future__ import annotations @@ -16,24 +22,42 @@ import argparse import traceback import yaml -from dataclasses import dataclass +from dataclasses import asdict, dataclass from pathlib import Path +from typing import Any import streamlit as st +from isaaclab_arena.agentic_environment_generation.asset_matcher import ASSET_ERROR_STAGES +from isaaclab_arena.agentic_environment_generation.environment_generation_agent import ( + AssetCatalogue, + EnvironmentGenerationAgent, + RelationCatalogue, + TaskCatalogue, + build_asset_catalogue, + build_relation_catalogue, + build_task_catalogue, +) +from isaaclab_arena.agentic_environment_generation.environment_intent_spec import EnvironmentIntentSpec +from isaaclab_arena.agentic_environment_generation.intent_compiler import IntentCompiler from isaaclab_arena.environments.arena_env_graph_spec import ArenaEnvInitialGraphSpec from isaaclab_arena_examples.agentic_environment_generation.review_gui.render.dashboard import render_dashboard_html -# Visualization iframe height. Tuned so the graph + tasks + node grid all -# fit without an outer Streamlit scrollbar swallowing the inner one. _IFRAME_HEIGHT_PX = 1100 +_DEFAULT_GENERATION_PROMPT = ( + "franka pick up avocado from the maple table and place it into a bowl on the table. " + "there are other veggies on the table as distractor" +) + _BROKEN_PLACEHOLDER_HTML = """

    No visualization yet — fix the YAML errors to auto-render.

    """ +_DEFAULT_SAVE_PATH = "isaaclab_arena_environments/llm_generated/generated_spec.yaml" + @dataclass class ValidationResult: @@ -47,35 +71,181 @@ def is_valid(self) -> bool: return self.spec is not None +@dataclass +class CatalogueBundle: + """Asset/relation/task vocabulary for the env-generation agent.""" + + asset_catalogue: AssetCatalogue + relation_catalogue: RelationCatalogue + task_catalogue: TaskCatalogue + + +@st.cache_resource(show_spinner="Building asset catalogues (first run)…") +def _get_catalogue_bundle() -> CatalogueBundle: + """Build and cache registry-backed catalogues for LLM prompt assembly.""" + return CatalogueBundle( + asset_catalogue=build_asset_catalogue(), + relation_catalogue=build_relation_catalogue(), + task_catalogue=build_task_catalogue(), + ) + + def validate_yaml_text(text: str) -> ValidationResult: """Parse ``text`` as YAML and validate it as an :class:`ArenaEnvInitialGraphSpec`.""" + if not text.strip(): + return ValidationResult(spec=None, error=None) + try: raw = yaml.safe_load(text) + except Exception: + return ValidationResult(spec=None, error=traceback.format_exc()) + + if raw is None: + return ValidationResult(spec=None, error="YAML is empty") + if not isinstance(raw, dict): + return ValidationResult(spec=None, error=f"Expected mapping, got {type(raw).__name__}") + + try: spec = ArenaEnvInitialGraphSpec.model_validate(raw) - return ValidationResult(spec=spec, error=None) except Exception: return ValidationResult(spec=None, error=traceback.format_exc()) + return ValidationResult(spec=spec, error=None) + + +def _get_generation_agent() -> EnvironmentGenerationAgent | None: + """Lazy-init the LLM agent when ``NV_API_KEY`` is available.""" + if st.session_state.get("generation_agent_error"): + return None + agent = st.session_state.get("generation_agent") + if agent is not None: + return agent + try: + agent = EnvironmentGenerationAgent() + except AssertionError as exc: + st.session_state["generation_agent_error"] = str(exc) + return None + except Exception as exc: + st.session_state["generation_agent_error"] = f"{type(exc).__name__}: {exc}" + return None + st.session_state["generation_agent"] = agent + st.session_state.pop("generation_agent_error", None) + return agent + + +def _format_trace_lines(trace: list[dict[str, Any]], *, errors_only: bool = False) -> str: + error_stages = ASSET_ERROR_STAGES | IntentCompiler._ERROR_TRACE_STAGES + lines: list[str] = [] + for event in trace: + stage = event.get("stage", "") + if errors_only and stage not in error_stages: + continue + chosen = event.get("chosen") + chosen_str = chosen if chosen is not None else "" + note = event.get("note") or "" + note_str = f" [{note}]" if note else "" + lines.append(f"{stage:34s} {event.get('query', ''):24s} -> {chosen_str}{note_str}") + return "\n".join(lines) + + +def _apply_generated_yaml(yaml_text: str) -> None: + """Push compiled spec YAML into the editor and force a re-render on the next pass.""" + st.session_state["edited_text"] = yaml_text + st.session_state["last_rendered_text"] = "" + st.session_state["editor_version"] = st.session_state.get("editor_version", 0) + 1 + + +def run_generation_pipeline(prompt: str) -> tuple[bool, str]: + """Call the LLM, compile intent in-process, and load YAML into the editor.""" + prompt = prompt.strip() + if not prompt: + return False, "Enter a prompt describing the environment." + + agent = _get_generation_agent() + if agent is None: + err = st.session_state.get( + "generation_agent_error", + "Set NV_API_KEY in the environment before generating specs.", + ) + return False, err + + try: + catalogues = _get_catalogue_bundle() + except Exception: + return False, traceback.format_exc() + + try: + intent_data, _raw = agent.fetch_intent_from_prompt( + prompt, + asset_catalog=catalogues.asset_catalogue, + relation_catalog=catalogues.relation_catalogue, + task_catalog=catalogues.task_catalogue, + ) + except Exception: + return False, traceback.format_exc() + + try: + intent = EnvironmentIntentSpec.model_validate(intent_data) + compiler = IntentCompiler() + spec = compiler.compile(intent) + yaml_text = yaml.safe_dump(spec.to_dict(), sort_keys=False) + trace = [asdict(event) for event in compiler.trace] + has_resolution_errors = compiler.has_resolution_errors + reasoning = intent.reasoning + except Exception: + return False, traceback.format_exc() + + _apply_generated_yaml(yaml_text) + + if reasoning: + st.session_state["last_generation_reasoning"] = reasoning + if trace: + st.session_state["last_generation_trace"] = trace + + if has_resolution_errors: + error_trace = _format_trace_lines(trace, errors_only=True) + return ( + True, + ( + "Spec generated with resolution warnings — review the trace below and edit the YAML as needed.\n\n" + f"{error_trace}" + ), + ) + + return True, "Spec generated and loaded into the YAML editor." + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--yaml", type=Path, - required=True, - help="Path to the ArenaEnvInitialGraphSpec YAML to open in the editor.", + default=None, + help="Optional path to an ArenaEnvInitialGraphSpec YAML to open in the editor.", ) return parser.parse_args() -def initialize_state(yaml_path: Path) -> None: +def initialize_state(yaml_path: Path | None) -> None: """Seed ``st.session_state`` from disk exactly once per session.""" - if st.session_state.get("_yaml_path") == str(yaml_path): + session_key = str(yaml_path.resolve()) if yaml_path is not None else "" + if st.session_state.get("_yaml_path") == session_key: + return + + st.session_state["_yaml_path"] = session_key + st.session_state.setdefault("generation_prompt", _DEFAULT_GENERATION_PROMPT) + st.session_state.setdefault("editor_version", 0) + + if yaml_path is None: + st.session_state["original_text"] = "" + st.session_state["edited_text"] = "" + st.session_state["last_rendered_text"] = "" + st.session_state["rendered_html"] = "" + st.session_state["save_path"] = _DEFAULT_SAVE_PATH return original_text = yaml_path.read_text(encoding="utf-8") - st.session_state["_yaml_path"] = str(yaml_path) st.session_state["original_text"] = original_text st.session_state["edited_text"] = original_text st.session_state["last_rendered_text"] = original_text @@ -89,6 +259,8 @@ def initialize_state(yaml_path: Path) -> None: def render_validation_badge(validation: ValidationResult) -> None: + if validation.spec is None and validation.error is None: + return if validation.is_valid: spec = validation.spec st.success( @@ -103,15 +275,18 @@ def render_validation_badge(validation: ValidationResult) -> None: def render_save_button(validation: ValidationResult) -> None: can_save = validation.is_valid save_path_str = st.session_state["save_path"] + save_label = f"Save to {Path(save_path_str).name}" if save_path_str else "Save YAML" if st.button( - f"Save to {Path(save_path_str).name}", + save_label, disabled=not can_save, use_container_width=True, help=f"Writes the editor contents to {save_path_str}. Disabled while YAML is invalid.", ): try: - Path(save_path_str).write_text(st.session_state["edited_text"], encoding="utf-8") + out_path = Path(save_path_str) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(st.session_state["edited_text"], encoding="utf-8") st.session_state["original_text"] = st.session_state["edited_text"] st.toast(f"Saved → {save_path_str}", icon="💾") except OSError as exc: @@ -122,13 +297,13 @@ def render_save_button(validation: ValidationResult) -> None: "Save path", value=save_path_str, key="save_path_input", - help="Defaults to the YAML file passed via --yaml.", + help="Defaults to the YAML file passed via --yaml, or a generated-spec path when none was given.", ) if new_path and new_path != save_path_str: st.session_state["save_path"] = new_path -def render_editor_panel(yaml_path: Path) -> ValidationResult: +def render_editor_panel(yaml_path: Path | None) -> ValidationResult: try: from streamlit_ace import st_ace # noqa: PLC0415 except ImportError as exc: @@ -141,8 +316,12 @@ def render_editor_panel(yaml_path: Path) -> ValidationResult: st.stop() st.subheader("YAML editor") - st.caption(f"Source: `{yaml_path}`") + if yaml_path is not None: + st.caption(f"Source: `{yaml_path}`") + else: + st.caption("No file loaded — generate a spec or paste YAML.") + editor_key = str(yaml_path) if yaml_path is not None else "new" new_text = st_ace( value=st.session_state["edited_text"], language="yaml", @@ -155,7 +334,7 @@ def render_editor_panel(yaml_path: Path) -> ValidationResult: wrap=False, auto_update=False, min_lines=30, - key=f"ace_editor::{yaml_path}", + key=f"ace_editor::{editor_key}::{st.session_state.get('editor_version', 0)}", ) if new_text is not None: st.session_state["edited_text"] = new_text @@ -174,10 +353,53 @@ def render_editor_panel(yaml_path: Path) -> ValidationResult: return validation +def render_generation_panel() -> None: + """Prompt input and generate-spec controls (top of the left column).""" + st.subheader("Generate from prompt") + st.caption("Calls the env-generation agent (LLM) then compiles intent in-process.") + + prompt = st.text_area( + "Prompt", + value=st.session_state.get("generation_prompt", _DEFAULT_GENERATION_PROMPT), + height=120, + placeholder="Describe the robot task, scene, objects, and distractors…", + ) + st.session_state["generation_prompt"] = prompt + + agent_error = st.session_state.get("generation_agent_error") + if agent_error: + st.info(f"LLM agent unavailable: {agent_error}", icon="ℹ️") + + if st.button("Generate & compile", type="primary", use_container_width=True): + with st.spinner("Generating spec (LLM call + intent compile)…"): + ok, message = run_generation_pipeline(st.session_state["generation_prompt"]) + if ok: + if "resolution warnings" in message: + st.warning(message, icon="⚠️") + else: + st.success(message, icon="✅") + st.rerun() + else: + st.error(f"Generation failed\n\n```\n{message}\n```", icon="🛑") + + reasoning = st.session_state.get("last_generation_reasoning") + if reasoning: + with st.expander("Agent reasoning (last run)", expanded=False): + st.markdown(reasoning) + + trace = st.session_state.get("last_generation_trace") + if trace: + with st.expander("Resolution trace (last run)", expanded=False): + st.code(_format_trace_lines(trace), language=None) + + def render_visualization_panel() -> None: st.subheader("Visualization") - st.caption("Updates automatically when the YAML is valid.") + if not st.session_state.get("last_rendered_text", "").strip(): + st.caption("Generate or enter valid YAML to see the visualization.") + return + st.caption("Updates automatically when the YAML is valid.") st.components.v1.html( st.session_state["rendered_html"], height=_IFRAME_HEIGHT_PX, @@ -193,8 +415,8 @@ def main() -> None: ) args = parse_args() - yaml_path = args.yaml.resolve() - if not yaml_path.exists(): + yaml_path = args.yaml.resolve() if args.yaml is not None else None + if yaml_path is not None and not yaml_path.exists(): st.error(f"YAML file not found: {yaml_path}", icon="🛑") st.stop() @@ -203,10 +425,10 @@ def main() -> None: st.markdown("### ArenaEnvInitialGraphSpec live editor") left, right = st.columns([2, 3], gap="large") with left: + render_generation_panel() render_editor_panel(yaml_path) with right: render_visualization_panel() -# Streamlit invokes the script top-level on every rerun. main() From e7579e5dab29b813b6a02b38ceb6136c8ba6a869 Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Tue, 16 Jun 2026 09:05:43 -0700 Subject: [PATCH 09/17] Remove redundant to_yaml from ArenaEnvGraphSpecBase. write_yaml already validates and serializes; to_yaml was unused in the repo. Signed-off-by: Qian Lin --- isaaclab_arena/environments/arena_env_graph_spec.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/isaaclab_arena/environments/arena_env_graph_spec.py b/isaaclab_arena/environments/arena_env_graph_spec.py index c11c09656..0dc3d6db6 100644 --- a/isaaclab_arena/environments/arena_env_graph_spec.py +++ b/isaaclab_arena/environments/arena_env_graph_spec.py @@ -83,18 +83,6 @@ def write_yaml(self, path: str | Path) -> None: with Path(path).open("w", encoding="utf-8") as f: yaml.safe_dump(self.to_dict(), f, sort_keys=False) - def to_yaml(self, path: str | Path) -> Path: - """Write this spec to ``path`` as YAML. Creates parent dirs as needed. - - Returns the resolved :class:`Path` written. Symmetric with - :meth:`from_yaml`. - """ - out_path = Path(path) - out_path.parent.mkdir(parents=True, exist_ok=True) - with out_path.open("w", encoding="utf-8") as f: - yaml.safe_dump(self.to_dict(), f, sort_keys=False) - return out_path - @property def nodes_by_id(self) -> dict[str, ArenaEnvGraphNodeSpec]: return {node.id: node for node in self.nodes} From 846594db267600645ac95819d6a42156e36e6f87 Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Tue, 16 Jun 2026 09:12:46 -0700 Subject: [PATCH 10/17] Revert asset pxr import deferral. build_asset_catalogue() after SimApp init: ~10.0s without deferral vs ~9.9s with deferral; pxr loads in both cases once ensure_assets_registered imports the object libraries. Signed-off-by: Qian Lin --- isaaclab_arena/assets/object.py | 13 +--- isaaclab_arena/assets/object_library.py | 2 +- .../assets/object_spawn_defaults.py | 46 ------------- isaaclab_arena/assets/object_utils.py | 65 ++++++++++++------- 4 files changed, 47 insertions(+), 79 deletions(-) delete mode 100644 isaaclab_arena/assets/object_spawn_defaults.py diff --git a/isaaclab_arena/assets/object.py b/isaaclab_arena/assets/object.py index 40e9d6089..e2a92af36 100644 --- a/isaaclab_arena/assets/object.py +++ b/isaaclab_arena/assets/object.py @@ -11,9 +11,12 @@ from isaaclab.sim.spawners.spawner_cfg import SpawnerCfg from isaaclab_arena.assets.object_base import ObjectBase, ObjectType +from isaaclab_arena.assets.object_utils import detect_object_type from isaaclab_arena.relations.relations import RelationBase from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox, quaternion_to_90_deg_z_quarters from isaaclab_arena.utils.pose import Pose +from isaaclab_arena.utils.usd.rigid_bodies import find_shallowest_rigid_body +from isaaclab_arena.utils.usd_helpers import compute_local_bounding_box_from_usd, has_light, open_stage class Object(ObjectBase): @@ -46,8 +49,6 @@ def __init__( "object_type is None (indicating auto-detect) but usd_path is also None. usd_path is required to detect" " object type" ) - from isaaclab_arena.assets.object_utils import detect_object_type - object_type = detect_object_type(usd_path=usd_path) super().__init__(name=name, prim_path=prim_path, object_type=object_type, **kwargs) self.usd_path = usd_path @@ -68,8 +69,6 @@ def add_relation(self, relation: RelationBase) -> None: def get_bounding_box(self) -> AxisAlignedBoundingBox: """Get local bounding box (relative to object origin).""" - from isaaclab_arena.utils.usd_helpers import compute_local_bounding_box_from_usd - assert self.usd_path is not None if self.bounding_box is None: self.bounding_box = compute_local_bounding_box_from_usd(self.usd_path, self.scale) @@ -89,8 +88,6 @@ def get_world_bounding_box(self) -> AxisAlignedBoundingBox: return local_bbox.rotated_90_around_z(quarters).translated(self.initial_pose.position_xyz) def get_corners(self, pos: torch.Tensor) -> torch.Tensor: - from isaaclab_arena.utils.usd_helpers import compute_local_bounding_box_from_usd - assert self.usd_path is not None if self.bounding_box is None: self.bounding_box = compute_local_bounding_box_from_usd(self.usd_path, self.scale) @@ -110,8 +107,6 @@ def enable_reset_pose(self) -> None: def get_contact_sensor_cfg( self, contact_against_object: ObjectBase | None = None, usd_path: str | None = None ) -> ContactSensorCfg: - from isaaclab_arena.utils.usd.rigid_bodies import find_shallowest_rigid_body - assert self.object_type == ObjectType.RIGID, "Contact sensor is only supported for rigid objects" # We override this function from the parent class because in some assets, the rigid body # is not at the root of the USD file. To be robust to this, we find the shallowest rigid body @@ -182,8 +177,6 @@ def _generate_articulation_cfg(self) -> ArticulationCfg: return self._add_initial_pose_to_cfg(object_cfg) def _generate_base_cfg(self) -> AssetBaseCfg: - from isaaclab_arena.utils.usd_helpers import has_light, open_stage - assert self.object_type == ObjectType.BASE if self.spawner_cfg is None: with open_stage(self.usd_path) as stage: diff --git a/isaaclab_arena/assets/object_library.py b/isaaclab_arena/assets/object_library.py index ad565e2ac..e7bf8c82a 100644 --- a/isaaclab_arena/assets/object_library.py +++ b/isaaclab_arena/assets/object_library.py @@ -22,7 +22,7 @@ from isaaclab_arena.assets.lightwheel_lazy import LightwheelLazyPath from isaaclab_arena.assets.object import Object from isaaclab_arena.assets.object_base import ObjectType -from isaaclab_arena.assets.object_spawn_defaults import ( +from isaaclab_arena.assets.object_utils import ( EMPTY_ARTICULATION_INIT_STATE_CFG, RIGID_BODY_PROPS_HIGH_PRECISION, RIGID_BODY_PROPS_MEDIUM_PRECISION, diff --git a/isaaclab_arena/assets/object_spawn_defaults.py b/isaaclab_arena/assets/object_spawn_defaults.py deleted file mode 100644 index 4bf52e3f9..000000000 --- a/isaaclab_arena/assets/object_spawn_defaults.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -"""Spawn/physics defaults for library objects (no USD / pxr imports).""" - -import isaaclab.sim as sim_utils -from isaaclab.assets import ArticulationCfg - -# Predefined rigid body property configurations for assembly tasks -# High iteration count for precision tasks (peg/hole insertion) -RIGID_BODY_PROPS_HIGH_PRECISION = sim_utils.RigidBodyPropertiesCfg( - disable_gravity=False, - max_depenetration_velocity=5.0, - linear_damping=0.0, - angular_damping=0.0, - max_linear_velocity=1000.0, - max_angular_velocity=3666.0, - enable_gyroscopic_forces=True, - solver_position_iteration_count=192, - solver_velocity_iteration_count=1, - max_contact_impulse=1e32, -) - -# Standard iteration count for gear mesh tasks -RIGID_BODY_PROPS_MEDIUM_PRECISION = sim_utils.RigidBodyPropertiesCfg( - disable_gravity=False, - max_depenetration_velocity=5.0, - linear_damping=0.0, - angular_damping=0.0, - max_linear_velocity=1000.0, - max_angular_velocity=3666.0, - enable_gyroscopic_forces=True, - solver_position_iteration_count=32, - solver_velocity_iteration_count=32, - max_contact_impulse=1e32, -) - -# Initial state configuration for articulations without joints (e.g., rigid bodies treated as articulations). -# We explicitly set joint_pos and joint_vel to empty dicts to avoid the default pattern {".*": 0.0} in ArticulationCfg.InitialStateCfg, -# which would fail to match when there are no joints in the articulation. -EMPTY_ARTICULATION_INIT_STATE_CFG = ArticulationCfg.InitialStateCfg( - joint_pos={}, - joint_vel={}, -) diff --git a/isaaclab_arena/assets/object_utils.py b/isaaclab_arena/assets/object_utils.py index 84445f794..0b0d2c289 100644 --- a/isaaclab_arena/assets/object_utils.py +++ b/isaaclab_arena/assets/object_utils.py @@ -4,28 +4,15 @@ # SPDX-License-Identifier: Apache-2.0 -from typing import TYPE_CHECKING +import isaaclab.sim as sim_utils +from isaaclab.assets import ArticulationCfg +from pxr import Usd from isaaclab_arena.assets.object_base import ObjectType -from isaaclab_arena.assets.object_spawn_defaults import ( - EMPTY_ARTICULATION_INIT_STATE_CFG, - RIGID_BODY_PROPS_HIGH_PRECISION, - RIGID_BODY_PROPS_MEDIUM_PRECISION, -) - -if TYPE_CHECKING: - from pxr import Usd - -# Re-export spawn defaults for backward compatibility. -__all__ = [ - "EMPTY_ARTICULATION_INIT_STATE_CFG", - "RIGID_BODY_PROPS_HIGH_PRECISION", - "RIGID_BODY_PROPS_MEDIUM_PRECISION", - "detect_object_type", -] +from isaaclab_arena.utils.usd_helpers import get_prim_depth, is_articulation_root, is_rigid_body -def detect_object_type(usd_path: str | None = None, stage: "Usd.Stage | None" = None) -> ObjectType: +def detect_object_type(usd_path: str | None = None, stage: Usd.Stage | None = None) -> ObjectType: """Detect the object type of the asset Goes through the USD tree and detects the object type. The detection is based @@ -41,10 +28,6 @@ def detect_object_type(usd_path: str | None = None, stage: "Usd.Stage | None" = Returns: The object type of the asset. """ - from pxr import Usd - - from isaaclab_arena.utils.usd_helpers import get_prim_depth, is_articulation_root, is_rigid_body - assert usd_path is not None or stage is not None, "Either usd_path or stage must be provided" assert usd_path is None or stage is None, "Either usd_path or stage must be provided" if usd_path is not None: @@ -79,3 +62,41 @@ def detect_object_type(usd_path: str | None = None, stage: "Usd.Stage | None" = return ObjectType.ARTICULATION else: raise ValueError("This should not happen. There is an unknown USD type in the tree.") + + +# Predefined rigid body property configurations for assembly tasks +# High iteration count for precision tasks (peg/hole insertion) +RIGID_BODY_PROPS_HIGH_PRECISION = sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + max_depenetration_velocity=5.0, + linear_damping=0.0, + angular_damping=0.0, + max_linear_velocity=1000.0, + max_angular_velocity=3666.0, + enable_gyroscopic_forces=True, + solver_position_iteration_count=192, + solver_velocity_iteration_count=1, + max_contact_impulse=1e32, +) + +# Standard iteration count for gear mesh tasks +RIGID_BODY_PROPS_MEDIUM_PRECISION = sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + max_depenetration_velocity=5.0, + linear_damping=0.0, + angular_damping=0.0, + max_linear_velocity=1000.0, + max_angular_velocity=3666.0, + enable_gyroscopic_forces=True, + solver_position_iteration_count=32, + solver_velocity_iteration_count=32, + max_contact_impulse=1e32, +) + +# Initial state configuration for articulations without joints (e.g., rigid bodies treated as articulations). +# We explicitly set joint_pos and joint_vel to empty dicts to avoid the default pattern {".*": 0.0} in ArticulationCfg.InitialStateCfg, +# which would fail to match when there are no joints in the articulation. +EMPTY_ARTICULATION_INIT_STATE_CFG = ArticulationCfg.InitialStateCfg( + joint_pos={}, + joint_vel={}, +) From 97c82d7cd8b8941d7c89c6495dd9b033e2cef705 Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Tue, 16 Jun 2026 09:15:40 -0700 Subject: [PATCH 11/17] Revert fetch_intent_from_prompt split. The review GUI still validated EnvironmentIntentSpec after fetch_intent_from_prompt, so the split added indirection without skipping work; generate_spec inlines the LLM call and validation again. Signed-off-by: Qian Lin --- .../environment_generation_agent.py | 32 ++----------------- .../review_gui/streamlit_ui.py | 4 +-- 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/isaaclab_arena/agentic_environment_generation/environment_generation_agent.py b/isaaclab_arena/agentic_environment_generation/environment_generation_agent.py index cf191cf03..b496f0cdc 100644 --- a/isaaclab_arena/agentic_environment_generation/environment_generation_agent.py +++ b/isaaclab_arena/agentic_environment_generation/environment_generation_agent.py @@ -250,33 +250,6 @@ def generate_spec( A ``(EnvironmentIntentSpec, raw_response)`` tuple. The raw text is useful for debugging. """ - data, text = self.fetch_intent_from_prompt( - prompt, - asset_catalog=asset_catalog, - relation_catalog=relation_catalog, - task_catalog=task_catalog, - temperature=temperature, - max_tokens=max_tokens, - max_retries=max_retries, - ) - spec = EnvironmentIntentSpec.model_validate(data) - return spec, text - - def fetch_intent_from_prompt( - self, - prompt: str, - asset_catalog: AssetCatalogue | None = None, - relation_catalog: RelationCatalogue | None = None, - task_catalog: TaskCatalogue | None = None, - temperature: float = 0.2, - max_tokens: int = 4096, - max_retries: int = 3, - ) -> tuple[dict[str, Any], str]: - """Call the model and return parsed intent JSON without registry validation. - - Registry-backed :class:`EnvironmentIntentSpec` validation is left to the - caller (e.g. :class:`IntentCompiler` in the review GUI). - """ asset_catalog = asset_catalog or build_asset_catalogue() relation_catalog = relation_catalog or build_relation_catalogue() task_catalog = task_catalog or build_task_catalogue() @@ -295,7 +268,7 @@ def fetch_intent_from_prompt( last_exc: Exception | None = None for attempt in range(1 + max_retries): if attempt > 0: - print(f"[fetch_intent_from_prompt] retry {attempt}/{max_retries} after: {last_exc}", flush=True) + print(f"[generate_spec] retry {attempt}/{max_retries} after: {last_exc}", flush=True) try: resp = self.client.chat.completions.create( @@ -326,7 +299,8 @@ def fetch_intent_from_prompt( # (e.g. literal tabs) inside JSON strings — DeepSeek-v4-flash is known # to emit these. data = json.loads(text, strict=False) - return data, text + spec = EnvironmentIntentSpec.model_validate(data) + return spec, text except Exception as exc: last_exc = exc diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py index eeb477080..ccc3a914a 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py @@ -38,7 +38,6 @@ build_relation_catalogue, build_task_catalogue, ) -from isaaclab_arena.agentic_environment_generation.environment_intent_spec import EnvironmentIntentSpec from isaaclab_arena.agentic_environment_generation.intent_compiler import IntentCompiler from isaaclab_arena.environments.arena_env_graph_spec import ArenaEnvInitialGraphSpec from isaaclab_arena_examples.agentic_environment_generation.review_gui.render.dashboard import render_dashboard_html @@ -175,7 +174,7 @@ def run_generation_pipeline(prompt: str) -> tuple[bool, str]: return False, traceback.format_exc() try: - intent_data, _raw = agent.fetch_intent_from_prompt( + intent, _raw = agent.generate_spec( prompt, asset_catalog=catalogues.asset_catalogue, relation_catalog=catalogues.relation_catalogue, @@ -185,7 +184,6 @@ def run_generation_pipeline(prompt: str) -> tuple[bool, str]: return False, traceback.format_exc() try: - intent = EnvironmentIntentSpec.model_validate(intent_data) compiler = IntentCompiler() spec = compiler.compile(intent) yaml_text = yaml.safe_dump(spec.to_dict(), sort_keys=False) From 2a42552c2bd6d02e93a08e98ebc2cbfe3fba2437 Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Tue, 16 Jun 2026 09:19:58 -0700 Subject: [PATCH 12/17] Move review GUI launcher to gui_runner.py. Relocate the Streamlit CLI from review_gui/server.py to the parent package so the launcher sits beside review_gui/ rather than inside it. Signed-off-by: Qian Lin --- .../{review_gui/server.py => gui_runner.py} | 12 +++++++----- .../review_gui/streamlit_ui.py | 6 +++--- 2 files changed, 10 insertions(+), 8 deletions(-) rename isaaclab_arena_examples/agentic_environment_generation/{review_gui/server.py => gui_runner.py} (91%) diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/server.py b/isaaclab_arena_examples/agentic_environment_generation/gui_runner.py similarity index 91% rename from isaaclab_arena_examples/agentic_environment_generation/review_gui/server.py rename to isaaclab_arena_examples/agentic_environment_generation/gui_runner.py index ea0ea2bdd..b48435e4f 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/server.py +++ b/isaaclab_arena_examples/agentic_environment_generation/gui_runner.py @@ -8,17 +8,17 @@ Spawns Streamlit with :mod:`~isaaclab_arena_examples.agentic_environment_generation.review_gui.streamlit_ui`. Usage: - /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server \\ + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner \\ --yaml isaaclab_arena/tests/test_data/pick_and_place_maple_table_init_env_graph.yaml # Prompt-only (empty editor until you generate or paste YAML): - /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner # Prompt-only (empty editor until you generate or paste YAML): - /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner # Custom port: - /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server \\ + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner \\ --yaml --port 8600 """ @@ -30,6 +30,8 @@ import sys from pathlib import Path +_REVIEW_GUI_DIR = Path(__file__).resolve().parent / "review_gui" + def main() -> None: parser = argparse.ArgumentParser( @@ -54,7 +56,7 @@ def main() -> None: def serve_live_editor(yaml_path: Path | None, port: int = 8501) -> None: """Spawn ``streamlit run streamlit_ui.py`` and wait.""" - app_path = Path(__file__).with_name("streamlit_ui.py") + app_path = _REVIEW_GUI_DIR / "streamlit_ui.py" if not app_path.exists(): raise FileNotFoundError(f"Streamlit app not found at {app_path} — installation is incomplete.") diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py index ccc3a914a..cda1b7611 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py @@ -5,13 +5,13 @@ """Streamlit UI for the initial-graph live editor. -Launch via :mod:`~isaaclab_arena_examples.agentic_environment_generation.review_gui.server`: +Launch via :mod:`~isaaclab_arena_examples.agentic_environment_generation.gui_runner`: - /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server \\ + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner \\ --yaml isaaclab_arena/tests/test_data/pick_and_place_maple_table_init_env_graph.yaml # Prompt-only (empty editor until you generate or paste YAML): - /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.review_gui.server + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner Natural-language generation calls the LLM from Streamlit (``NV_API_KEY``) and compiles the returned intent in-process with :class:`IntentCompiler`. From 3dbd1ce6bd1e9e4f284cbd3e75b6359f2cfabc15 Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Tue, 16 Jun 2026 09:21:22 -0700 Subject: [PATCH 13/17] Add missing docstrings to review GUI panel renderers. Document render_tasks_table, render_node_cards, and render_node_card in panels.py to match the existing render_unary_constraints style. Signed-off-by: Qian Lin --- .../agentic_environment_generation/review_gui/render/panels.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/panels.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/panels.py index 8cfbbdef0..6ab00637d 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/panels.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/panels.py @@ -40,6 +40,7 @@ def render_unary_constraints(state: ArenaEnvGraphStateSpec) -> str: def render_tasks_table(spec: ArenaEnvInitialGraphSpec) -> str: + """Render task rows as an HTML table for the dashboard tasks panel.""" if not spec.tasks: return "

    No tasks defined.

    " rows = [] @@ -63,10 +64,12 @@ def render_tasks_table(spec: ArenaEnvInitialGraphSpec) -> str: def render_node_cards(spec: ArenaEnvInitialGraphSpec) -> str: + """Render one card per graph node for the dashboard nodes panel.""" return "\n".join(render_node_card(node) for node in spec.nodes) def render_node_card(node: ArenaEnvGraphNodeSpec) -> str: + """Render a single node card with placeholder thumbnail and YAML dump.""" node_dict = node.model_dump(mode="json", exclude_none=True) node_yaml = yaml.safe_dump(node_dict, sort_keys=False).rstrip() thumb = render_placeholder_thumbnail(node) From 804bf5dcbac89d3ca7866edbd40b6100a6eaae5e Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Tue, 16 Jun 2026 09:22:32 -0700 Subject: [PATCH 14/17] Add module docstring to review GUI styles. Signed-off-by: Qian Lin --- .../agentic_environment_generation/review_gui/render/styles.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/styles.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/styles.py index b2638549c..ece136de4 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/styles.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/render/styles.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: Apache-2.0 +"""Dark-theme CSS for the review GUI embedded HTML dashboard.""" + DASHBOARD_CSS = """ :root { --bg: #15181d; From 0f77a3351c662d6a26d3e27a7a49049ab3999ec0 Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Tue, 16 Jun 2026 09:23:31 -0700 Subject: [PATCH 15/17] Make prompt-only mode the default in launcher docs. Remove the duplicate usage line and list the no-args launch first in gui_runner.py and streamlit_ui.py. Signed-off-by: Qian Lin --- .../agentic_environment_generation/gui_runner.py | 10 ++++------ .../review_gui/streamlit_ui.py | 7 ++++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/isaaclab_arena_examples/agentic_environment_generation/gui_runner.py b/isaaclab_arena_examples/agentic_environment_generation/gui_runner.py index b48435e4f..8bf2ae3c5 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/gui_runner.py +++ b/isaaclab_arena_examples/agentic_environment_generation/gui_runner.py @@ -8,14 +8,12 @@ Spawns Streamlit with :mod:`~isaaclab_arena_examples.agentic_environment_generation.review_gui.streamlit_ui`. Usage: - /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner \\ - --yaml isaaclab_arena/tests/test_data/pick_and_place_maple_table_init_env_graph.yaml - - # Prompt-only (empty editor until you generate or paste YAML): + # Default — prompt-only (empty editor until you generate or paste YAML): /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner - # Prompt-only (empty editor until you generate or paste YAML): - /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner + # Open an existing spec: + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner \\ + --yaml isaaclab_arena/tests/test_data/pick_and_place_maple_table_init_env_graph.yaml # Custom port: /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner \\ diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py index cda1b7611..92432a76a 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py @@ -7,12 +7,13 @@ Launch via :mod:`~isaaclab_arena_examples.agentic_environment_generation.gui_runner`: + # Default — prompt-only (empty editor until you generate or paste YAML): + /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner + + # Open an existing spec: /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner \\ --yaml isaaclab_arena/tests/test_data/pick_and_place_maple_table_init_env_graph.yaml - # Prompt-only (empty editor until you generate or paste YAML): - /isaac-sim/python.sh -m isaaclab_arena_examples.agentic_environment_generation.gui_runner - Natural-language generation calls the LLM from Streamlit (``NV_API_KEY``) and compiles the returned intent in-process with :class:`IntentCompiler`. """ From 1f77d6e841d6ec8abef87cf8e559b0bd3e9db6e7 Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Tue, 16 Jun 2026 09:24:30 -0700 Subject: [PATCH 16/17] Add missing docstrings in review GUI streamlit_ui. Document the remaining helpers and panel renderers so every public entry point in the Streamlit app has a summary docstring. Signed-off-by: Qian Lin --- .../review_gui/streamlit_ui.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py index 92432a76a..75404fb75 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py @@ -68,6 +68,7 @@ class ValidationResult: @property def is_valid(self) -> bool: + """True when ``spec`` parsed successfully.""" return self.spec is not None @@ -134,6 +135,7 @@ def _get_generation_agent() -> EnvironmentGenerationAgent | None: def _format_trace_lines(trace: list[dict[str, Any]], *, errors_only: bool = False) -> str: + """Format intent-compiler trace events as fixed-width log lines.""" error_stages = ASSET_ERROR_STAGES | IntentCompiler._ERROR_TRACE_STAGES lines: list[str] = [] for event in trace: @@ -215,6 +217,7 @@ def run_generation_pipeline(prompt: str) -> tuple[bool, str]: def parse_args() -> argparse.Namespace: + """Parse Streamlit CLI args forwarded after ``--`` by :mod:`gui_runner`.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--yaml", @@ -258,6 +261,7 @@ def initialize_state(yaml_path: Path | None) -> None: def render_validation_badge(validation: ValidationResult) -> None: + """Show a success or error badge for the current editor YAML.""" if validation.spec is None and validation.error is None: return if validation.is_valid: @@ -272,6 +276,7 @@ def render_validation_badge(validation: ValidationResult) -> None: def render_save_button(validation: ValidationResult) -> None: + """Render save controls and optional save-path editor.""" can_save = validation.is_valid save_path_str = st.session_state["save_path"] save_label = f"Save to {Path(save_path_str).name}" if save_path_str else "Save YAML" @@ -303,6 +308,7 @@ def render_save_button(validation: ValidationResult) -> None: def render_editor_panel(yaml_path: Path | None) -> ValidationResult: + """Render the ACE YAML editor and refresh the dashboard when text changes.""" try: from streamlit_ace import st_ace # noqa: PLC0415 except ImportError as exc: @@ -393,6 +399,7 @@ def render_generation_panel() -> None: def render_visualization_panel() -> None: + """Embed the rendered dashboard HTML in the right-hand column.""" st.subheader("Visualization") if not st.session_state.get("last_rendered_text", "").strip(): st.caption("Generate or enter valid YAML to see the visualization.") @@ -407,6 +414,7 @@ def render_visualization_panel() -> None: def main() -> None: + """Build the two-column Streamlit layout for generation, editing, and preview.""" st.set_page_config( page_title="ArenaEnvInitialGraphSpec live editor", layout="wide", From 54410acce52d624f59391a46c5e9050ff336bdeb Mon Sep 17 00:00:00 2001 From: Qian Lin Date: Tue, 16 Jun 2026 09:28:14 -0700 Subject: [PATCH 17/17] Simplify YAML validation in the review GUI editor. Cache validation per editor text, drop the duplicate validate on file load, and pre-render generated specs from the compiler output instead of re-parsing on the next Streamlit rerun. Signed-off-by: Qian Lin --- .../review_gui/streamlit_ui.py | 77 +++++++++++-------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py index 75404fb75..c3b5b0a59 100644 --- a/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py +++ b/isaaclab_arena_examples/agentic_environment_generation/review_gui/streamlit_ui.py @@ -93,25 +93,29 @@ def _get_catalogue_bundle() -> CatalogueBundle: def validate_yaml_text(text: str) -> ValidationResult: """Parse ``text`` as YAML and validate it as an :class:`ArenaEnvInitialGraphSpec`.""" - if not text.strip(): - return ValidationResult(spec=None, error=None) - - try: - raw = yaml.safe_load(text) - except Exception: - return ValidationResult(spec=None, error=traceback.format_exc()) - - if raw is None: - return ValidationResult(spec=None, error="YAML is empty") - if not isinstance(raw, dict): - return ValidationResult(spec=None, error=f"Expected mapping, got {type(raw).__name__}") + cached_text = st.session_state.get("_validation_text") + cached_result = st.session_state.get("_validation_result") + if cached_text == text and isinstance(cached_result, ValidationResult): + return cached_result - try: - spec = ArenaEnvInitialGraphSpec.model_validate(raw) - except Exception: - return ValidationResult(spec=None, error=traceback.format_exc()) + if not text.strip(): + result = ValidationResult(spec=None, error=None) + else: + try: + raw = yaml.safe_load(text) + if raw is None: + result = ValidationResult(spec=None, error="YAML is empty") + elif not isinstance(raw, dict): + result = ValidationResult(spec=None, error=f"Expected mapping, got {type(raw).__name__}") + else: + spec = ArenaEnvInitialGraphSpec.from_dict(raw) + result = ValidationResult(spec=spec, error=None) + except Exception: + result = ValidationResult(spec=None, error=traceback.format_exc()) - return ValidationResult(spec=spec, error=None) + st.session_state["_validation_text"] = text + st.session_state["_validation_result"] = result + return result def _get_generation_agent() -> EnvironmentGenerationAgent | None: @@ -150,11 +154,19 @@ def _format_trace_lines(trace: list[dict[str, Any]], *, errors_only: bool = Fals return "\n".join(lines) -def _apply_generated_yaml(yaml_text: str) -> None: - """Push compiled spec YAML into the editor and force a re-render on the next pass.""" +def _apply_generated_yaml(yaml_text: str, *, spec: ArenaEnvInitialGraphSpec | None = None) -> None: + """Push compiled spec YAML into the editor and sync the dashboard preview.""" st.session_state["edited_text"] = yaml_text - st.session_state["last_rendered_text"] = "" st.session_state["editor_version"] = st.session_state.get("editor_version", 0) + 1 + st.session_state["last_rendered_text"] = yaml_text + if spec is not None: + st.session_state["rendered_html"] = render_dashboard_html(spec) + st.session_state["_validation_text"] = yaml_text + st.session_state["_validation_result"] = ValidationResult(spec=spec, error=None) + else: + st.session_state["rendered_html"] = "" + st.session_state.pop("_validation_text", None) + st.session_state.pop("_validation_result", None) def run_generation_pipeline(prompt: str) -> tuple[bool, str]: @@ -196,7 +208,7 @@ def run_generation_pipeline(prompt: str) -> tuple[bool, str]: except Exception: return False, traceback.format_exc() - _apply_generated_yaml(yaml_text) + _apply_generated_yaml(yaml_text, spec=spec) if reasoning: st.session_state["last_generation_reasoning"] = reasoning @@ -237,6 +249,8 @@ def initialize_state(yaml_path: Path | None) -> None: st.session_state["_yaml_path"] = session_key st.session_state.setdefault("generation_prompt", _DEFAULT_GENERATION_PROMPT) st.session_state.setdefault("editor_version", 0) + st.session_state.pop("_validation_text", None) + st.session_state.pop("_validation_result", None) if yaml_path is None: st.session_state["original_text"] = "" @@ -250,15 +264,10 @@ def initialize_state(yaml_path: Path | None) -> None: st.session_state["original_text"] = original_text st.session_state["edited_text"] = original_text - st.session_state["last_rendered_text"] = original_text + st.session_state["last_rendered_text"] = "" + st.session_state["rendered_html"] = "" st.session_state["save_path"] = str(yaml_path) - initial = validate_yaml_text(original_text) - if not initial.is_valid: - st.session_state["rendered_html"] = _BROKEN_PLACEHOLDER_HTML - else: - st.session_state["rendered_html"] = render_dashboard_html(initial.spec) - def render_validation_badge(validation: ValidationResult) -> None: """Show a success or error badge for the current editor YAML.""" @@ -348,11 +357,15 @@ def render_editor_panel(yaml_path: Path | None) -> ValidationResult: render_validation_badge(validation) edited_since_render = st.session_state["edited_text"] != st.session_state["last_rendered_text"] - if validation.is_valid and edited_since_render: - with st.spinner("Rendering visualization…"): - st.session_state["rendered_html"] = render_dashboard_html(validation.spec) + if edited_since_render: + if validation.is_valid: + with st.spinner("Rendering visualization…"): + st.session_state["rendered_html"] = render_dashboard_html(validation.spec) + else: + st.session_state["rendered_html"] = _BROKEN_PLACEHOLDER_HTML st.session_state["last_rendered_text"] = st.session_state["edited_text"] - st.toast("Visualization updated.", icon="🔄") + if validation.is_valid: + st.toast("Visualization updated.", icon="🔄") render_save_button(validation) return validation