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