Source code for simvx.core.scene_io.emitter

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