Source code for simvx.core.scene_codegen

"""
Scene-to-code conversion -- generate Python source from serialized scene JSON.

Reads a ``.json`` scene file (as produced by :func:`simvx.core.scene.save_scene`)
and emits an equivalent Python module with a ``build_scene()`` factory function.

Public API:
    scene_to_code(scene_path)          -- file path → Python source string
    scene_dict_to_code(data, name)     -- parsed dict → Python source string
"""


from __future__ import annotations

import json
import logging
from pathlib import Path

log = logging.getLogger(__name__)

__all__ = ["scene_to_code", "scene_dict_to_code"]

# Node types that live in simvx.core (imported from engine / audio / etc.)
_CORE_NODE_TYPES = {
    "Node", "Node2D", "Node3D", "Camera2D", "Camera3D", "OrbitCamera3D",
    "MeshInstance3D", "Light3D", "DirectionalLight3D", "PointLight3D", "SpotLight3D",
    "Text2D", "Text3D", "Timer", "Line2D", "Polygon2D",
    "CharacterBody2D", "CharacterBody3D", "CollisionShape2D", "CollisionShape3D",
    "Area2D", "Area3D", "CanvasLayer", "ParallaxBackground", "ParallaxLayer",
    "CanvasModulate", "YSortContainer", "EditorCamera3D",
    "AudioStreamPlayer", "AudioStreamPlayer2D", "AudioStreamPlayer3D",
    "Sprite2D", "AnimatedSprite2D", "ParticleEmitter",
    "RigidBody2D", "RigidBody3D", "StaticBody2D", "StaticBody3D",
    "KinematicBody2D", "KinematicBody3D",
    "TileMap",
}

# Tagged dict keys used by scene.py serialization
_VEC_TAGS = {"__vec2__": "Vec2", "__vec3__": "Vec3", "__quat__": "Quat"}


