"""Greenfield scene emitter — live ``Node`` tree → canonical ``.py`` source.
Produces a single class definition whose ``__init__`` reconstructs the tree
via ``add_child`` calls, with non-default :class:`Property` values passed
as keyword arguments. The output is a *string*; round-trip identity through
:func:`simvx.core.scene_io.parse_source` is guaranteed by construction
because the emitted form is canonical and parso preserves any text it
parses verbatim under :meth:`SourceTree.dump`.
The emitter is intentionally string-based rather than parso-based: building
a fresh tree is the right tool when there is no source to preserve. The
diff-and-edit layer (Tier 3b) handles the round-trip case where source
already exists on disk.
"""
from __future__ import annotations
from pathlib import PurePath
from typing import TYPE_CHECKING, Any
import numpy as np
from ..math.types import Quat, Vec2, Vec3
from ..node import Node
if TYPE_CHECKING:
pass
__all__ = [
"emit_node_construction",
"emit_scene",
"emit_value",
"iter_runtime_kwargs",
"structural_type_name",
]
# ---------------------------------------------------------------------------
# Public surface
# ---------------------------------------------------------------------------
[docs]
def emit_scene(
root: Node,
*,
class_name: str | None = None,
extra_imports: list[str] | None = None,
) -> str:
"""Emit a complete ``.py`` source file for the live tree rooted at ``root``.
Args:
root: Root node of the tree.
class_name: Class name to use. Defaults to ``root.name`` when it is
a valid identifier and differs from the engine type name;
otherwise falls back to the engine type name.
extra_imports: Additional verbatim import lines to include in place
of the auto-generated ``from simvx.core import ...`` line.
Empty/``None`` → minimal generated imports.
Returns:
Complete Python source string. The output round-trips through
``parse_source(s).dump() == s`` by construction (parso preserves
any text it parses verbatim).
"""
root_type_cls = _structural_type(root)
root_type_name = root_type_cls.__name__
if class_name is None:
candidate = root.name
if candidate and candidate.isidentifier() and candidate != root_type_name:
class_name = candidate
else:
class_name = root_type_name or "Scene"
ctx = _EmitterContext()
body_lines = ctx.emit_node(root, "self", is_root=True)
root_kwargs = [(k, v) for k, v in ctx.build_root_kwargs(root) if not (k == "name" and v == repr(class_name))]
super_args = ", ".join(["**kwargs", *(f"{k}={v}" for k, v in root_kwargs)])
base_type = root_type_name
if base_type == class_name:
mro = root_type_cls.__mro__
base_type = mro[1].__name__ if len(mro) > 1 else "Node"
parts: list[str] = []
if extra_imports:
parts.extend(extra_imports)
else:
used = set(ctx.used_types)
if base_type:
used.add(base_type)
if used:
parts.append(f"from simvx.core import {', '.join(sorted(used))}")
parts.append("")
parts.append("")
parts.append(f"class {class_name}({base_type}):")
parts.append(" def __init__(self, **kwargs):")
parts.append(f" super().__init__({super_args})")
if body_lines:
parts.append("")
parts.extend(f" {line}" for line in body_lines)
parts.append("")
return "\n".join(parts)
[docs]
def emit_node_construction(node: Node, var_name: str, *, used_types: set[str] | None = None) -> str:
"""Emit a single ``var_name = Type(kwargs...)`` line for ``node``.
``used_types`` is mutated in place when supplied so callers can
accumulate imports across multiple emissions; the type of ``node``
itself is added, plus the types of any complex kwarg values
(``Vec2``/``Vec3``/``Quat``).
"""
ctx = _EmitterContext(used_types=used_types if used_types is not None else set())
type_name = _structural_type(node).__name__
ctx.used_types.add(type_name)
kwargs = ctx.build_kwargs(node)
kwargs_str = ", ".join(f"{k}={v}" for k, v in kwargs)
return f"{var_name} = {type_name}({kwargs_str})"
[docs]
def iter_runtime_kwargs(node: Node, *, used_types: set[str] | None = None) -> list[tuple[str, str]]:
"""Return the ``(kwarg_name, formatted_expr)`` pairs the emitter would
emit for ``node`` in greenfield mode.
This is the canonical answer to "which constructor kwargs reflect the
non-default state of this node?" — used by the editor's round-trip
save path (``simvx.editor.scene_diff``) to reconcile a parsed source
file against a live runtime tree without duplicating the
Property/spatial-default iteration logic.
The returned list includes:
* ``name=...`` when ``node.name`` differs from the engine type name.
* Spatial kwargs (``position``/``rotation``/``scale``) for ``Node2D``
/ ``Node3D`` when they deviate from origin/identity/one.
* One entry per declared ``Property`` whose current value differs
from its declared default and is serialisable via :func:`emit_value`.
``used_types`` is mutated in place when supplied so callers can
accumulate import names (``Vec2``/``Vec3``/``Quat``) for emit.
"""
ctx = _EmitterContext(used_types=used_types if used_types is not None else set())
return ctx.build_kwargs(node)
[docs]
def structural_type_name(node: Node) -> str:
"""Importable type name the emitter would use for ``node``.
Returns the *pre-script* class name when a user script has rebound
``type(node)`` to a class living in a user file unreachable from
``simvx.core`` — see :func:`_structural_type`. Used by the round-trip
diff layer to decide whether to add an import for a newly-introduced
runtime child.
"""
return _structural_type(node).__name__
[docs]
def emit_value(val: Any) -> str | None:
"""Format ``val`` as Python source, or ``None`` when not serialisable.
Non-serialisable values include :class:`Node` instances, callables,
and modules. Numeric types use a compact canonical form (integers
formatted as ``N.0`` floats where appropriate); :class:`Vec2`,
:class:`Vec3`, :class:`Quat`, lists, tuples, and ``str``-keyed dicts
serialise recursively.
"""
return _format_value(val)
# ---------------------------------------------------------------------------
# Emitter context
# ---------------------------------------------------------------------------
class _EmitterContext:
"""Per-emission state: tracked imports, variable counter, name collisions."""
def __init__(self, *, used_types: set[str] | None = None) -> None:
self.used_types: set[str] = used_types if used_types is not None else set()
self._seen_names: dict[str, int] = {}
def build_root_kwargs(self, node: Node) -> list[tuple[str, str]]:
return self.build_kwargs(node)
def build_kwargs(self, node: Node) -> list[tuple[str, str]]:
kwargs: list[tuple[str, str]] = []
real_type = _structural_type(node)
if node.name != real_type.__name__:
kwargs.append(("name", repr(node.name)))
kwargs.extend(self._spatial_kwargs(node))
for prop_name, prop in node.get_properties().items():
val = getattr(node, prop_name)
if _is_default(val, prop.default):
continue
formatted = _format_value(val)
if formatted is None:
continue
self._register_value_type(val)
kwargs.append((prop_name, formatted))
return kwargs
def emit_node(self, node: Node, var_name: str, *, is_root: bool = False) -> list[str]:
lines: list[str] = []
real_type = _structural_type(node)
type_name = real_type.__name__
if not is_root:
self.used_types.add(type_name)
kwargs = self.build_kwargs(node)
kwargs_str = ", ".join(f"{k}={v}" for k, v in kwargs)
lines.append(f"{var_name} = {type_name}({kwargs_str})")
target = var_name
if getattr(node, "script", None):
lines.append(f"{target}.script = {node.script!r}")
if getattr(node, "_script_embedded", None):
src = node._script_embedded.replace('"""', '\\"\\"\\"')
lines.append(f'{target}._script_embedded = """{src}"""')
for child in node.children:
child_var = self._unique_var(child.name)
lines.extend(self.emit_node(child, child_var))
lines.append(f"{target}.add_child({child_var})")
return lines
def _spatial_kwargs(self, node: Node) -> list[tuple[str, str]]:
from ..nodes_2d.node2d import Node2D
from ..nodes_3d.node3d import Node3D
kwargs: list[tuple[str, str]] = []
if isinstance(node, Node3D):
pos = node.position
if not _is_zero_vec3(pos):
self.used_types.add("Vec3")
kwargs.append(("position", _format_value(Vec3(pos))))
rot = node.rotation
if not _is_identity_quat(rot):
self.used_types.add("Quat")
kwargs.append(("rotation", _format_value(rot)))
scl = node.scale
if not _is_one_vec3(scl):
self.used_types.add("Vec3")
kwargs.append(("scale", _format_value(Vec3(scl))))
elif isinstance(node, Node2D):
pos = node.position
if not _is_zero_vec2(pos):
self.used_types.add("Vec2")
kwargs.append(("position", _format_value(Vec2(pos))))
rot = node.rotation
if abs(float(rot)) > 1e-9:
kwargs.append(("rotation", _fmt_num(rot)))
scl = node.scale
if not _is_one_vec2(scl):
self.used_types.add("Vec2")
kwargs.append(("scale", _format_value(Vec2(scl))))
return kwargs
def _register_value_type(self, val: Any) -> None:
if isinstance(val, Vec2):
self.used_types.add("Vec2")
elif isinstance(val, Vec3):
self.used_types.add("Vec3")
elif isinstance(val, Quat):
self.used_types.add("Quat")
def _unique_var(self, name: str) -> str:
base = name.lower().replace(" ", "_").replace("-", "_")
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 == "self":
base = "node_self"
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
# ---------------------------------------------------------------------------
# Value formatting
# ---------------------------------------------------------------------------
def _structural_type(node: Node) -> type:
"""The pre-script (importable) type of ``node``.
A user script may have replaced ``type(node)`` with a class living in a
user file that isn't reachable from ``simvx.core``; the original
structural class is the one the generated source must reference.
"""
return getattr(node, "_script_original_class", None) or type(node)
def _is_serialisable(value: Any) -> bool:
if value is None:
return True
if isinstance(value, bool | int | float | str):
return True
if isinstance(value, Vec2 | Vec3 | Quat):
return True
if isinstance(value, PurePath):
return True
if isinstance(value, np.ndarray):
return True
if isinstance(value, tuple | list):
return all(_is_serialisable(v) for v in value)
if isinstance(value, dict):
return all(isinstance(k, str) and _is_serialisable(v) for k, v in value.items())
if isinstance(value, Node):
return False
if callable(value):
return False
return False
def _format_value(val: Any) -> str | None:
if not _is_serialisable(val):
return None
if val is None:
return "None"
if isinstance(val, bool):
return repr(val)
if isinstance(val, Quat):
return f"Quat({_fmt_num(val.w)}, {_fmt_num(val.x)}, {_fmt_num(val.y)}, {_fmt_num(val.z)})"
if isinstance(val, Vec3):
return f"Vec3({_fmt_num(val[0])}, {_fmt_num(val[1])}, {_fmt_num(val[2])})"
if isinstance(val, Vec2):
return f"Vec2({_fmt_num(val[0])}, {_fmt_num(val[1])})"
if isinstance(val, PurePath):
return f"Path({str(val)!r})"
if isinstance(val, np.ndarray):
items = ", ".join(_fmt_num(float(v)) for v in val.flat)
return f"[{items}]"
if isinstance(val, int | float):
return _fmt_num(val)
if isinstance(val, str):
return repr(val)
if isinstance(val, tuple):
items = ", ".join(_format_value(v) or "None" for v in val)
return f"({items},)" if len(val) == 1 else f"({items})"
if isinstance(val, list):
items = ", ".join(_format_value(v) or "None" for v in val)
return f"[{items}]"
if isinstance(val, dict):
items = ", ".join(f"{k!r}: {_format_value(v)}" for k, v in val.items())
return "{" + items + "}"
return None
def _fmt_num(v: Any) -> str:
if isinstance(v, bool):
return repr(v)
if isinstance(v, int):
return str(v)
if isinstance(v, float | np.floating):
fv = float(v)
if fv == int(fv) and abs(fv) < 1e15:
return f"{int(fv)}.0"
return f"{fv:g}"
return str(v)
# ---------------------------------------------------------------------------
# Default-value comparisons
# ---------------------------------------------------------------------------
def _is_default(val: Any, default: Any) -> bool:
try:
eq = val == default
if isinstance(eq, np.ndarray):
return bool(eq.all())
return bool(eq)
except Exception:
return False
def _is_zero_vec2(v: Any) -> bool:
return abs(float(v[0])) < 1e-9 and abs(float(v[1])) < 1e-9
def _is_zero_vec3(v: Any) -> bool:
return abs(float(v[0])) < 1e-9 and abs(float(v[1])) < 1e-9 and abs(float(v[2])) < 1e-9
def _is_one_vec2(v: Any) -> bool:
return abs(float(v[0]) - 1.0) < 1e-9 and abs(float(v[1]) - 1.0) < 1e-9
def _is_one_vec3(v: Any) -> bool:
return abs(float(v[0]) - 1.0) < 1e-9 and abs(float(v[1]) - 1.0) < 1e-9 and abs(float(v[2]) - 1.0) < 1e-9
def _is_identity_quat(q: Any) -> bool:
return abs(float(q.w) - 1.0) < 1e-9 and abs(float(q.x)) < 1e-9 and abs(float(q.y)) < 1e-9 and abs(float(q.z)) < 1e-9