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