Source code for simvx.editor.web_export

"""Export the SimVX editor as a standalone HTML file that runs in the browser via Pyodide + WebGPU.

Usage::

    from simvx.editor.web_export import export_editor_web
    export_editor_web("editor.html", width=1920, height=1080, title="SimVX Editor")

Or from the command line::

    uv run python -m simvx.editor.web_export \\
        --output editor.html --width 1920 --height 1080 --title "SimVX Editor"
"""


from __future__ import annotations

import json
import logging
from pathlib import Path

from simvx.graphics.web_export import (
    _CLIENT_DIR,
    _CORE_SRC,
    _GRAPHICS_SRC,
    _PACKAGES_DIR,
    _collect_python_sources,
    _escape_for_js,
    _inline_js,
    _prebake_atlas,
)

log = logging.getLogger(__name__)

__all__ = ["export_editor_web"]

_EDITOR_SRC = _PACKAGES_DIR / "editor" / "src" / "simvx" / "editor"

# Extended charset for editor UI — includes box-drawing, arrows, common symbols
_EDITOR_CHARSET = (
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
    "0123456789 .!?:;,'-\"()[]{}/<>@#$%^&*+=_~`\\|"
    "\u2018\u2019\u201c\u201d\u2014\u2013\u2026\u00b7"  # smart quotes, dashes, ellipsis
    "\u25b6\u25bc\u25b2\u25c0\u2713\u2717\u2022\u00d7"  # triangles, check, cross, bullet, multiplication
    "\u2502\u2500\u250c\u2510\u2514\u2518\u251c\u2524"  # box drawing
    "\u23f5\u23f8\u23f9\u23fa"  # play, pause, stop, record
    "\u2699\ud83d\udcc1\ud83d\udcc4\ud83d\udd0d"  # gear, folder, page, magnifier
)

# Stub for gizmo_pass — GizmoRenderData dataclass only (editor late-imports it)
_GIZMO_PASS_STUB = '''\
"""Stub gizmo_pass for browser — no Vulkan gizmo rendering."""
from dataclasses import dataclass, field
import numpy as np

@dataclass
class GizmoRenderData:
    """Per-frame data describing what gizmo to render."""
    position: np.ndarray = field(default_factory=lambda: np.zeros(3, dtype=np.float32))
    mode: int = 0
    hover_axis: int = -1
    active_axis: int = -1
    view_matrix: np.ndarray = field(default_factory=lambda: np.eye(4, dtype=np.float32))
    proj_matrix: np.ndarray = field(default_factory=lambda: np.eye(4, dtype=np.float32))
    axis_length: float = 1.0
'''

# Stub for shader_compiler — no glslc in browser
_SHADER_COMPILER_STUB = '''\
"""Stub shader_compiler for browser — no glslc available."""

def compile_shader(*args, **kwargs):
    raise RuntimeError("Shader compilation is not available in the browser (no glslc)")
'''

# Stub for tilemap_pass — only the dtype (SceneAdapter imports it)
_TILEMAP_PASS_STUB = (
    "import numpy as np\n"
    "TILE_INSTANCE_DTYPE = np.dtype([\n"
    "    ('position', np.float32, 2),\n"
    "    ('tile_uv_offset', np.float32, 2),\n"
    "    ('tile_uv_size', np.float32, 2),\n"
    "    ('flip_h', np.uint32),\n"
    "    ('flip_v', np.uint32),\n"
    "])\n"
)


