Source code for simvx.editor.extract

"""Extract a node subtree to a separate Python file.

Generates a new Python module containing a class definition for a node
and its children, and modifies the original source to import the new class.

Public API:
    node_to_class_source(node, class_name)  -- node subtree -> Python class source
    extract_node_to_file(node, source, output_path, class_name)
        -- full extraction: new file + modified original
"""

import logging
import re
from pathlib import Path

from simvx.core.node import Node

log = logging.getLogger(__name__)

# Core node types known to ship from simvx.core. Mirrors the set used by the
# scene-detection rules in simvx.core.scene_io.detection (Tier 3); kept here
# locally because extract.py is the only consumer that needs it for import
# generation and we don't want to leak this set as a public engine surface.
_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",
    "AudioStreamPlayer", "AudioStreamPlayer2D", "AudioStreamPlayer3D",
    "Sprite2D", "AnimatedSprite2D", "ParticleEmitter",
    "RigidBody2D", "RigidBody3D", "StaticBody2D", "StaticBody3D",
    "KinematicBody2D", "KinematicBody3D",
    "TileMap",
}

_VEC_TAGS = {"__vec2__": "Vec2", "__vec3__": "Vec3", "__quat__": "Quat"}


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)


def _value_to_source(val) -> str:
    """Format a runtime value as Python source for emission."""
    from simvx.core.math.types import Quat, Vec2, Vec3

    if isinstance(val, Vec2):
        return f"Vec2({_fmt_num(float(val[0]))}, {_fmt_num(float(val[1]))})"
    if isinstance(val, Vec3):
        return f"Vec3({_fmt_num(float(val[0]))}, {_fmt_num(float(val[1]))}, {_fmt_num(float(val[2]))})"
    if isinstance(val, Quat):
        return (
            f"Quat({_fmt_num(float(val.w))}, {_fmt_num(float(val.x))}, "
            f"{_fmt_num(float(val.y))}, {_fmt_num(float(val.z))})"
        )
    if isinstance(val, dict):
        for tag, type_name in _VEC_TAGS.items():
            if tag in val:
                args = ", ".join(_fmt_num(c) for c in val[tag])
                return f"{type_name}({args})"
        items = ", ".join(f"{k!r}: {_value_to_source(v)}" for k, v in val.items())
        return "{" + items + "}"
    if isinstance(val, list):
        return "[" + ", ".join(_value_to_source(v) for v in val) + "]"
    if isinstance(val, tuple):
        items = ", ".join(_value_to_source(v) for v in val)
        return f"({items},)" if len(val) == 1 else f"({items})"
    if isinstance(val, bool):
        return repr(val)
    if isinstance(val, int | float):
        return _fmt_num(val)
    return repr(val)


def _get_base_class_name(node: Node) -> str:
    """Return the best simvx.core base class name for code generation.

    If the node's own type is a core type (e.g. Node2D), return that.
    Otherwise walk the MRO to find the nearest core ancestor.
    """
    cls = type(node)
    # If the node's own class is a core type, use it directly
    if cls.__name__ in _CORE_NODE_TYPES or cls.__name__ == "Node":
        return cls.__name__
    # Otherwise find the nearest core ancestor
    for base in cls.__mro__[1:]:
        if base.__name__ in _CORE_NODE_TYPES or base.__name__ == "Node":
            return base.__name__
    return "Node"

def _collect_imports(node: Node) -> set[str]:
    """Recursively collect simvx.core type names needed to reconstruct *node*."""
    types: set[str] = set()
    cls_name = type(node).__name__
    if cls_name in _CORE_NODE_TYPES or cls_name == "Node":
        types.add(cls_name)
    # Also need the base class
    base = _get_base_class_name(node)
    types.add(base)
    # Properties may reference math types
    for prop_name, prop in node.get_properties().items():
        val = getattr(node, prop_name, prop.default)
        types |= _types_from_value(val)
    for child in node.children:
        types |= _collect_imports(child)
    return types

def _types_from_value(val) -> set[str]:
    """Detect simvx math types used by a value."""
    types: set[str] = set()
    type_name = type(val).__name__
    if type_name in ("Vec2", "Vec3", "Quat"):
        types.add(type_name)
    return types

def _format_value(val) -> str:
    """Format a property value as Python source."""
    return _value_to_source(val)


_FACTORY_BUILTIN_NAMES = {list: "list", dict: "dict", set: "set", bytearray: "bytearray"}


def _format_default_factory(factory) -> str | None:
    """Return the source repr of a Property's ``default_factory`` if known.

    Only built-in container constructors are supported; arbitrary lambdas
    and user callables can't be safely round-tripped to source.
    """
    return _FACTORY_BUILTIN_NAMES.get(factory)


def _is_property_default(prop, val) -> bool:
    """Compare a runtime value to the Property's effective default."""
    import numpy as np

    if prop.default_factory is not None:
        try:
            ref = prop.default_factory()
        except Exception:
            return False
    else:
        ref = prop.default
    try:
        eq = val == ref
        return bool(eq) if not isinstance(eq, np.ndarray) else bool(eq.all())
    except Exception:
        return False


