"""Diagnostics -- scene comparison, description, and performance measurement."""
from __future__ import annotations
import time
import numpy as np
from ..node import Node
__all__ = [
"scene_diff",
"NodeCounter",
"FrameTimer",
"scene_describe",
"ui_describe",
]
# ============================================================================
# scene_diff -- compare two scene snapshots
# ============================================================================
[docs]
def scene_diff(before: dict, after: dict, _path: str = "") -> list[str]:
"""Compare two scene snapshots and return a list of human-readable differences.
Each entry describes a single change, e.g.:
"Root/Player.position: (0, 0) -> (10, 5)"
"Root/Enemy: REMOVED"
"Root/PowerUp: ADDED (Node2D)"
"""
if not before and not after:
return []
if not before:
return [f"{_path or '?'}: ADDED ({after.get('type', '?')})"]
if not after:
return [f"{_path or '?'}: REMOVED"]
diffs: list[str] = []
current = f"{_path}/{before['name']}" if _path else before.get("name", "?")
# Compare settings
bs = before.get("settings", {})
as_ = after.get("settings", {})
for key in sorted(set(bs) | set(as_)):
bv = bs.get(key)
av = as_.get(key)
if bv != av:
diffs.append(f"{current}.{key}: {bv!r} -> {av!r}")
# Compare properties (position, rotation, etc.)
bp = before.get("properties", {})
ap = after.get("properties", {})
for key in sorted(set(bp) | set(ap)):
bv = bp.get(key)
av = ap.get(key)
if bv != av:
diffs.append(f"{current}.{key}: {bv!r} -> {av!r}")
# Compare children by name
before_children = {c["name"]: c for c in before.get("children", [])}
after_children = {c["name"]: c for c in after.get("children", [])}
for name in sorted(set(before_children) | set(after_children)):
if name not in after_children:
diffs.append(f"{current}/{name}: REMOVED")
elif name not in before_children:
diffs.append(f"{current}/{name}: ADDED ({after_children[name].get('type', '?')})")
else:
diffs.extend(scene_diff(before_children[name], after_children[name], current))
return diffs
# ============================================================================
# NodeCounter -- count nodes by type
# ============================================================================
[docs]
class NodeCounter:
"""Count nodes by type in a scene tree. Useful for debugging and assertions."""
[docs]
@staticmethod
def count(root: Node) -> dict[str, int]:
"""Return a dict mapping type name to count."""
counts: dict[str, int] = {}
_walk_count(root, counts)
return counts
[docs]
@staticmethod
def total(root: Node) -> int:
"""Return total number of nodes in the tree."""
return sum(NodeCounter.count(root).values())
def _walk_count(node: Node, counts: dict[str, int]) -> None:
type_name = type(node).__name__
counts[type_name] = counts.get(type_name, 0) + 1
for child in node.children:
_walk_count(child, counts)
# ============================================================================
# FrameTimer -- measure frame timing for performance testing
# ============================================================================
[docs]
class FrameTimer:
"""Measure frame timing for performance testing.
Usage:
timer = FrameTimer()
for _ in range(100):
timer.begin_frame()
runner.advance_frames(1)
timer.end_frame()
print(f"Average: {timer.average_ms:.2f}ms, FPS: {timer.fps:.0f}")
"""
def __init__(self):
self._times: list[float] = []
self._start: float | None = None
[docs]
def begin_frame(self) -> None:
self._start = time.perf_counter()
[docs]
def end_frame(self) -> None:
if self._start is not None:
self._times.append(time.perf_counter() - self._start)
self._start = None
@property
def average_ms(self) -> float:
return (sum(self._times) / len(self._times) * 1000) if self._times else 0.0
@property
def max_ms(self) -> float:
return max(self._times) * 1000 if self._times else 0.0
@property
def min_ms(self) -> float:
return min(self._times) * 1000 if self._times else 0.0
@property
def fps(self) -> float:
avg = sum(self._times) / len(self._times) if self._times else 0
return 1.0 / avg if avg > 0 else 0.0
@property
def frame_count(self) -> int:
return len(self._times)
[docs]
def reset(self) -> None:
self._times.clear()
self._start = None
# ============================================================================
# scene_describe -- LLM/human-readable scene tree description
# ============================================================================
[docs]
def scene_describe(root: Node, include_properties: bool = True, include_layout: bool = True) -> str:
"""Return a tree-formatted text description of the scene hierarchy.
Uses box-drawing chars for hierarchy. Each line shows the node name, type,
and relevant state (position, rotation, scale, visibility, Property values).
Designed for LLM consumption -- an LLM can read this to understand what's on screen.
"""
lines: list[str] = []
_describe_node(root, lines, "", True, include_properties, include_layout, is_ui=False)
return "\n".join(lines)
# ============================================================================
# ui_describe -- LLM/human-readable UI tree description
# ============================================================================
[docs]
def ui_describe(root, include_layout: bool = True) -> str:
"""Return a tree-formatted text description of a UI widget hierarchy.
Focused on UI-specific info: widget type, text content, rect, focus, visibility.
Designed for LLM consumption -- an LLM can read this to understand the UI state.
"""
lines: list[str] = []
_describe_node(root, lines, "", True, include_properties=False, include_layout=include_layout, is_ui=True)
return "\n".join(lines)
def _format_vec(v) -> str:
"""Format a Vec2/Vec3/ndarray compactly."""
if hasattr(v, "x") and hasattr(v, "y"):
if hasattr(v, "z"):
return f"({v.x:.3g}, {v.y:.3g}, {v.z:.3g})"
return f"({v.x:.3g}, {v.y:.3g})"
if isinstance(v, np.ndarray):
return "(" + ", ".join(f"{float(x):.3g}" for x in v) + ")"
return repr(v)
def _is_default_position(v) -> bool:
"""Check if a position vector is at origin."""
try:
if hasattr(v, "x") and hasattr(v, "y"):
if hasattr(v, "z"):
return float(v.x) == 0.0 and float(v.y) == 0.0 and float(v.z) == 0.0
return float(v.x) == 0.0 and float(v.y) == 0.0
if isinstance(v, np.ndarray):
return all(float(x) == 0.0 for x in v)
except (TypeError, ValueError):
pass
return False
def _is_default_scale(v) -> bool:
"""Check if a scale vector is (1, 1) or (1, 1, 1)."""
try:
if hasattr(v, "x") and hasattr(v, "y"):
if hasattr(v, "z"):
return float(v.x) == 1.0 and float(v.y) == 1.0 and float(v.z) == 1.0
return float(v.x) == 1.0 and float(v.y) == 1.0
if isinstance(v, np.ndarray):
return all(float(x) == 1.0 for x in v)
except (TypeError, ValueError):
pass
return False
def _is_default_rotation(v) -> bool:
"""Check if rotation is at default (0 for float, identity for Quat)."""
if isinstance(v, (int, float)):
return float(v) == 0.0
if hasattr(v, "w") and hasattr(v, "x"):
return float(v.w) == 1.0 and float(v.x) == 0.0 and float(v.y) == 0.0 and float(v.z) == 0.0
return False
def _describe_node(
node: Node,
lines: list[str],
prefix: str,
is_last: bool,
include_properties: bool,
include_layout: bool,
is_ui: bool,
is_root: bool = True,
) -> None:
"""Recursively build tree description lines for a node."""
if is_root:
connector = ""
child_prefix = ""
else:
connector = "\u2514\u2500 " if is_last else "\u251c\u2500 "
child_prefix = prefix + (" " if is_last else "\u2502 ")
type_name = type(node).__name__
parts: list[str] = [f"{node.name} ({type_name})"]
if not node.visible:
parts.append("visible=False")
if is_ui:
if hasattr(node, "text") and node.text:
text_val = node.text
if len(text_val) > 40:
text_val = text_val[:37] + "..."
parts.append(f'text="{text_val}"')
if hasattr(node, "focused") and node.focused:
parts.append("focused")
if include_layout and hasattr(node, "get_global_rect"):
try:
x, y, w, h = node.get_global_rect()
parts.append(f"rect=({x:.3g}, {y:.3g}, {w:.3g}, {h:.3g})")
except Exception:
pass
else:
if include_layout:
if hasattr(node, "position") and not _is_default_position(node.position):
parts.append(f"pos={_format_vec(node.position)}")
if hasattr(node, "rotation") and not _is_default_rotation(node.rotation):
v = node.rotation
if isinstance(v, (int, float)):
parts.append(f"rot={v:.3g}")
else:
parts.append(f"rot={_format_vec(v)}")
if hasattr(node, "scale") and not _is_default_scale(node.scale):
parts.append(f"scale={_format_vec(node.scale)}")
if include_properties:
props = type(node).get_properties()
for pname, pdesc in sorted(props.items()):
try:
val = getattr(node, pname)
if val != pdesc.default:
parts.append(f"{pname}={val!r}")
except Exception:
pass
line = prefix + connector + " ".join(parts)
lines.append(line)
child_list = list(node.children)
for i, child in enumerate(child_list):
_describe_node(
child,
lines,
child_prefix,
i == len(child_list) - 1,
include_properties,
include_layout,
is_ui,
is_root=False,
)