Source code for simvx.editor.panels.inspector_sections

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