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