"""Inspector section registry -- extensible node-type-specific inspector sections.
Each InspectorSection subclass handles a specific node type or pattern.
The inspector queries the registry via ``get_sections_for_node(node)`` and
calls ``build_rows()`` to create the section widgets.
Sections are registered with the ``@register_inspector_section`` decorator.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
import numpy as np
from simvx.core import (
AnimatedSprite2D,
AnimatedSprite3D,
AudioStream,
AudioStreamPlayer,
AudioStreamPlayer2D,
AudioStreamPlayer3D,
Button,
CallableCommand,
Camera2D,
Camera3D,
CheckBox,
CollisionShape2D,
CollisionShape3D,
ColourPicker,
Control,
DropDown,
FileDialog,
GPUParticles2D,
GPUParticles3D,
HBoxContainer,
Label,
MeshInstance3D,
Node,
Path2D,
Path3D,
Property,
PropertyCommand,
Signal,
Slider,
SpinBox,
Vec2,
Vec3,
)
from simvx.core.particles import ParticleEmitter
from simvx.core.collision import BoxShape, CapsuleShape, SphereShape
from simvx.core.ui.theme import em, get_theme
if TYPE_CHECKING:
pass
log = logging.getLogger(__name__)
def _font_size() -> float:
"""Get the current font size in logical pixels."""
t = get_theme()
return t.font_size if t.font_size >= 8 else 11.0
def _row_h() -> float:
return em(2.18)
def _label_w() -> float:
return em(7.27)
def _indent() -> float:
return em(1.09)
def _padding() -> float:
return em(0.55)
# ============================================================================
# Shared widget helpers (imported from inspector to avoid duplication)
# ============================================================================
def _make_property_row(label_text: str, widget: Control) -> Control:
"""Create a PropertyRow from the shared section widgets module."""
from .section_widgets import PropertyRow
return PropertyRow(label_text, widget)
def _make_vector_row(label_text: str, components: int, values: tuple, **kwargs) -> Control:
from .section_widgets import VectorRow
return VectorRow(label_text, components, values, **kwargs)
def _make_resource_picker(current_path: str | None = None, file_filter: str = "*.*") -> Control:
from .section_widgets import ResourcePicker
return ResourcePicker(current_path=current_path, file_filter=file_filter)
# ============================================================================
# InspectorContext -- stable API for sections to interact with the inspector
# ============================================================================
[docs]
class InspectorContext:
"""Helper passed to InspectorSection.build_rows().
Provides undo-aware property editing without exposing InspectorPanel internals.
"""
def __init__(self, inspector):
self._inspector = inspector
[docs]
def on_property_changed(self, node: Node, prop: str, old_val: Any, new_val: Any):
"""Push a PropertyCommand through the undo stack."""
self._inspector._on_property_changed(node, prop, old_val, new_val)
[docs]
def on_callable_command(self, do_fn, undo_fn, description: str):
"""Push a CallableCommand through the undo stack."""
state = self._inspector.state
if state is not None:
cmd = CallableCommand(do_fn, undo_fn, description=description)
state.undo_stack.push(cmd)
state.modified = True
else:
do_fn()
[docs]
def on_material_prop_changed(self, node, prop: str, value: Any):
"""Handle material sub-object property change with undo."""
self._inspector._on_material_prop_changed(node, prop, value)
[docs]
def on_material_colour_changed(self, node, new_colour: tuple):
"""Handle material colour change with undo."""
self._inspector._on_material_colour_changed(node, new_colour)
[docs]
def on_material_texture_changed(self, node, attr: str, path: str | None):
"""Handle material texture URI change with undo."""
self._inspector._on_material_texture_changed(node, attr, path)
[docs]
def rebuild(self):
"""Request full inspector rebuild."""
self._inspector._rebuild()
@property
def editor_state(self):
return self._inspector.state
[docs]
def register_widget(self, key: str, widget: Control):
"""Register a widget in the inspector's _property_widgets dict."""
self._inspector._property_widgets[key] = widget
@property
def property_changed_signal(self) -> Signal:
return self._inspector.property_changed
# ============================================================================
# InspectorSection base class
# ============================================================================
[docs]
class InspectorSection:
"""Base class for node-type-specific inspector sections.
Subclasses override ``can_handle()``, ``build_rows()``, and optionally
``handled_properties()`` to claim properties from the generic section.
"""
section_title: str = "Section"
priority: int = 0 # Higher = appears later
[docs]
def can_handle(self, node: Node) -> bool:
raise NotImplementedError
[docs]
def build_rows(self, node: Node, ctx: InspectorContext) -> list[Control]:
raise NotImplementedError
[docs]
def handled_properties(self, node: Node) -> set[str]:
"""Property names this section manages (excluded from generic section)."""
return set()
# ============================================================================
# Section registry
# ============================================================================
_SECTION_REGISTRY: list[type[InspectorSection]] = []
[docs]
def register_inspector_section(cls: type[InspectorSection]) -> type[InspectorSection]:
"""Decorator to register an InspectorSection subclass."""
_SECTION_REGISTRY.append(cls)
return cls
[docs]
def get_sections_for_node(node: Node) -> list[InspectorSection]:
"""Return instantiated sections applicable to the given node, sorted by priority."""
sections = [cls() for cls in _SECTION_REGISTRY if cls().can_handle(node)]
sections.sort(key=lambda s: s.priority)
return sections
# ============================================================================
# MeshSection -- mesh primitive creation
# ============================================================================
[docs]
@register_inspector_section
class MeshSection(InspectorSection):
section_title = "Mesh"
priority = 10
[docs]
def can_handle(self, node):
return isinstance(node, MeshInstance3D)
[docs]
def build_rows(self, node, ctx):
from simvx.core import Mesh
rows: list[Control] = []
if node.mesh is None:
mesh_row = HBoxContainer()
mesh_row.separation = 4
for prim_name, factory in [
("Cube", lambda: Mesh.cube(size=1.0)),
("Sphere", lambda: Mesh.sphere(radius=1.0, rings=16, segments=16)),
("Cylinder", lambda: Mesh.cylinder(radius=1.0, height=1.0, segments=16)),
]:
btn = Button(prim_name)
btn.size = Vec2(70, _row_h())
btn.font_size = _font_size()
btn.pressed.connect(lambda f=factory, c=ctx, n=node: _set_mesh(n, f(), c))
mesh_row.add_child(btn)
rows.append(mesh_row)
elif node.material is None:
mat_btn = Button("Add Material")
mat_btn.size = Vec2(120, _row_h())
mat_btn.font_size = _font_size()
mat_btn.pressed.connect(lambda c=ctx, n=node: _add_material(n, c))
rows.append(mat_btn)
return rows
def _set_mesh(node, mesh, ctx):
node.mesh = mesh
if ctx.editor_state:
ctx.editor_state.modified = True
ctx.rebuild()
def _add_material(node, ctx):
from simvx.core import Material
node.material = Material()
if ctx.editor_state:
ctx.editor_state.modified = True
ctx.rebuild()
# ============================================================================
# MaterialSection -- PBR material editing
# ============================================================================
[docs]
@register_inspector_section
class MaterialSection(InspectorSection):
section_title = "Material"
priority = 11
[docs]
def can_handle(self, node):
return isinstance(node, MeshInstance3D) and node.material is not None
[docs]
def build_rows(self, node, ctx):
mat = node.material
rows: list[Control] = []
# Colour
picker = ColourPicker()
picker.size = Vec2(200, 180)
picker.colour = mat.colour
picker.colour_changed.connect(lambda colour, c=ctx, n=node: c.on_material_colour_changed(n, colour))
colour_row = _make_property_row("Colour", picker)
colour_row.size = Vec2(300, 184)
rows.append(colour_row)
ctx.register_widget("mat_colour", picker)
# Metallic
metallic_slider = Slider(min_val=0.0, max_val=1.0, value=mat.metallic)
metallic_slider.step = 0.01
metallic_slider.value_changed.connect(
lambda val, c=ctx, n=node: c.on_material_prop_changed(n, "metallic", val))
rows.append(_make_property_row("Metallic", metallic_slider))
ctx.register_widget("mat_metallic", metallic_slider)
# Roughness
roughness_slider = Slider(min_val=0.0, max_val=1.0, value=mat.roughness)
roughness_slider.step = 0.01
roughness_slider.value_changed.connect(
lambda val, c=ctx, n=node: c.on_material_prop_changed(n, "roughness", val))
rows.append(_make_property_row("Roughness", roughness_slider))
ctx.register_widget("mat_roughness", roughness_slider)
# Blend mode
blend_modes = ["opaque", "alpha", "additive"]
blend_idx = blend_modes.index(mat.blend) if mat.blend in blend_modes else 0
blend_dd = DropDown(items=blend_modes, selected=blend_idx)
blend_dd.font_size = _font_size()
blend_dd.item_selected.connect(
lambda idx, c=ctx, n=node: c.on_material_prop_changed(n, "blend", blend_modes[idx]))
rows.append(_make_property_row("Blend", blend_dd))
ctx.register_widget("mat_blend", blend_dd)
# Wireframe
wire_cb = CheckBox("", checked=mat.wireframe)
wire_cb.toggled.connect(lambda checked, c=ctx, n=node: c.on_material_prop_changed(n, "wireframe", checked))
rows.append(_make_property_row("Wireframe", wire_cb))
ctx.register_widget("mat_wireframe", wire_cb)
# Unlit
unlit_cb = CheckBox("", checked=mat.unlit)
unlit_cb.toggled.connect(lambda checked, c=ctx, n=node: c.on_material_prop_changed(n, "unlit", checked))
rows.append(_make_property_row("Unlit", unlit_cb))
ctx.register_widget("mat_unlit", unlit_cb)
# Texture pickers
tex_filter = "*.png;*.jpg;*.jpeg;*.bmp;*.tga"
albedo_picker = _make_resource_picker(current_path=mat.albedo_uri, file_filter=tex_filter)
albedo_picker.file_selected.connect(
lambda path, c=ctx, n=node: c.on_material_texture_changed(n, "albedo_uri", path))
albedo_picker.cleared.connect(
lambda c=ctx, n=node: c.on_material_texture_changed(n, "albedo_uri", None))
rows.append(_make_property_row("Albedo Tex", albedo_picker))
ctx.register_widget("mat_albedo_tex", albedo_picker)
normal_picker = _make_resource_picker(current_path=mat.normal_uri, file_filter=tex_filter)
normal_picker.file_selected.connect(
lambda path, c=ctx, n=node: c.on_material_texture_changed(n, "normal_uri", path))
normal_picker.cleared.connect(
lambda c=ctx, n=node: c.on_material_texture_changed(n, "normal_uri", None))
rows.append(_make_property_row("Normal Map", normal_picker))
ctx.register_widget("mat_normal_tex", normal_picker)
return rows
# ============================================================================
# AudioStreamSection -- audio file picker
# ============================================================================
[docs]
@register_inspector_section
class AudioStreamSection(InspectorSection):
section_title = "Audio"
priority = 20
[docs]
def can_handle(self, node):
return isinstance(node, AudioStreamPlayer | AudioStreamPlayer2D | AudioStreamPlayer3D)
[docs]
def build_rows(self, node, ctx):
rows: list[Control] = []
current_path = node.stream.path if node.stream else None
picker = _make_resource_picker(current_path=current_path, file_filter="*.ogg;*.wav;*.mp3;*.flac")
picker.file_selected.connect(lambda path, c=ctx, n=node: _set_audio_stream(n, path, c))
picker.cleared.connect(lambda c=ctx, n=node: _set_audio_stream(n, None, c))
rows.append(_make_property_row("Stream", picker))
ctx.register_widget("audio_stream", picker)
return rows
def _set_audio_stream(node, path, ctx):
old_stream = node.stream
new_stream = AudioStream(path) if path else None
def do_fn():
node.stream = new_stream
def undo_fn():
node.stream = old_stream
ctx.on_callable_command(do_fn, undo_fn, description=f"Set {node.name} audio stream")
ctx.property_changed_signal.emit(node, "stream", old_stream, new_stream)
# ============================================================================
# CollisionShapeSection -- shape type + parameters
# ============================================================================
[docs]
@register_inspector_section
class CollisionShapeSection(InspectorSection):
section_title = "Collision Shape"
priority = 30
[docs]
def can_handle(self, node):
return isinstance(node, CollisionShape2D | CollisionShape3D)
[docs]
def handled_properties(self, node):
return {"radius"}
[docs]
def build_rows(self, node, ctx):
rows: list[Control] = []
is_3d = isinstance(node, CollisionShape3D)
shape = getattr(node, "_collision_shape", None)
shape_types = ["Sphere", "Box", "Capsule"]
current_idx = 0
if isinstance(shape, BoxShape):
current_idx = 1
elif isinstance(shape, CapsuleShape):
current_idx = 2
dd = DropDown(items=shape_types, selected=current_idx)
dd.font_size = _font_size()
dd.item_selected.connect(
lambda idx, c=ctx, n=node, st=shape_types: _change_shape_type(n, st[idx], c))
rows.append(_make_property_row("Shape", dd))
ctx.register_widget("shape_type", dd)
if isinstance(shape, BoxShape):
he = shape.half_extents
comps = 3 if is_3d else 2
he_vals = tuple(float(he[i]) for i in range(comps))
he_row = _make_vector_row("", comps, he_vals, step=0.1, min_val=0.01)
for i, spin in enumerate(he_row._spinboxes):
axis = i
spin.value_changed.connect(
lambda val, ax=axis, c=ctx, n=node, s=shape: _change_box_extents(n, s, ax, val, c))
rows.append(_make_property_row("Half Ext", he_row))
ctx.register_widget("shape_half_extents", he_row)
elif isinstance(shape, CapsuleShape):
rad_spin = SpinBox(min_val=0.01, max_val=10000, value=shape.radius, step=0.1)
rad_spin.font_size = _font_size()
rad_spin.value_changed.connect(
lambda val, c=ctx, n=node, s=shape: _change_capsule_param(n, s, "radius", val, c))
rows.append(_make_property_row("Radius", rad_spin))
ctx.register_widget("shape_capsule_radius", rad_spin)
height_spin = SpinBox(min_val=0.01, max_val=10000, value=shape.height, step=0.1)
height_spin.font_size = _font_size()
height_spin.value_changed.connect(
lambda val, c=ctx, n=node, s=shape: _change_capsule_param(n, s, "height", val, c))
rows.append(_make_property_row("Height", height_spin))
ctx.register_widget("shape_capsule_height", height_spin)
else:
# Sphere
rad_spin = SpinBox(min_val=0.01, max_val=10000, value=node.radius, step=0.1)
rad_spin.font_size = _font_size()
rad_spin.value_changed.connect(
lambda val, c=ctx, n=node: c.on_property_changed(n, "radius", n.radius, val))
rows.append(_make_property_row("Radius", rad_spin))
ctx.register_widget("shape_sphere_radius", rad_spin)
return rows
def _change_shape_type(node, shape_name, ctx):
old_shape = getattr(node, "_collision_shape", None)
is_3d = isinstance(node, CollisionShape3D)
if shape_name == "Sphere":
new_shape = SphereShape(radius=node.radius)
elif shape_name == "Box":
new_shape = BoxShape(half_extents=(0.5, 0.5, 0.5))
elif shape_name == "Capsule":
new_shape = CapsuleShape(radius=0.5, height=2.0)
else:
return
def do_fn():
node._collision_shape = new_shape
def undo_fn():
node._collision_shape = old_shape
ctx.on_callable_command(do_fn, undo_fn, description=f"Set {node.name} shape to {shape_name}")
ctx.property_changed_signal.emit(node, "_collision_shape", old_shape, new_shape)
ctx.rebuild()
def _change_box_extents(node, shape, axis, value, ctx):
if not isinstance(shape, BoxShape):
return
old_he = np.copy(shape.half_extents)
new_he = np.copy(shape.half_extents)
new_he[axis] = value
def do_fn():
shape.half_extents = np.copy(new_he)
def undo_fn():
shape.half_extents = np.copy(old_he)
ctx.on_callable_command(do_fn, undo_fn, description=f"Set {node.name} box half_extents")
def _change_capsule_param(node, shape, param, value, ctx):
if not isinstance(shape, CapsuleShape):
return
old_val = getattr(shape, param)
if old_val == value:
return
def do_fn():
setattr(shape, param, value)
def undo_fn():
setattr(shape, param, old_val)
ctx.on_callable_command(do_fn, undo_fn, description=f"Set {node.name} capsule {param}")
# ============================================================================
# PostProcessToggleSection -- quick enable/disable for post-process effects
# ============================================================================
# Maps display label -> WorldEnvironment property name
_POST_PROCESS_TOGGLES: list[tuple[str, str]] = [
("Bloom", "bloom_enabled"),
("SSAO", "ssao_enabled"),
("Depth of Field", "dof_enabled"),
("Motion Blur", "motion_blur_enabled"),
("Film Grain", "film_grain_enabled"),
("Vignette", "vignette_enabled"),
("Chromatic Aberration", "chromatic_aberration_enabled"),
("Fog", "fog_enabled"),
("Colour Grading", "colour_grading_enabled"),
]
[docs]
@register_inspector_section
class PostProcessToggleSection(InspectorSection):
"""Compact toggle summary for post-processing effects on WorldEnvironment."""
section_title = "Post Processing"
priority = 5
[docs]
def can_handle(self, node):
from simvx.core.world_environment import WorldEnvironment
return isinstance(node, WorldEnvironment)
[docs]
def handled_properties(self, node):
return {prop for _, prop in _POST_PROCESS_TOGGLES}
[docs]
def build_rows(self, node, ctx):
rows: list[Control] = []
for label_text, prop_name in _POST_PROCESS_TOGGLES:
current = getattr(node, prop_name, False)
cb = CheckBox(label_text, checked=current)
cb.size = Vec2(200, _row_h())
cb.font_size = _font_size()
cb.toggled.connect(
lambda checked, p=prop_name, c=ctx, n=node: c.on_property_changed(
n, p, getattr(n, p), checked
)
)
rows.append(cb)
ctx.register_widget(f"pp_{prop_name}", cb)
return rows
# ============================================================================
# _BitToggle -- Small toggle button for a single bit in a bitmask
# ============================================================================
class _BitToggle(Control):
"""20x20 toggle button showing a bit number. Highlighted when active."""
_ACTIVE_BG = (0.25, 0.45, 0.85, 1.0)
_INACTIVE_BG = (0.18, 0.18, 0.18, 1.0)
_HOVER_BG = (0.3, 0.3, 0.3, 1.0)
_ACTIVE_HOVER_BG = (0.35, 0.55, 0.95, 1.0)
_TEXT_COLOUR = (0.9, 0.9, 0.9, 1.0)
_BORDER_COLOUR = (0.4, 0.4, 0.4, 1.0)
def __init__(self, bit_number: int, active: bool = False, **kwargs):
super().__init__(**kwargs)
self.bit_number = bit_number
self.active = active
self.toggled = Signal()
self.size = Vec2(20, 20)
self.mouse_filter = True
def _on_gui_input(self, event):
if event.button == 1 and event.pressed:
if self.is_point_inside(event.position):
self.active = not self.active
self.toggled.emit(self.active)
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
# Background
if self.active:
bg = self._ACTIVE_HOVER_BG if self.mouse_over else self._ACTIVE_BG
else:
bg = self._HOVER_BG if self.mouse_over else self._INACTIVE_BG
renderer.draw_filled_rect(x, y, w, h, bg)
# Border
renderer.draw_rect_coloured(x, y, w, h, self._BORDER_COLOUR)
# Bit number text (1-based display)
label = str(self.bit_number + 1)
scale = 9.0 / 14.0
# Centre text in the box
char_w = 9.0 * 0.6
text_w = len(label) * char_w
tx = x + (w - text_w) / 2
ty = y + (h - 9.0) / 2
renderer.draw_text_coloured(label, tx, ty, scale, self._TEXT_COLOUR)
# ============================================================================
# CollisionLayerSection -- visual bitmask editor for collision layer/mask
# ============================================================================
[docs]
@register_inspector_section
class CollisionLayerSection(InspectorSection):
"""Visual 32-bit grid editor for collision_layer and collision_mask properties."""
section_title = "Collision Layers"
priority = 25
[docs]
def can_handle(self, node):
return hasattr(node, "collision_layer") and hasattr(node, "collision_mask")
[docs]
def handled_properties(self, node):
return {"collision_layer", "collision_mask"}
[docs]
def build_rows(self, node, ctx):
rows: list[Control] = []
for prop_name, label_text in [("collision_layer", "Layer"), ("collision_mask", "Mask")]:
current_val = int(getattr(node, prop_name, 0))
hbox = HBoxContainer()
hbox.separation = 2
hbox.size = Vec2(8 * 22, 20)
for bit in range(8):
active = bool(current_val & (1 << bit))
toggle = _BitToggle(bit, active=active)
toggle.toggled.connect(
_make_bit_toggle_handler(node, prop_name, bit, ctx)
)
hbox.add_child(toggle)
ctx.register_widget(f"collision_{prop_name}_bit{bit}", toggle)
rows.append(_make_property_row(label_text, hbox))
return rows
def _make_bit_toggle_handler(node, prop_name: str, bit: int, ctx):
"""Return a closure that toggles a specific bit with undo support."""
def handler(active: bool):
old_val = int(getattr(node, prop_name))
if active:
new_val = old_val | (1 << bit)
else:
new_val = old_val & ~(1 << bit)
if old_val != new_val:
ctx.on_property_changed(node, prop_name, old_val, new_val)
return handler
# ============================================================================
# CameraPreviewSection -- camera viewport preview placeholder
# ============================================================================
_PREVIEW_W = 200.0
_PREVIEW_H = 120.0
_PREVIEW_BG = (0.08, 0.08, 0.12, 1.0)
_PREVIEW_BORDER = (0.35, 0.45, 0.6, 1.0)
_PREVIEW_TEXT = (0.5, 0.55, 0.65, 1.0)
_INFO_TEXT = (0.7, 0.7, 0.7, 1.0)
class _CameraPreview(Control):
"""Live camera preview widget -- captures the scene and displays it as a texture.
On first draw (and when camera properties change), captures the current
framebuffer, downsamples to preview size, uploads as a GPU texture, and
draws it. Falls back to a placeholder when no engine is available.
"""
_draw_caching = False
_REFRESH_INTERVAL = 30 # re-capture every N frames
def __init__(self, camera_node: Node, **kwargs):
super().__init__(**kwargs)
self._camera = camera_node
self.size = Vec2(_PREVIEW_W, _PREVIEW_H)
self._texture_id: int = -1
self._last_props: str = ""
self._frame_counter: int = 0
self._capture_attempted: bool = False
def _camera_info(self) -> str:
"""Build a one-line summary of camera parameters."""
cam = self._camera
if isinstance(cam, Camera3D):
return f"Perspective FOV: {cam.fov:.0f}\u00b0 Near: {cam.near} Far: {cam.far}"
if isinstance(cam, Camera2D):
parts = [f"Zoom: {cam.zoom:.1f}x"]
has_limits = (
cam.limit_left > -1e8
or cam.limit_right < 1e8
or cam.limit_top > -1e8
or cam.limit_bottom < 1e8
)
if has_limits:
parts.append(
f"Limits: L={cam.limit_left:.0f} R={cam.limit_right:.0f} "
f"T={cam.limit_top:.0f} B={cam.limit_bottom:.0f}"
)
return " ".join(parts)
return ""
def _camera_prop_key(self) -> str:
"""Fingerprint camera properties to detect changes."""
cam = self._camera
if isinstance(cam, Camera3D):
pos = cam.world_position
return f"{cam.fov},{cam.near},{cam.far},{pos[0]:.2f},{pos[1]:.2f},{pos[2]:.2f}"
if isinstance(cam, Camera2D):
pos = cam.world_position
return f"{cam.zoom},{pos[0]:.2f},{pos[1]:.2f}"
return ""
def _try_capture(self) -> bool:
"""Attempt to capture the current framebuffer and upload as a texture.
Returns True if capture succeeded, False otherwise.
"""
cam = self._camera
app = getattr(cam, "app", None)
if app is None:
return False
engine = getattr(app, "_engine", None) or getattr(app, "engine", None)
renderer = getattr(engine, "renderer", None) if engine is not None else None
if renderer is None or not hasattr(renderer, "capture_frame"):
return False
try:
pixels = renderer.capture_frame() # (H, W, 4) uint8
if pixels is None or pixels.size == 0:
return False
# Downsample to preview dimensions
ph, pw = int(_PREVIEW_H), int(_PREVIEW_W)
src_h, src_w = pixels.shape[:2]
if src_h < 2 or src_w < 2:
return False
# Simple strided downsample (fast, no PIL dependency)
step_y = max(1, src_h // ph)
step_x = max(1, src_w // pw)
small = pixels[::step_y, ::step_x][:ph, :pw]
# Ensure exact preview size via padding/cropping
final = np.zeros((ph, pw, 4), dtype=np.uint8)
copy_h = min(small.shape[0], ph)
copy_w = min(small.shape[1], pw)
final[:copy_h, :copy_w] = small[:copy_h, :copy_w]
self._texture_id = engine.upload_texture_pixels(
np.ascontiguousarray(final), pw, ph,
)
return True
except Exception:
log.debug("Camera preview capture failed", exc_info=True)
return False
def process(self, dt: float):
self._frame_counter += 1
# Capture on first process, then periodically or on property change
props = self._camera_prop_key()
needs_refresh = (
self._texture_id < 0
or props != self._last_props
or self._frame_counter % self._REFRESH_INTERVAL == 0
)
if needs_refresh and self._frame_counter > 1:
if self._try_capture():
self._last_props = props
def draw(self, renderer):
x, y, w, h = self.get_global_rect()
info = self._camera_info()
if self._texture_id >= 0:
# Draw captured preview texture
renderer.draw_filled_rect(x, y, w, _PREVIEW_H, _PREVIEW_BG)
renderer.draw_texture(self._texture_id, x, y, w, _PREVIEW_H)
renderer.draw_rect_coloured(x, y, w, _PREVIEW_H, _PREVIEW_BORDER)
else:
# Fallback placeholder
renderer.draw_filled_rect(x, y, w, _PREVIEW_H, _PREVIEW_BG)
renderer.draw_rect_coloured(x, y, w, _PREVIEW_H, _PREVIEW_BORDER)
scale = _font_size() / 14.0
label = "Camera Preview"
tw = renderer.text_width(label, scale)
renderer.draw_text_coloured(
label, x + (w - tw) / 2, y + (_PREVIEW_H - _font_size()) / 2, scale, _PREVIEW_TEXT,
)
# Camera info line below the preview rectangle
if info:
info_scale = (_font_size() - 1) / 14.0
renderer.draw_text_coloured(info, x + 2, y + _PREVIEW_H + 2, info_scale, _INFO_TEXT)
[docs]
@register_inspector_section
class CameraPreviewSection(InspectorSection):
"""Shows a live camera preview (captured framebuffer) and key camera parameters."""
section_title = "Camera Preview"
priority = 2
[docs]
def can_handle(self, node):
return isinstance(node, Camera2D | Camera3D)
[docs]
def handled_properties(self, node):
return set()
[docs]
def build_rows(self, node, ctx):
rows: list[Control] = []
# Preview label
lbl = Label("Preview")
lbl.font_size = _font_size()
lbl.size = Vec2(_PREVIEW_W, _row_h())
rows.append(lbl)
# Camera preview widget
preview = _CameraPreview(node)
# Reserve extra height for the info text below
info_text = preview._camera_info()
extra = _font_size() * 1.4 if info_text else 0.0
preview.size = Vec2(_PREVIEW_W, _PREVIEW_H + extra)
rows.append(preview)
ctx.register_widget("camera_preview", preview)
return rows
# ============================================================================
# _EmissionShapePreview -- schematic emission shape outline
# ============================================================================
_SHAPE_SIZE = 60.0
_SHAPE_BG = (0.08, 0.08, 0.12, 1.0)
_SHAPE_BORDER = (0.35, 0.45, 0.6, 1.0)
class _EmissionShapePreview(Control):
"""60x60 widget that draws a schematic outline of the emission shape."""
_draw_caching = False
def __init__(self, particle_node: Node, **kwargs):
super().__init__(**kwargs)
self._node = particle_node
self.size = Vec2(_SHAPE_SIZE, _SHAPE_SIZE)
def draw(self, renderer):
import math
x, y, w, h = self.get_global_rect()
theme = get_theme()
outline = theme.get("accent", _SHAPE_BORDER)
# Background
renderer.draw_filled_rect(x, y, w, h, _SHAPE_BG)
renderer.draw_rect_coloured(x, y, w, h, _SHAPE_BORDER)
cx, cy = x + w / 2, y + h / 2
shape = str(getattr(self._node, "emission_shape", "point"))
if shape == "sphere":
radius = float(getattr(self._node, "emission_radius", 1.0))
# Scale to fit inside the preview (max radius maps to ~25px)
r = min(max(radius * 5.0, 4.0), 25.0)
# Approximate circle with line segments
segs = 24
for i in range(segs):
a0 = 2 * math.pi * i / segs
a1 = 2 * math.pi * (i + 1) / segs
x0 = cx + r * math.cos(a0)
y0 = cy + r * math.sin(a0)
x1 = cx + r * math.cos(a1)
y1 = cy + r * math.sin(a1)
renderer.draw_line(x0, y0, x1, y1, outline)
elif shape == "box":
box = getattr(self._node, "emission_box", (1.0, 1.0, 1.0))
bx = float(box[0]) if hasattr(box, "__getitem__") else float(box)
by = float(box[1]) if hasattr(box, "__getitem__") and len(box) > 1 else bx
# Scale to fit (max half-extent maps to ~25px)
scale = min(25.0 / max(bx, by, 0.01), 25.0)
rw = max(bx * scale * 2, 4.0)
rh = max(by * scale * 2, 4.0)
renderer.draw_rect_coloured(cx - rw / 2, cy - rh / 2, rw, rh, outline)
else:
# Point -- small filled dot
dot_r = 3.0
renderer.draw_filled_rect(cx - dot_r, cy - dot_r, dot_r * 2, dot_r * 2, outline)
# ============================================================================
# ParticlePreviewSection -- emit/reset controls + emission shape preview
# ============================================================================
_PARTICLE_TYPES = (ParticleEmitter, GPUParticles2D, GPUParticles3D)
[docs]
@register_inspector_section
class ParticlePreviewSection(InspectorSection):
"""Compact control bar and emission shape preview for particle emitter nodes."""
section_title = "Particle Controls"
priority = 3
[docs]
def can_handle(self, node):
return isinstance(node, _PARTICLE_TYPES)
[docs]
def handled_properties(self, node):
return set()
[docs]
def build_rows(self, node, ctx):
rows: list[Control] = []
# -- Control bar: Emit | Reset | particle count label --
bar = HBoxContainer()
bar.separation = 4
bar.size = Vec2(250, _row_h())
emit_btn = Button("Emit")
emit_btn.size = Vec2(50, _row_h())
emit_btn.font_size = _font_size()
def _do_emit(c=ctx, n=node):
old = n.emitting
c.on_callable_command(
lambda: setattr(n, "emitting", True),
lambda: setattr(n, "emitting", old),
description=f"Start {n.name} emitting",
)
emit_btn.pressed.connect(_do_emit)
bar.add_child(emit_btn)
ctx.register_widget("particle_emit", emit_btn)
reset_btn = Button("Reset")
reset_btn.size = Vec2(50, _row_h())
reset_btn.font_size = _font_size()
def _do_reset(c=ctx, n=node):
old = n.emitting
c.on_callable_command(
lambda: setattr(n, "emitting", False),
lambda: setattr(n, "emitting", old),
description=f"Stop {n.name} emitting",
)
reset_btn.pressed.connect(_do_reset)
bar.add_child(reset_btn)
ctx.register_widget("particle_reset", reset_btn)
# Particle count (read-only)
count_label = Label("")
count_label.font_size = _font_size()
count_label.size = Vec2(100, _row_h())
if hasattr(node, "alive_count"):
count_label.text = f"Alive: {node.alive_count}"
elif hasattr(node, "amount"):
count_label.text = f"Max: {node.amount}"
bar.add_child(count_label)
ctx.register_widget("particle_count", count_label)
rows.append(bar)
# -- Emission shape preview --
shape_label = Label("Emission Shape")
shape_label.font_size = _font_size()
shape_label.size = Vec2(120, _row_h())
rows.append(shape_label)
preview = _EmissionShapePreview(node)
rows.append(preview)
ctx.register_widget("particle_shape_preview", preview)
return rows
# ============================================================================
# SpriteAnimationSection -- playback controls for AnimatedSprite2D / 3D
# ============================================================================
_ANIM_HANDLED_PROPS = {"frame", "playing", "current_animation", "animations", "speed_scale"}
def _get_anim(node) -> object | None:
"""Return the active SpriteAnimation/SpriteAnimation3D or None."""
name = getattr(node, "current_animation", None)
if name and name in node.animations:
return node.animations[name]
return None
def _total_frames(node) -> int:
"""Total frame count for the current animation (or the full sheet)."""
anim = _get_anim(node)
if anim:
return len(anim.frames)
return max(getattr(node, "frames_h", 1) * getattr(node, "frames_v", 1), 1)
def _current_fps(node) -> float:
anim = _get_anim(node)
return anim.fps if anim else 10.0
[docs]
@register_inspector_section
class SpriteAnimationSection(InspectorSection):
"""Playback controls and frame info for AnimatedSprite2D / AnimatedSprite3D."""
section_title = "Sprite Animation"
priority = 4
[docs]
def can_handle(self, node):
return isinstance(node, AnimatedSprite2D | AnimatedSprite3D)
[docs]
def handled_properties(self, node):
return _ANIM_HANDLED_PROPS
[docs]
def build_rows(self, node, ctx):
rows: list[Control] = []
total = _total_frames(node)
# -- Playback controls bar --
bar = HBoxContainer()
bar.separation = 2
first_btn = Button("|<")
first_btn.size = Vec2(30, _row_h())
first_btn.font_size = _font_size()
first_btn.pressed.connect(lambda n=node, c=ctx: _set_frame(n, 0, c))
bar.add_child(first_btn)
ctx.register_widget("anim_first", first_btn)
prev_btn = Button("<")
prev_btn.size = Vec2(30, _row_h())
prev_btn.font_size = _font_size()
prev_btn.pressed.connect(lambda n=node, c=ctx: _set_frame(n, max(n.frame - 1, 0), c))
bar.add_child(prev_btn)
ctx.register_widget("anim_prev", prev_btn)
play_label = "Pause" if node.playing else "Play"
play_btn = Button(play_label)
play_btn.size = Vec2(50, _row_h())
play_btn.font_size = _font_size()
play_btn.pressed.connect(lambda n=node, c=ctx, b=play_btn: _toggle_playing(n, c, b))
bar.add_child(play_btn)
ctx.register_widget("anim_play", play_btn)
next_btn = Button(">")
next_btn.size = Vec2(30, _row_h())
next_btn.font_size = _font_size()
next_btn.pressed.connect(
lambda n=node, c=ctx: _set_frame(n, min(n.frame + 1, _total_frames(n) - 1), c)
)
bar.add_child(next_btn)
ctx.register_widget("anim_next", next_btn)
last_btn = Button(">|")
last_btn.size = Vec2(30, _row_h())
last_btn.font_size = _font_size()
last_btn.pressed.connect(lambda n=node, c=ctx: _set_frame(n, _total_frames(n) - 1, c))
bar.add_child(last_btn)
ctx.register_widget("anim_last", last_btn)
rows.append(bar)
# -- Frame info label --
frame_label = Label(f"Frame: {node.frame} / {total}")
frame_label.font_size = _font_size()
frame_label.size = Vec2(200, _row_h())
rows.append(_make_property_row("Frame", frame_label))
ctx.register_widget("anim_frame_label", frame_label)
# -- FPS control --
fps = _current_fps(node)
fps_spin = SpinBox(min_val=0.1, max_val=120.0, value=fps, step=0.5)
fps_spin.font_size = _font_size()
fps_spin.value_changed.connect(lambda val, n=node, c=ctx: _set_fps(n, val, c))
rows.append(_make_property_row("FPS", fps_spin))
ctx.register_widget("anim_fps", fps_spin)
# -- Frames info --
anim = _get_anim(node)
anim_name = node.current_animation or "(none)"
grid = f"{getattr(node, 'frames_h', 1)}x{getattr(node, 'frames_v', 1)}"
info_text = f"{anim_name} | {total} frames | Grid: {grid}"
info_label = Label(info_text)
info_label.font_size = _font_size()
info_label.size = Vec2(280, _row_h())
rows.append(_make_property_row("Frames", info_label))
ctx.register_widget("anim_info", info_label)
# -- Animation list (if multiple) --
if len(node.animations) > 1:
anim_names = list(node.animations)
current_idx = anim_names.index(node.current_animation) if node.current_animation in anim_names else 0
dd = DropDown(items=anim_names, selected=current_idx)
dd.font_size = _font_size()
dd.item_selected.connect(
lambda idx, n=node, c=ctx, names=anim_names: _set_animation(n, names[idx], c)
)
rows.append(_make_property_row("Animation", dd))
ctx.register_widget("anim_dropdown", dd)
return rows
def _set_frame(node, new_frame: int, ctx: InspectorContext):
"""Set current frame with undo support."""
old_frame = node.frame
if old_frame == new_frame:
return
def do_fn():
node.frame = new_frame
def undo_fn():
node.frame = old_frame
ctx.on_callable_command(do_fn, undo_fn, description=f"Set {node.name} frame to {new_frame}")
def _toggle_playing(node, ctx: InspectorContext, btn: Button):
"""Toggle playing state with undo support."""
old_val = node.playing
new_val = not old_val
def do_fn():
node.playing = new_val
def undo_fn():
node.playing = old_val
ctx.on_callable_command(do_fn, undo_fn, description=f"{'Pause' if old_val else 'Play'} {node.name}")
btn.text = "Pause" if new_val else "Play"
def _set_fps(node, new_fps: float, ctx: InspectorContext):
"""Set animation FPS with undo support."""
anim = _get_anim(node)
if anim is None:
return
old_fps = anim.fps
if old_fps == new_fps:
return
def do_fn():
anim.fps = new_fps
def undo_fn():
anim.fps = old_fps
ctx.on_callable_command(do_fn, undo_fn, description=f"Set {node.name} FPS to {new_fps}")
def _set_animation(node, anim_name: str, ctx: InspectorContext):
"""Switch active animation with undo support."""
old_name = node.current_animation
old_frame = node.frame
if old_name == anim_name:
return
def do_fn():
node.current_animation = anim_name
node.frame = 0
def undo_fn():
node.current_animation = old_name
node.frame = old_frame
ctx.on_callable_command(do_fn, undo_fn, description=f"Set {node.name} animation to {anim_name}")
# ============================================================================
# PathCurveSection -- curve point editor for Path2D / Path3D
# ============================================================================
_MAX_VISIBLE_POINTS = 10
[docs]
@register_inspector_section
class PathCurveSection(InspectorSection):
"""Compact curve point editor for Path2D and Path3D nodes."""
section_title = "Curve"
priority = 6
[docs]
def can_handle(self, node):
return isinstance(node, Path2D | Path3D)
[docs]
def handled_properties(self, node):
return set()
[docs]
def build_rows(self, node, ctx):
curve = node.curve
is_3d = isinstance(node, Path3D)
rows: list[Control] = []
# -- Point count label --
count_lbl = Label(f"Curve Points: {curve.point_count} points")
count_lbl.font_size = _font_size()
count_lbl.size = Vec2(200, _row_h())
rows.append(count_lbl)
ctx.register_widget("curve_count", count_lbl)
# -- Add / Clear buttons --
btn_bar = HBoxContainer()
btn_bar.separation = 4
btn_bar.size = Vec2(200, _row_h())
add_btn = Button("Add Point")
add_btn.size = Vec2(80, _row_h())
add_btn.font_size = _font_size()
add_btn.pressed.connect(lambda n=node, c=ctx: _curve_add_point(n, c))
btn_bar.add_child(add_btn)
ctx.register_widget("curve_add", add_btn)
clear_btn = Button("Clear Points")
clear_btn.size = Vec2(90, _row_h())
clear_btn.font_size = _font_size()
clear_btn.pressed.connect(lambda n=node, c=ctx: _curve_clear_points(n, c))
btn_bar.add_child(clear_btn)
ctx.register_widget("curve_clear", clear_btn)
rows.append(btn_bar)
# -- Per-point coordinate rows (up to _MAX_VISIBLE_POINTS) --
visible = min(curve.point_count, _MAX_VISIBLE_POINTS)
for idx in range(visible):
pos = curve.get_point_position(idx)
row = HBoxContainer()
row.separation = 2
row.size = Vec2(280, _row_h())
# Index label
idx_lbl = Label(f"[{idx}]")
idx_lbl.font_size = _font_size()
idx_lbl.size = Vec2(28, _row_h())
row.add_child(idx_lbl)
# X spin
spin_x = SpinBox(min_val=-1e6, max_val=1e6, value=float(pos.x), step=1.0)
spin_x.font_size = _font_size()
spin_x.size = Vec2(60, _row_h())
spin_x.value_changed.connect(_make_point_coord_handler(node, idx, 0, ctx))
row.add_child(spin_x)
ctx.register_widget(f"curve_pt{idx}_x", spin_x)
# Y spin
spin_y = SpinBox(min_val=-1e6, max_val=1e6, value=float(pos.y), step=1.0)
spin_y.font_size = _font_size()
spin_y.size = Vec2(60, _row_h())
spin_y.value_changed.connect(_make_point_coord_handler(node, idx, 1, ctx))
row.add_child(spin_y)
ctx.register_widget(f"curve_pt{idx}_y", spin_y)
if is_3d:
spin_z = SpinBox(min_val=-1e6, max_val=1e6, value=float(pos.z), step=1.0)
spin_z.font_size = _font_size()
spin_z.size = Vec2(60, _row_h())
spin_z.value_changed.connect(_make_point_coord_handler(node, idx, 2, ctx))
row.add_child(spin_z)
ctx.register_widget(f"curve_pt{idx}_z", spin_z)
# Remove button
rm_btn = Button("x")
rm_btn.size = Vec2(22, _row_h())
rm_btn.font_size = 10.0
rm_btn.pressed.connect(_make_point_remove_handler(node, idx, ctx))
row.add_child(rm_btn)
ctx.register_widget(f"curve_pt{idx}_rm", rm_btn)
rows.append(row)
# Overflow label
if curve.point_count > _MAX_VISIBLE_POINTS:
extra = curve.point_count - _MAX_VISIBLE_POINTS
more_lbl = Label(f"... and {extra} more")
more_lbl.font_size = _font_size()
more_lbl.size = Vec2(200, _row_h())
rows.append(more_lbl)
return rows
def _curve_add_point(node, ctx):
"""Add a new control point to the node's curve with undo support."""
curve = node.curve
is_3d = isinstance(node, Path3D)
if curve.point_count > 0:
last = curve.get_point_position(curve.point_count - 1)
new_pos = Vec3(float(last.x) + 50, float(last.y), float(last.z)) if is_3d else Vec2(float(last.x) + 50, float(last.y))
else:
new_pos = Vec3() if is_3d else Vec2()
def do_fn():
curve.add_point(new_pos)
def undo_fn():
curve.remove_point(curve.point_count - 1)
ctx.on_callable_command(do_fn, undo_fn, description=f"Add curve point to {node.name}")
ctx.rebuild()
def _curve_clear_points(node, ctx):
"""Remove all points from the node's curve with undo support."""
curve = node.curve
saved = list(curve._points)
saved_tilts = list(curve._tilts) if hasattr(curve, '_tilts') else None
def do_fn():
curve.clear()
def undo_fn():
curve.clear()
for i, entry in enumerate(saved):
if saved_tilts is not None:
curve.add_point(entry[0], entry[1], entry[2], tilt=saved_tilts[i])
else:
curve.add_point(entry[0], entry[1], entry[2])
ctx.on_callable_command(do_fn, undo_fn, description=f"Clear curve points on {node.name}")
ctx.rebuild()
def _make_point_coord_handler(node, point_idx: int, axis: int, ctx):
"""Return a closure that updates a single coordinate of a curve point with undo."""
def handler(value: float):
curve = node.curve
if point_idx >= curve.point_count:
return
old_pos = curve.get_point_position(point_idx)
old_vals = [float(x) for x in old_pos]
if old_vals[axis] == value:
return
new_vals = list(old_vals)
new_vals[axis] = value
is_3d = isinstance(node, Path3D)
new_pos = Vec3(*new_vals) if is_3d else Vec2(*new_vals)
old_pos_copy = Vec3(*old_vals) if is_3d else Vec2(*old_vals)
def do_fn():
curve.set_point_position(point_idx, new_pos)
def undo_fn():
curve.set_point_position(point_idx, old_pos_copy)
ctx.on_callable_command(do_fn, undo_fn, description=f"Set {node.name} curve point [{point_idx}]")
return handler
def _make_point_remove_handler(node, point_idx: int, ctx):
"""Return a closure that removes a curve point with undo."""
def handler():
curve = node.curve
if point_idx >= curve.point_count:
return
entry = curve._points[point_idx]
tilt = curve._tilts[point_idx] if hasattr(curve, '_tilts') else None
def do_fn():
curve.remove_point(point_idx)
def undo_fn():
if tilt is not None:
curve.add_point(entry[0], entry[1], entry[2], index=point_idx, tilt=tilt)
else:
curve.add_point(entry[0], entry[1], entry[2], index=point_idx)
ctx.on_callable_command(do_fn, undo_fn, description=f"Remove {node.name} curve point [{point_idx}]")
ctx.rebuild()
return handler