Source code for simvx.graphics.web_export._export

"""Main export_web function."""

from __future__ import annotations

import ast
import json
import logging
import re
from pathlib import Path

from ._atlas import _prebake_atlas
from ._dependencies import _parse_pyproject_dependencies, _parse_script_dependencies, _resolve_web_packages
from ._detection import _detect_3d
from ._html_2d import _build_html
from ._html_3d import _build_html_3d
from ._paths import _CLIENT_DIR, _CORE_SRC, _GRAPHICS_SRC
from ._sources import _collect_3d_sources, _collect_python_sources

log = logging.getLogger(__name__)


[docs] def export_web( game_path: str | Path, output: str | Path = "game.html", *, width: int = 800, height: int = 600, title: str = "SimVX", root_class: str | None = None, physics_fps: int = 60, charset: str | None = None, responsive: bool = False, pyodide_version: str = "0.29.3", extra_packages: list[str] | None = None, ) -> Path: """Export a SimVX game as a standalone HTML file. Auto-detects 3D usage and includes the appropriate WebGPU pipeline. Dependencies are resolved from PEP 723 inline metadata, pyproject.toml, or the *extra_packages* parameter (PEP 723 takes precedence). Args: game_path: Path to the game's Python module. output: Output HTML file path. width, height: Engine viewport dimensions. title: Browser page title. root_class: Name of the root Node subclass. Auto-detected if None. physics_fps: Physics tick rate. charset: Characters to pre-bake in the MSDF atlas. responsive: Adapt engine viewport to browser window size. pyodide_version: Pyodide CDN version. extra_packages: Additional Pyodide packages to load (e.g. ``["requests"]``). Returns: Path to the generated HTML file. """ game_path = Path(game_path) output = Path(output) if not game_path.exists(): raise FileNotFoundError(f"Game module not found: {game_path}") game_source = game_path.read_text() is_3d = _detect_3d(game_source) if is_3d: log.info("3D nodes detected — including 3D pipeline") # Auto-detect root class if root_class is None: classes = re.findall(r"class\s+(\w+)\s*\(", game_source) if not classes: raise ValueError("No class found in game module; specify --root") root_class = classes[0] log.info("Auto-detected root class: %s", root_class) # Resolve web packages from PEP 723, pyproject.toml, or extra_packages game_deps = _parse_script_dependencies(game_source) if not game_deps: game_deps = _parse_pyproject_dependencies(game_path) web_packages = _resolve_web_packages(game_deps, extra_packages) if len(web_packages) > 1: log.info("Web packages: %s", ", ".join(web_packages)) # Scan game source for string literals to expand charset game_chars = set() try: tree = ast.parse(game_source) for node in ast.walk(tree): if isinstance(node, ast.Constant) and isinstance(node.value, str): game_chars.update(node.value) except SyntaxError: pass full_charset = charset if game_chars: base = ( "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" "0123456789 .!?:;,'-\"()[]{}/<>@#$%^&*+=_~`\\|" ) full_charset = base + "".join(c for c in game_chars if c not in base and c.isprintable()) log.info("Pre-baking MSDF atlas...") atlas_data = _prebake_atlas(full_charset) # Read JS and shader sources renderer2d_js = (_CLIENT_DIR / "renderer2d.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() renderer3d_js = "" combined_js = "" forward3d_wgsl = "" bloom_extract_wgsl = "" bloom_blur_wgsl = "" tonemap3d_wgsl = "" if is_3d: renderer3d_js = (_CLIENT_DIR / "renderer3d.js").read_text() combined_js = (_CLIENT_DIR / "combined_renderer.js").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...") py_sources = _collect_python_sources(_CORE_SRC, _GRAPHICS_SRC) if is_3d: _collect_3d_sources(_GRAPHICS_SRC, py_sources) py_sources["__game__.py"] = game_source # Include sibling .py files from the game's directory (for multi-file examples) game_dir = game_path.parent for sibling in sorted(game_dir.glob("*.py")): if sibling == game_path or sibling.name.startswith("_"): continue py_sources[sibling.name] = sibling.read_text() # Recursively include sub-packages (scripts/, data/, etc.) for multi-file games for sub_py in sorted(game_dir.rglob("**/*.py")): rel = sub_py.relative_to(game_dir) key = str(rel) if key in py_sources or sub_py == game_path: continue # Skip test files and __pycache__ if "__pycache__" in key or key.startswith("tests/") or key.startswith("test_"): continue py_sources[key] = sub_py.read_text() # Include data files (.toml, .json) as virtual files for tomllib/json.load data_files: dict[str, str] = {} for data_file in sorted(game_dir.rglob("**/*.toml")) + sorted(game_dir.rglob("**/*.json")): rel = str(data_file.relative_to(game_dir)) if "__pycache__" in rel: continue data_files[rel] = data_file.read_text() py_sources_json = json.dumps(py_sources) data_files_json = json.dumps(data_files) if data_files else "{}" # Build HTML log.info("Generating %s...", output) if is_3d: html = _build_html_3d( title=title, width=width, height=height, physics_fps=physics_fps, root_class=root_class, 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, data_files_json=data_files_json, atlas_data=atlas_data, pyodide_version=pyodide_version, web_packages=web_packages, ) else: html = _build_html( title=title, width=width, height=height, physics_fps=physics_fps, root_class=root_class, responsive=responsive, renderer_js=renderer2d_js, fill_wgsl=fill_wgsl, text_wgsl=text_wgsl, textured_wgsl=textured_wgsl, py_sources_json=py_sources_json, data_files_json=data_files_json, atlas_data=atlas_data, pyodide_version=pyodide_version, web_packages=web_packages, ) output.write_text(html) size_kb = output.stat().st_size / 1024 log.info("Exported %s (%.0f KB)", output, size_kb) return output