[docs] def scene_to_code(scene_path: str | Path) -> str: """Convert a ``.json`` scene file to equivalent Python source.""" scene_path = Path(scene_path) data = json.loads(scene_path.read_text()) scene_name = scene_path.stem return scene_dict_to_code(data, scene_name)
[docs] def scene_dict_to_code(data: dict, scene_name: str = "scene") -> str: """Convert a serialized scene dict to Python source.""" ctx = _CodegenContext() body_lines = ctx.emit_node(data, "root", is_root=True) # Build import block imports = _build_imports(ctx) header = f'"""Scene: {scene_name} -- generated from {scene_name}.json"""\n' parts = [header, imports, "", "", f"def build_scene() -> {ctx.root_type}:"] parts.extend(f" {line}" for line in body_lines) parts.append(" return root") parts.append("") return "\n".join(parts)
def _build_imports(ctx: _CodegenContext) -> str: """Build the import statements from collected type usage.""" lines: list[str] = [] # simvx.core node + math imports core_names = sorted(ctx.used_core_types | ctx.used_math_types) if core_names: lines.append(f"from simvx.core import {', '.join(core_names)}") # Material if "Material" in ctx.used_resource_types: lines.append("from simvx.core.graphics.material import Material") # ResourceCache if "ResourceCache" in ctx.used_resource_types: lines.append("from simvx.core.resource import ResourceCache") # AudioStream if ctx.uses_audio_stream: lines.append("from simvx.core.audio import AudioStream") # load_scene for sub-scenes if ctx.uses_load_scene: if not any("load_scene" in line for line in lines): # Merge into existing core import if possible if lines and lines[0].startswith("from simvx.core import"): existing = lines[0][len("from simvx.core import "):] names = [n.strip() for n in existing.split(",")] if "load_scene" not in names: names.append("load_scene") names.sort() lines[0] = f"from simvx.core import {', '.join(names)}" else: lines.append("from simvx.core import load_scene") return "\n".join(lines) class _CodegenContext: """Tracks imports and generates code for a scene tree.""" def __init__(self): self.used_core_types: set[str] = set() self.used_math_types: set[str] = set() self.used_resource_types: set[str] = set() self.uses_audio_stream: bool = False self.uses_load_scene: bool = False self.root_type: str = "Node" self._var_counter: int = 0 self._seen_names: dict[str, int] = {} def _unique_var(self, name: str) -> str: """Generate a unique Python variable name from a node name.""" # Sanitize: lowercase, replace spaces/dashes with underscores base = name.lower().replace(" ", "_").replace("-", "_") # Remove non-alphanumeric (except underscore) base = "".join(c for c in base if c.isalnum() or c == "_") if not base or base[0].isdigit(): base = f"node_{base}" if base in self._seen_names: self._seen_names[base] += 1 return f"{base}_{self._seen_names[base]}" self._seen_names[base] = 0 return base def emit_node(self, data: dict, var_name: str, is_root: bool = False) -> list[str]: """Emit Python lines for a node dict. Returns list of code lines.""" lines: list[str] = [] # Sub-scene reference if "__scene__" in data: self.uses_load_scene = True scene_path = data["__scene__"] name = data.get("name", "") lines.append(f'{var_name} = load_scene("{scene_path}")') if name: lines.append(f'{var_name}.name = "{name}"') # Apply overrides for attr in ("position", "rotation", "scale"): if attr in data: val_str = self._format_value(data[attr]) lines.append(f"{var_name}.{attr} = {val_str}") return lines # Regular node node_type = data.get("__type__", "Node") self._register_type(node_type) if is_root: self.root_type = node_type # Build constructor kwargs kwargs: list[str] = [] name = data.get("name", node_type) kwargs.append(f'name="{name}"') # Spatial properties for attr in ("position", "rotation", "scale"): if attr in data: kwargs.append(f"{attr}={self._format_value(data[attr])}") # Settings (Property values) for key, val in data.get("settings", {}).items(): kwargs.append(f"{key}={self._format_value(val)}") kwargs_str = ", ".join(kwargs) lines.append(f"{var_name} = {node_type}({kwargs_str})") # Embedded script if "script_embedded" in data: lines.append(f'{var_name}._script_embedded = """{data["script_embedded"]}"""') elif "script_inline" in data: lines.append(f'{var_name}._script_inline = """{data["script_inline"]}"""') elif "script" in data: lines.append(f'{var_name}.script = "{data["script"]}"') # Groups for group in data.get("groups", []): lines.append(f'{var_name}.add_to_group("{group}")') # Mesh resource if "mesh" in data: self.used_resource_types.add("ResourceCache") lines.append(f'{var_name}.mesh = ResourceCache.get().resolve_mesh("{data["mesh"]}")') # Material if "material" in data: self.used_resource_types.add("Material") mat = data["material"] mat_kwargs = self._format_material_kwargs(mat) lines.append(f"{var_name}.material = Material({mat_kwargs})") # Audio stream if "stream" in data: stream_val = data["stream"] if stream_val.startswith("audio://"): self.used_resource_types.add("ResourceCache") lines.append(f'{var_name}.stream = ResourceCache.get().resolve_audio("{stream_val}")') else: self.uses_audio_stream = True lines.append(f'{var_name}.stream = AudioStream("{stream_val}")') # Children for child_data in data.get("children", []): child_name_raw = child_data.get("name", child_data.get("__type__", "child")) child_var = self._unique_var(child_name_raw) child_lines = self.emit_node(child_data, child_var) lines.extend(child_lines) lines.append(f"{var_name}.add_child({child_var})") return lines def _register_type(self, type_name: str) -> None: """Track a node type for import generation.""" if type_name in _CORE_NODE_TYPES: self.used_core_types.add(type_name) def _format_value(self, val) -> str: """Format a value for Python source output.""" if isinstance(val, dict): # Tagged vector/quat dict for tag, type_name in _VEC_TAGS.items(): if tag in val: self.used_math_types.add(type_name) components = val[tag] args = ", ".join(_fmt_num(c) for c in components) return f"{type_name}({args})" # Generic dict items = ", ".join(f'"{k}": {self._format_value(v)}' for k, v in val.items()) return "{" + items + "}" if isinstance(val, list): items = ", ".join(self._format_value(v) for v in val) return f"[{items}]" if isinstance(val, tuple): items = ", ".join(self._format_value(v) for v in val) return f"({items},)" if len(val) == 1 else f"({items})" if isinstance(val, str): return repr(val) if isinstance(val, bool): return repr(val) if isinstance(val, int | float): return _fmt_num(val) return repr(val) def _format_material_kwargs(self, mat: dict) -> str: """Format Material constructor keyword arguments.""" parts: list[str] = [] for key, val in mat.items(): if key == "colour": parts.append(f"colour=({', '.join(_fmt_num(c) for c in val)})") elif key in ("metallic", "roughness"): parts.append(f"{key}={_fmt_num(val)}") elif key in ("wireframe", "blend", "double_sided", "unlit"): parts.append(f"{key}={val!r}") elif key.endswith("_uri"): # Convert storage key to constructor key ctor_key = key.replace("_uri", "_map") parts.append(f'{ctor_key}="{val}"') return ", ".join(parts) def _fmt_num(v) -> str: """Format a number cleanly (strip trailing zeros from floats).""" if isinstance(v, int): return str(v) if isinstance(v, float): if v == int(v) and abs(v) < 1e15: return f"{int(v)}.0" return f"{v:g}" return str(v)