def _emit_child_setup(node: Node, indent: str = "        ") -> list[str]:
    """Generate lines that reconstruct *node*'s children inside an __init__ body."""
    lines: list[str] = []
    for child in node.children:
        child_type = type(child).__name__
        kwargs: list[str] = [f'name="{child.name}"']
        # Spatial properties
        for attr in ("position", "rotation", "scale"):
            val = getattr(child, attr, None)
            if val is not None:
                import numpy as np

                from simvx.core.math.types import Quat, Vec2, Vec3

                defaults = {
                    "position": (Vec2, Vec3),
                    "rotation": (float, int, Quat),
                    "scale": (Vec2, Vec3),
                }
                is_default = False
                if attr == "rotation" and isinstance(val, (int, float)):
                    is_default = abs(val) < 1e-9
                elif hasattr(val, "__len__"):
                    if isinstance(val, Quat):
                        is_default = all(abs(getattr(val, c) - d) < 1e-9 for c, d in zip("wxyz", [1, 0, 0, 0]))
                    else:
                        expected = 0.0 if attr == "position" else 1.0
                        is_default = all(abs(float(v) - expected) < 1e-9 for v in val)
                if not is_default:
                    kwargs.append(f"{attr}={_format_value(val)}")

        # Property values
        for prop_name, prop in child.get_properties().items():
            val = getattr(child, prop_name, prop.default)
            if not _is_property_default(prop, val):
                kwargs.append(f"{prop_name}={_format_value(val)}")

        kwargs_str = ", ".join(kwargs)
        var = child.name.lower().replace(" ", "_").replace("-", "_")
        var = re.sub(r"[^a-z0-9_]", "", var)
        if not var or var[0].isdigit():
            var = f"node_{var}"

        lines.append(f"{indent}{var} = {child_type}({kwargs_str})")
        lines.append(f"{indent}self.add_child({var})")

        # Recurse into grandchildren
        grandchild_lines = _emit_child_setup(child, indent)
        for gl in grandchild_lines:
            # Replace self.add_child with var.add_child for grandchildren
            lines.append(gl.replace("self.add_child(", f"{var}.add_child(", 1) if "self.add_child(" in gl else gl)

    return lines

[docs] def node_to_class_source(node: Node, class_name: str | None = None) -> str: """Generate Python source for a class that recreates *node* and its children. Args: node: The node whose subtree to convert. class_name: Name for the generated class. Defaults to the node's class name. Returns: Complete Python source string for the new file. """ cls_name = class_name or type(node).__name__ base_name = _get_base_class_name(node) imports = _collect_imports(node) # Remove the class_name from imports if it matches a custom type imports.discard(cls_name) # Always include the base imports.add(base_name) # Build import line import_names = sorted(imports) header = f"from simvx.core import {', '.join(import_names)}" # Build property declarations + post-init overrides for non-default values. prop_lines: list[str] = [] init_overrides: list[str] = [] for prop_name, prop in node.get_properties().items(): val = getattr(node, prop_name, prop.default) is_default = _is_property_default(prop, val) if prop.default_factory is not None: factory_src = _format_default_factory(prop.default_factory) if factory_src is None: # Unknown factory (lambda / user callable) — can't round-trip safely. log.warning( "extract: skipping Property %r on %r — default_factory %r " "is not a built-in container constructor and cannot be " "emitted as source.", prop_name, type(node).__name__, prop.default_factory, ) continue prop_lines.append(f" {prop_name} = Property(default_factory={factory_src})") if not is_default: init_overrides.append(f" self.{prop_name} = {_format_value(val)}") elif not is_default: prop_lines.append(f" {prop_name} = Property({_format_value(val)})") if prop_lines and "Property" not in imports: import_names.append("Property") import_names.sort() header = f"from simvx.core import {', '.join(import_names)}" # Build __init__ init_lines = [ f" def __init__(self, name: str = \"{node.name}\", **kwargs):", " super().__init__(name=name, **kwargs)", ] if init_overrides: init_lines.extend(init_overrides) child_lines = _emit_child_setup(node) if child_lines: init_lines.extend(child_lines) # Assemble parts = [ f'"""Scene node: {cls_name} -- extracted from parent scene."""', "", header, "", "", f"class {cls_name}({base_name}):", ] if prop_lines: parts.extend(prop_lines) parts.append("") parts.extend(init_lines) parts.append("") return "\n".join(parts)
[docs] def extract_node_to_file( node: Node, source: str, output_path: str, class_name: str | None = None, ) -> tuple[str, str]: """Extract a node subtree to a new Python file. Args: node: The node to extract (must be a child of the scene root). source: The original Python source of the parent scene. output_path: File path for the new module (used to derive import path). class_name: Name for the extracted class. Returns: ``(new_file_source, modified_original_source)`` where: - *new_file_source* contains the class definition with the node's children. - *modified_original_source* has the inline construction replaced with an import and instantiation of the new class. """ cls_name = class_name or type(node).__name__ new_source = node_to_class_source(node, cls_name) # Derive module name from output path out = Path(output_path) module_name = out.stem # Modify original source: # 1. Add import for the new class # 2. Try to find and replace the inline node construction modified = source # Add import statement after existing imports import_line = f"from .{module_name} import {cls_name}" # Find the last import line and insert after it lines = modified.split("\n") last_import_idx = -1 for i, line in enumerate(lines): stripped = line.strip() if stripped.startswith("import ") or stripped.startswith("from "): last_import_idx = i if last_import_idx >= 0: lines.insert(last_import_idx + 1, import_line) else: lines.insert(0, import_line) modified = "\n".join(lines) return new_source, modified