def _collect_editor_sources(core_src: Path, graphics_src: Path, editor_src: Path) -> dict[str, str]:
    """Collect all Python sources needed for the editor: core + graphics web modules + editor."""
    sources = _collect_python_sources(core_src, graphics_src)

    # Restore real core __init__.py — the game stub is too limited for the editor
    # (editor needs Selection, UndoStack, Gizmo, and 200+ other types)
    sources["simvx/core/__init__.py"] = (core_src / "__init__.py").read_text()

    # Additional graphics web modules
    web_dir = graphics_src / "web"
    for name in ["engine_stub.py", "texture_packer.py", "stubs.py"]:
        p = web_dir / name
        if p.exists():
            sources[f"simvx/graphics/web/{name}"] = p.read_text()

    # Extra graphics modules needed for 3D viewport
    for name in ["web_app3d.py", "scene_adapter.py"]:
        p = graphics_src / name
        if p.exists():
            sources[f"simvx/graphics/{name}"] = p.read_text()

    # streaming extras
    p = graphics_src / "streaming" / "scene3d_serializer.py"
    if p.exists():
        sources["simvx/graphics/streaming/scene3d_serializer.py"] = p.read_text()

    # renderer extras
    for name in ["_base.py", "web3d.py", "viewport_manager.py"]:
        p = graphics_src / "renderer" / name
        if p.exists():
            sources[f"simvx/graphics/renderer/{name}"] = p.read_text()
    sources["simvx/graphics/renderer/__init__.py"] = '"""Renderer package."""\n'

    # Graphics sub-module stubs for editor late-imports
    sources["simvx/graphics/renderer/gizmo_pass.py"] = _GIZMO_PASS_STUB
    sources["simvx/graphics/renderer/tilemap_pass.py"] = _TILEMAP_PASS_STUB
    sources["simvx/graphics/materials/__init__.py"] = ""
    sources["simvx/graphics/materials/shader_compiler.py"] = _SHADER_COMPILER_STUB

    # simvx.editor — recursively collect all .py files
    for py_file in sorted(editor_src.rglob("*.py")):
        rel = py_file.relative_to(editor_src.parent.parent)  # relative to src/
        sources[str(rel)] = py_file.read_text()

    return sources


[docs] def export_editor_web( output: str | Path = "editor.html", *, width: int = 1920, height: int = 1080, title: str = "SimVX Editor", charset: str | None = None, responsive: bool = True, pyodide_version: str = "0.29.3", ) -> Path: """Export the SimVX editor as a standalone HTML file for the browser. Bundles simvx.core + simvx.graphics (web modules) + simvx.editor as Python sources, with 2D+3D WebGPU renderers, MSDF atlas, and IndexedDB persistence. Args: output: Output HTML file path. width, height: Engine viewport dimensions. title: Browser page title. charset: Characters to pre-bake in the MSDF atlas. Defaults to extended editor charset. pyodide_version: Pyodide CDN version. Returns: Path to the generated HTML file. """ output = Path(output) log.info("Pre-baking MSDF atlas...") atlas_data = _prebake_atlas(charset or _EDITOR_CHARSET) # Read JavaScript renderers and shaders renderer2d_js = (_CLIENT_DIR / "renderer2d.js").read_text() renderer3d_js = (_CLIENT_DIR / "renderer3d.js").read_text() combined_js = (_CLIENT_DIR / "combined_renderer.js").read_text() fill_wgsl = (_CLIENT_DIR / "shaders" / "fill.wgsl").read_text() text_wgsl = (_CLIENT_DIR / "shaders" / "text.wgsl").read_text() textured_wgsl = (_CLIENT_DIR / "shaders" / "textured.wgsl").read_text() forward3d_wgsl = (_CLIENT_DIR / "shaders" / "forward3d.wgsl").read_text() bloom_extract_wgsl = (_CLIENT_DIR / "shaders" / "bloom_extract.wgsl").read_text() bloom_blur_wgsl = (_CLIENT_DIR / "shaders" / "bloom_blur.wgsl").read_text() tonemap3d_wgsl = (_CLIENT_DIR / "shaders" / "tonemap3d.wgsl").read_text() # Collect Python sources log.info("Collecting Python sources (core + graphics + editor)...") py_sources = _collect_editor_sources(_CORE_SRC, _GRAPHICS_SRC, _EDITOR_SRC) py_sources_json = json.dumps(py_sources) # Build HTML log.info("Generating %s...", output) html = _build_editor_html( title=title, width=width, height=height, responsive=responsive, renderer2d_js=renderer2d_js, renderer3d_js=renderer3d_js, combined_js=combined_js, fill_wgsl=fill_wgsl, text_wgsl=text_wgsl, textured_wgsl=textured_wgsl, forward3d_wgsl=forward3d_wgsl, bloom_extract_wgsl=bloom_extract_wgsl, bloom_blur_wgsl=bloom_blur_wgsl, tonemap3d_wgsl=tonemap3d_wgsl, py_sources_json=py_sources_json, atlas_data=atlas_data, pyodide_version=pyodide_version, ) output.write_text(html) size_kb = output.stat().st_size / 1024 log.info("Exported %s (%.0f KB)", output, size_kb) return output
def _build_editor_html( *, title: str, width: int, height: int, responsive: bool, renderer2d_js: str, renderer3d_js: str, combined_js: str, fill_wgsl: str, text_wgsl: str, textured_wgsl: str, forward3d_wgsl: str, bloom_extract_wgsl: str, bloom_blur_wgsl: str, tonemap3d_wgsl: str, py_sources_json: str, atlas_data: dict, pyodide_version: str, ) -> str: """Assemble the single-file HTML export for the editor.""" atlas_regions_json = json.dumps(atlas_data["regions"]) # Inline JS: each renderer wrapped in IIFE to isolate top-level consts inlined_js = ( _inline_js(renderer2d_js, "Renderer2D") + "\n\n" + _inline_js(renderer3d_js, "Renderer3D") + "\n\n" + _inline_js(combined_js, "CombinedRenderer") ) return f"""<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <title>{title}</title> <script src="https://cdn.jsdelivr.net/pyodide/v{pyodide_version}/full/pyodide.js"></script> <style> * {{ margin: 0; padding: 0; box-sizing: border-box; }} body {{ background: #1a1a2e; overflow: hidden; height: 100vh; height: 100dvh; display: flex; align-items: center; justify-content: center; }} canvas {{ display: block; image-rendering: pixelated; }} #status {{ position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #888; font: 16px monospace; z-index: 10; pointer-events: none; text-align: center; }} </style> </head> <body> <div id="status">Loading Pyodide...</div> <canvas id="canvas" tabindex="0"></canvas> <script> // --- Embedded renderers (2D + 3D + Combined) --- {inlined_js} // --- Embedded shaders --- const SHADERS = {{ fill: `{_escape_for_js(fill_wgsl)}`, text: `{_escape_for_js(text_wgsl)}`, textured: `{_escape_for_js(textured_wgsl)}`, }}; const FORWARD_3D_WGSL = `{_escape_for_js(forward3d_wgsl)}`; const BLOOM_EXTRACT_WGSL = `{_escape_for_js(bloom_extract_wgsl)}`; const BLOOM_BLUR_WGSL = `{_escape_for_js(bloom_blur_wgsl)}`; const TONEMAP_3D_WGSL = `{_escape_for_js(tonemap3d_wgsl)}`; // --- Embedded atlas --- const ATLAS_PNG_B64 = "{atlas_data['atlas_png_b64']}"; const ATLAS_SIZE = {atlas_data['atlas_size']}; const ATLAS_REGIONS = `{_escape_for_js(atlas_regions_json)}`; const FONT_SIZE = {atlas_data['font_size']}; const FONT_ASCENDER = {atlas_data['ascender']}; const FONT_DESCENDER = {atlas_data['descender']}; const FONT_LINE_HEIGHT = {atlas_data['line_height']}; const SDF_RANGE = {atlas_data['sdf_range']}; const GLYPH_PADDING = {atlas_data['glyph_padding']}; // --- Config --- const ENGINE_WIDTH = {width}; const ENGINE_HEIGHT = {height}; const PHYSICS_FPS = 60; const RESPONSIVE = {'true' if responsive else 'false'}; // --- Python sources --- const PY_SOURCES = {py_sources_json}; // --- Key mapping --- const KEY_MAP = {{ 'Space': 32, 'KeyA': 65, 'KeyB': 66, 'KeyC': 67, 'KeyD': 68, 'KeyE': 69, 'KeyF': 70, 'KeyG': 71, 'KeyH': 72, 'KeyI': 73, 'KeyJ': 74, 'KeyK': 75, 'KeyL': 76, 'KeyM': 77, 'KeyN': 78, 'KeyO': 79, 'KeyP': 80, 'KeyQ': 81, 'KeyR': 82, 'KeyS': 83, 'KeyT': 84, 'KeyU': 85, 'KeyV': 86, 'KeyW': 87, 'KeyX': 88, 'KeyY': 89, 'KeyZ': 90, 'Digit0': 48, 'Digit1': 49, 'Digit2': 50, 'Digit3': 51, 'Digit4': 52, 'Digit5': 53, 'Digit6': 54, 'Digit7': 55, 'Digit8': 56, 'Digit9': 57, 'Enter': 257, 'Escape': 256, 'Backspace': 259, 'Tab': 258, 'ArrowRight': 262, 'ArrowLeft': 263, 'ArrowDown': 264, 'ArrowUp': 265, 'ShiftLeft': 340, 'ShiftRight': 344, 'ControlLeft': 341, 'ControlRight': 345, 'AltLeft': 342, 'AltRight': 346, 'F1': 290, 'F2': 291, 'F3': 292, 'F4': 293, 'F5': 294, 'F6': 295, 'F7': 296, 'F8': 297, 'F9': 298, 'F10': 299, 'F11': 300, 'F12': 301, 'Delete': 261, 'Insert': 260, 'Home': 268, 'End': 269, 'PageUp': 266, 'PageDown': 267, 'Minus': 45, 'Equal': 61, 'BracketLeft': 91, 'BracketRight': 93, 'Backslash': 92, 'Semicolon': 59, 'Quote': 39, 'Backquote': 96, 'Comma': 44, 'Period': 46, 'Slash': 47, }}; // --- Main --- const canvas = document.getElementById('canvas'); const statusEl = document.getElementById('status'); let renderer = null; let webApp = null; let inputQueue = []; let engineW = ENGINE_WIDTH, engineH = ENGINE_HEIGHT; function toEngineCoords(e) {{ const rect = canvas.getBoundingClientRect(); return {{ x: (e.clientX - rect.left) / rect.width * engineW, y: (e.clientY - rect.top) / rect.height * engineH, }}; }} function send(msg) {{ inputQueue.push(msg); }} function layoutCanvas() {{ const dpr = window.devicePixelRatio || 1; const vpW = window.innerWidth, vpH = window.innerHeight; let cssW, cssH; if (RESPONSIVE) {{ engineW = vpW; engineH = vpH; cssW = vpW; cssH = vpH; }} else {{ const aspect = ENGINE_WIDTH / ENGINE_HEIGHT; if (vpW / vpH > aspect) {{ cssH = vpH; cssW = Math.round(vpH * aspect); }} else {{ cssW = vpW; cssH = Math.round(vpW / aspect); }} }} canvas.style.width = cssW + 'px'; canvas.style.height = cssH + 'px'; canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr); if (renderer) {{ renderer.resize(engineW, engineH); renderer.setCanvasSize(canvas.width, canvas.height); if (_gpuDevice && _gpuFormat) {{ _gpuCtx.configure({{ device: _gpuDevice, format: _gpuFormat, alphaMode: 'premultiplied', viewFormats: [_gpuSrgbFormat] }}); }} }} if (webApp) {{ webApp.resize(engineW, engineH); }} }} function bindInput() {{ document.addEventListener('keydown', e => {{ e.preventDefault(); const code = KEY_MAP[e.code]; if (code !== undefined) send({{ type: 'key', code, pressed: true }}); if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) send({{ type: 'char', codepoint: e.key.codePointAt(0) }}); }}); document.addEventListener('keyup', e => {{ e.preventDefault(); const code = KEY_MAP[e.code]; if (code !== undefined) send({{ type: 'key', code, pressed: false }}); }}); canvas.addEventListener('mousedown', e => {{ canvas.focus(); const p = toEngineCoords(e); send({{ type: 'mousemove', x: p.x, y: p.y }}); send({{ type: 'mouse', button: e.button, pressed: true }}); }}); canvas.addEventListener('mouseup', e => {{ send({{ type: 'mouse', button: e.button, pressed: false }}); }}); canvas.addEventListener('mousemove', e => {{ const p = toEngineCoords(e); send({{ type: 'mousemove', x: p.x, y: p.y }}); }}); canvas.addEventListener('wheel', e => {{ e.preventDefault(); send({{ type: 'scroll', dx: e.deltaX, dy: -e.deltaY / 100 }}); }}, {{ passive: false }}); canvas.addEventListener('contextmenu', e => e.preventDefault()); canvas.addEventListener('touchstart', e => {{ e.preventDefault(); for (const t of e.changedTouches) {{ const p = toEngineCoords(t); send({{ type: 'touch', id: t.identifier, action: 0, x: p.x, y: p.y, pressure: t.force || 1.0 }}); send({{ type: 'mousemove', x: p.x, y: p.y }}); send({{ type: 'mouse', button: 0, pressed: true }}); }} }}, {{ passive: false }}); canvas.addEventListener('touchend', e => {{ e.preventDefault(); for (const t of e.changedTouches) {{ const p = toEngineCoords(t); send({{ type: 'touch', id: t.identifier, action: 1, x: p.x, y: p.y, pressure: 0 }}); send({{ type: 'mouse', button: 0, pressed: false }}); }} }}, {{ passive: false }}); canvas.addEventListener('touchmove', e => {{ e.preventDefault(); for (const t of e.changedTouches) {{ const p = toEngineCoords(t); send({{ type: 'touch', id: t.identifier, action: 2, x: p.x, y: p.y, pressure: t.force || 1.0 }}); send({{ type: 'mousemove', x: p.x, y: p.y }}); }} }}, {{ passive: false }}); }} let _gpuDevice = null; let _gpuFormat = null; let _gpuSrgbFormat = null; let _gpuCtx = null; function _srgbFormat(fmt) {{ if (fmt === 'bgra8unorm') return 'bgra8unorm-srgb'; if (fmt === 'rgba8unorm') return 'rgba8unorm-srgb'; return fmt; }} async function initWebGPU() {{ if (!navigator.gpu) {{ statusEl.textContent = 'WebGPU not supported'; return null; }} const adapter = await navigator.gpu.requestAdapter(); if (!adapter) {{ statusEl.textContent = 'No WebGPU adapter'; return null; }} _gpuDevice = await adapter.requestDevice(); _gpuCtx = canvas.getContext('webgpu'); _gpuFormat = navigator.gpu.getPreferredCanvasFormat(); _gpuSrgbFormat = _srgbFormat(_gpuFormat); _gpuCtx.configure({{ device: _gpuDevice, format: _gpuFormat, alphaMode: 'premultiplied', viewFormats: [_gpuSrgbFormat] }}); const r = new CombinedRenderer(_gpuDevice, _gpuCtx, _gpuFormat); await r.init(SHADERS, FORWARD_3D_WGSL, BLOOM_EXTRACT_WGSL, BLOOM_BLUR_WGSL, TONEMAP_3D_WGSL); return r; }} async function loadAtlasToWebGPU() {{ const binary = atob(ATLAS_PNG_B64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); const blob = new Blob([bytes], {{ type: 'image/png' }}); const bitmap = await createImageBitmap(blob, {{ premultiplyAlpha: 'none' }}); renderer.pxRange = SDF_RANGE; renderer.uploadAtlas(bitmap, 1); bitmap.close(); }} async function loadAtlasToPython(pyodide) {{ const binary = atob(ATLAS_PNG_B64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); const blob = new Blob([bytes], {{ type: 'image/png' }}); const bitmap = await createImageBitmap(blob, {{ premultiplyAlpha: 'none' }}); const offscreen = new OffscreenCanvas(bitmap.width, bitmap.height); const ctx2d = offscreen.getContext('2d'); ctx2d.drawImage(bitmap, 0, 0); const imageData = ctx2d.getImageData(0, 0, bitmap.width, bitmap.height); bitmap.close(); webApp.load_atlas( pyodide.toPy(imageData.data), ATLAS_SIZE, ATLAS_REGIONS, FONT_SIZE, FONT_ASCENDER, FONT_DESCENDER, FONT_LINE_HEIGHT, SDF_RANGE, GLYPH_PADDING ); }} async function boot() {{ // 1. Size canvas before WebGPU init layoutCanvas(); renderer = await initWebGPU(); if (!renderer) return; layoutCanvas(); bindInput(); window.addEventListener('resize', layoutCanvas); if (window.visualViewport) window.visualViewport.addEventListener('resize', layoutCanvas); // 2. Load atlas into WebGPU statusEl.textContent = 'Loading atlas...'; await loadAtlasToWebGPU(); // 3. Load Pyodide statusEl.textContent = 'Loading Python runtime...'; const pyodide = await loadPyodide({{ indexURL: 'https://cdn.jsdelivr.net/pyodide/v{pyodide_version}/full/', }}); await pyodide.loadPackage('numpy'); // 4. Write Python sources to virtual filesystem statusEl.textContent = 'Loading editor...'; const pkgRoot = '/home/pyodide/pkg'; try {{ pyodide.FS.mkdir(pkgRoot); }} catch(e) {{}} for (const [path, source] of Object.entries(PY_SOURCES)) {{ const parts = path.split('/'); let dir = pkgRoot; for (let i = 0; i < parts.length - 1; i++) {{ dir += '/' + parts[i]; try {{ pyodide.FS.mkdir(dir); }} catch(e) {{}} }} pyodide.FS.writeFile(dir + '/' + parts[parts.length - 1], source); }} // 5. Mount IndexedDB-backed filesystem for project persistence try {{ pyodide.FS.mkdir('/projects'); }} catch(e) {{}} pyodide.FS.mount(pyodide.FS.filesystems.IDBFS, {{}}, '/projects'); await new Promise((resolve) => pyodide.FS.syncfs(true, resolve)); // Periodic sync to persist changes setInterval(() => pyodide.FS.syncfs(false, () => {{}}), 5000); // Sync on page unload window.addEventListener('beforeunload', () => {{ pyodide.FS.syncfs(false, () => {{}}); }}); // 6. Create WebApp3D and load atlas into Python statusEl.textContent = 'Starting editor...'; pyodide.runPython(` import sys, traceback as _tb sys.path.insert(0, '/home/pyodide/pkg') # Install browser stubs (glfw, vulkan, miniaudio, freetype, subprocess, fcntl, pty, termios) from simvx.graphics.web.stubs import install_stubs install_stubs() # Stub only modules NOT bundled in the export import types for mod_name in ['simvx.graphics.engine', 'simvx.graphics.text_renderer']: if mod_name not in sys.modules: sys.modules[mod_name] = types.ModuleType(mod_name) # text_renderer needs no-op functions so Draw2D.set_font() gracefully skips _tr = sys.modules['simvx.graphics.text_renderer'] _tr._find_font = lambda: None _tr.get_shared_text_renderer = lambda: None try: from simvx.graphics.web_app3d import WebApp3D _web_app = WebApp3D(${{engineW}}, ${{engineH}}, ${{PHYSICS_FPS}}) except Exception: _tb.print_exc() raise `); webApp = pyodide.globals.get('_web_app'); await loadAtlasToPython(pyodide); // 7. Instantiate editor root pyodide.runPython(` try: from simvx.editor import EditorShell _web_app.set_root(EditorShell()) except Exception: import traceback; traceback.print_exc() raise `); // 8. Start render loop statusEl.textContent = ''; let lastTime = performance.now(); function frame(now) {{ const dt = Math.min((now - lastTime) / 1000, 0.1); lastTime = now; if (inputQueue.length > 0) {{ webApp.process_input(JSON.stringify(inputQueue)); inputQueue.length = 0; }} const pyBytes = webApp.tick(dt); const jsBytes = pyBytes.toJs(); pyBytes.destroy(); const frameData = jsBytes.buffer.slice(jsBytes.byteOffset, jsBytes.byteOffset + jsBytes.byteLength); renderer.renderFrame(frameData); requestAnimationFrame(frame); }} requestAnimationFrame(frame); }} boot().catch(e => {{ console.error(e); statusEl.textContent = 'Error: ' + e.message; }}); </script> </body> </html>""" if __name__ == "__main__": import argparse import sys logging.basicConfig(level=logging.INFO, format="%(message)s") parser = argparse.ArgumentParser(description="Export SimVX editor to a standalone HTML file") parser.add_argument("--output", "-o", default="editor.html", help="Output HTML file") parser.add_argument("--width", type=int, default=1920, help="Engine width") parser.add_argument("--height", type=int, default=1080, help="Engine height") parser.add_argument("--title", default="SimVX Editor", help="Page title") parser.add_argument("--no-responsive", dest="responsive", action="store_false", default=True, help="Use fixed engine dimensions instead of adapting to viewport") args = parser.parse_args() try: export_editor_web(args.output, width=args.width, height=args.height, title=args.title, responsive=args.responsive) except Exception as e: print(f"Export failed: {e}", file=sys.stderr) sys.exit(1)