"""2D Viewport Panel -- Canvas-based editor for Node2D scene trees.
Provides an interactive 2D canvas with:
- Orthographic zoom and pan controls
- Grid with minor/major lines and optional snapping
- Pixel rulers along the top and left edges
- Node2D visualization (coloured rectangles, selection highlight)
- Translate, rotate, and scale gizmos for the selected node with undo support
- Info overlay showing zoom level and mouse canvas coordinates
"""
import logging
import math
from typing import TYPE_CHECKING
from simvx.core import (
Control,
GizmoMode,
MouseButton,
Node,
Node2D,
PropertyCommand,
Vec2,
)
from ._scene2d_helpers import _Scene2DCanvasRenderer
if TYPE_CHECKING:
from simvx.editor.state import State
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# Zoom limits
_ZOOM_MIN = 0.1
_ZOOM_MAX = 10.0
_ZOOM_STEP = 0.1
# Grid
_MINOR_GRID_SIZE = 50 # pixels between minor grid lines
_MAJOR_GRID_SIZE = 200 # pixels between major grid lines
# Ruler
_RULER_THICKNESS = 30 # pixel width/height of rulers
# Default node visualisation size (used when a node has no explicit extent)
_DEFAULT_NODE_SIZE = 40.0
# Frames the offscreen target size must be stable before recreating GPU
# resources. Prevents Vulkan thrashing during a user-driven window drag.
_RESIZE_DEBOUNCE_FRAMES = 6
# Gizmo dimensions (screen pixels, unscaled)
_GIZMO_ARROW_LEN = 60.0
_GIZMO_ARROW_HEAD = 10.0
_GIZMO_PICK_RADIUS = 8.0
_GIZMO_ROTATE_RADIUS = 60.0 # circle radius for rotate gizmo
_GIZMO_ROTATE_SEGMENTS = 48 # line segments to approximate the circle
_GIZMO_SCALE_BOX_HALF = 5.0 # half-size of endpoint squares on scale gizmo
_GIZMO_CENTRE_BOX_HALF = 6.0 # half-size of centre uniform-scale handle
_GIZMO_SNAP_ANGLE_DEG = 15.0 # angular snap increment in degrees
# Colours (RGBA floats)
_COL_BG = (0.15, 0.15, 0.17, 1.0)
_COL_MINOR_GRID = (0.22, 0.22, 0.24, 1.0)
_COL_MAJOR_GRID = (0.30, 0.30, 0.33, 1.0)
_COL_ORIGIN_X = (0.96, 0.26, 0.28, 1.0)
_COL_ORIGIN_Y = (0.40, 0.84, 0.36, 1.0)
_COL_RULER_BG = (0.12, 0.12, 0.12, 1.0)
_COL_RULER_TEXT = (0.55, 0.55, 0.55, 1.0)
_COL_RULER_TICK = (0.40, 0.40, 0.40, 1.0)
_COL_NODE_FILL = (0.30, 0.55, 0.80, 0.35)
_COL_NODE_BORDER = (0.40, 0.65, 0.90, 0.80)
_COL_SEL_BORDER = (1.00, 0.85, 0.20, 1.00)
_COL_HANDLE = (1.00, 1.00, 1.00, 0.90)
_COL_GIZMO_X = (0.96, 0.26, 0.28, 1.0)
_COL_GIZMO_Y = (0.40, 0.84, 0.36, 1.0)
_COL_GIZMO_HOVER = (1.00, 0.90, 0.20, 1.0)
_COL_GIZMO_Z = (0.30, 0.50, 0.95, 1.0) # blue for Z-rotation ring
_COL_GIZMO_CENTRE = (0.85, 0.85, 0.85, 1.0) # white-ish for uniform scale handle
_COL_INFO_TEXT = (0.70, 0.70, 0.70, 1.0)
_COL_SNAP_BADGE = (0.50, 0.80, 1.00, 1.0)
# Debug overlay toggle button styling (matches 3D viewport pattern)
_OVERLAY_BTN_W = 48
_OVERLAY_BTN_H = 22
_OVERLAY_BTN_GAP = 3
_OVERLAY_PAD = 6
_OVERLAY_BG = (0.06, 0.06, 0.08, 0.90)
_OVERLAY_BTN_NORMAL = (0.24, 0.24, 0.27, 0.90)
_OVERLAY_BTN_ACTIVE = (0.35, 0.55, 0.80, 1.0)
_OVERLAY_BTN_TEXT = (0.80, 0.80, 0.80, 1.0)
_OVERLAY_BTN_TEXT_ACTIVE = (1.0, 1.0, 1.0, 1.0)
_OVERLAY_FONT_SCALE = 0.65
# Distinct gizmo overlay colours per category
_COL_OVERLAY_COLLISION = (0.2, 0.8, 0.2, 0.7) # green -- collision shapes, areas
_COL_OVERLAY_CAMERA = (0.6, 0.4, 0.9, 0.7) # purple -- camera bounds
_COL_OVERLAY_LIGHT = (1.0, 0.9, 0.3, 0.6) # yellow -- lights & occluders
_COL_OVERLAY_PATH = (1.0, 0.8, 0.0, 0.7) # amber -- paths, trails, lines
# ---------------------------------------------------------------------------
# Helper: deterministic colour per node name (for distinct visual identity)
# ---------------------------------------------------------------------------
def _node_colour(name: str) -> tuple[float, float, float, float]:
"""Return a pastel RGBA colour derived from the node name hash."""
h = hash(name) & 0xFFFFFFFF
r = 0.35 + 0.45 * ((h >> 0) & 0xFF) / 255.0
g = 0.35 + 0.45 * ((h >> 8) & 0xFF) / 255.0
b = 0.35 + 0.45 * ((h >> 16) & 0xFF) / 255.0
return (r, g, b, 0.50)
# ---------------------------------------------------------------------------
# Helper: estimate visual size of a Node2D in canvas pixels
# ---------------------------------------------------------------------------
def _node_extent(node: Node2D) -> tuple[float, float]:
"""Return (half_w, half_h) of the node's visual footprint."""
# Controls have a real size: use it
if isinstance(node, Control) and hasattr(node, "size"):
return node.size_x / 2, node.size_y / 2
sx = abs(node.scale.x) if hasattr(node, "scale") else 1.0
sy = abs(node.scale.y) if hasattr(node, "scale") else 1.0
base = _DEFAULT_NODE_SIZE * 0.5
return base * sx, base * sy
# ============================================================================
# Scene2DView
# ============================================================================
[docs]
class Scene2DView(Control):
"""Interactive 2D viewport for editing Node2D-based scenes.
Displays a pannable, zoomable canvas with grid, rulers, node
visualisation, and a move gizmo for the primary selection.
Laptop-friendly: Alt+left-drag or right-drag to pan (no middle mouse needed).
Parameters
----------
editor_state:
The central ``State`` instance that holds the edited scene,
selection, undo stack, etc.
"""
def __init__(self, editor_state: State, **kwargs):
super().__init__(**kwargs)
self.state = editor_state
self.bg_colour = _COL_BG
# -- camera / canvas state ------------------------------------------
self._zoom: float = 1.0
self._offset = Vec2(0.0, 0.0) # canvas centre in canvas coords
# -- interaction state ----------------------------------------------
self._is_panning: bool = False
self._last_mouse = Vec2(0.0, 0.0)
self._is_dragging_node: bool = False
self._drag_start_pos: Vec2 | None = None # node position at drag start
self._drag_axis: str | None = None # "x", "y", "centre", or None
# Rotate drag state
self._drag_start_angle: float = 0.0 # angle from gizmo centre to mouse at drag start
self._drag_start_rotation: float = 0.0 # node rotation at drag start
# Scale drag state
self._drag_start_dist: float = 0.0 # distance from gizmo centre to mouse at drag start
self._drag_start_scale: Vec2 | None = None # node scale at drag start
# -- snapping -------------------------------------------------------
self._snap_enabled: bool = True
self._snap_size: float = 10.0
# -- gizmo hover state ---------------------------------------------
self._gizmo_hover: str | None = None # "x" | "y" | "ring" | "centre" | None
# -- cached mouse canvas position (for overlay) ---------------------
self._mouse_canvas = Vec2(0.0, 0.0)
# -- debug overlay toggles ------------------------------------------
self._show_collision: bool = True
self._show_cameras: bool = True
self._show_lights: bool = True
self._show_paths: bool = True
# -- view mode: "wire" | "shaded" (GPU offscreen) | "textured" (node draw) --
self._view_mode: str = "wire"
# -- edit-mode shaded viewport (GPU offscreen render target) -------
self._edit_viewport = None # GameViewportRenderer | None
self._edit_viewport_size: tuple[int, int] = (0, 0)
# See viewport3d for rationale: debounce GPU target recreation so
# a user-driven window drag does not thrash Vulkan resources.
self._edit_viewport_pending_size: tuple[int, int] = (0, 0)
self._edit_viewport_stable_frames: int = 0
# -- saved editor camera for play mode restoration -----------------
self._saved_editor_zoom: float | None = None
self._saved_editor_offset: Vec2 | None = None
# -- connect to play state changes ---------------------------------
self.state.play_state_changed.connect(self._on_play_state_changed)
# -- composition: canvas renderer owns all in-viewport drawing ------
# Given this panel via DI so it reads shared camera state (_zoom /
# _offset), view-mode flags, selection and gizmo hover, and routes
# coordinate conversion back through the panel. The panel keeps every
# drawing method below as a thin delegator. Tuned constants are read
# off this module via the injected reference.
import sys
self._canvas = _Scene2DCanvasRenderer(self, sys.modules[__name__])
# ======================================================================
# Coordinate conversion
# ======================================================================
def _canvas_to_screen(self, cx: float, cy: float) -> tuple[float, float]:
"""Convert canvas coordinates to screen (pixel) coordinates."""
vx, vy, vw, vh = self.get_global_rect()
sx = vx + vw * 0.5 + (cx - self._offset.x) * self._zoom
sy = vy + vh * 0.5 + (cy - self._offset.y) * self._zoom
return sx, sy
def _screen_to_canvas(self, sx: float, sy: float) -> tuple[float, float]:
"""Convert screen (pixel) coordinates to canvas coordinates."""
vx, vy, vw, vh = self.get_global_rect()
cx = (sx - vx - vw * 0.5) / self._zoom + self._offset.x
cy = (sy - vy - vh * 0.5) / self._zoom + self._offset.y
return cx, cy
# ======================================================================
# Snap helper
# ======================================================================
def _snap(self, value: float) -> float:
"""Snap *value* to the nearest grid increment if snapping is on."""
if not self._snap_enabled:
return value
return round(value / self._snap_size) * self._snap_size
def _snap_vec(self, v: Vec2) -> Vec2:
return Vec2(self._snap(v.x), self._snap(v.y))
# ======================================================================
# Input handling
# ======================================================================
def _on_gui_input(self, event):
"""Handle mouse and scroll events from the UI system.
During play mode, input is forwarded to the game's isolated input
state instead of being consumed by editor pan/zoom/pick.
Only overlay button clicks pass through.
"""
# ---- play mode: forward input to game, skip editor controls --------
play_mode = getattr(self.state, "play_mode", None)
if self.state.is_playing and play_mode is not None:
mx, my = event.position.x, event.position.y
# Overlay buttons always pass through
if event.pressed and event.button == MouseButton.LEFT:
if self._handle_overlay_click(mx, my):
return
if play_mode.should_route_input_to_game():
vx, vy, vw, vh = self.get_global_rect()
rel_x = mx - vx
rel_y = my - vy
if event.button is not None:
# Update game's mouse_pos before the button event so the
# forwarded click lands at the cursor's current location.
play_mode.forward_input_to_game(
"mouse_move", x=rel_x, y=rel_y, viewport_size=(vw, vh),
)
play_mode.forward_input_to_game(
"mouse_button", button=event.button, pressed=event.pressed,
)
elif event.button is None and not event.key and not event.char:
play_mode.forward_input_to_game(
"mouse_move", x=rel_x, y=rel_y, viewport_size=(vw, vh),
)
if event.key == "scroll_up":
play_mode.forward_input_to_game("scroll", dx=0.0, dy=1.0)
elif event.key == "scroll_down":
play_mode.forward_input_to_game("scroll", dx=0.0, dy=-1.0)
elif event.key and event.key not in ("scroll_up", "scroll_down"):
from simvx.core import Key
key_val = Key.__members__.get(event.key.upper())
if key_val is not None:
play_mode.forward_input_to_game(
"key", key=key_val.value, pressed=event.pressed,
key_name=event.key,
)
if event.char:
play_mode.forward_input_to_game("char", char=event.char)
return
# Scroll wheel zoom (arrives as key event: scroll_up / scroll_down)
if event.key in ("scroll_up", "scroll_down"):
self._handle_scroll(event)
return
# Mouse button press / release (button > 0)
if event.button is not None:
if event.pressed:
self._handle_press(event)
else:
self._handle_release(event)
return
# Mouse motion (button == 0, no key/char)
if not event.key and not event.char:
self._handle_motion(event)
# -- scroll (zoom) ------------------------------------------------------
def _handle_scroll(self, event):
mx, my = event.position.x, event.position.y
# Canvas position under mouse before zoom
cx_before, cy_before = self._screen_to_canvas(mx, my)
# Apply zoom factor
factor = 1.0 + _ZOOM_STEP
if event.key == "scroll_up":
self._zoom = min(self._zoom * factor, _ZOOM_MAX)
else:
self._zoom = max(self._zoom / factor, _ZOOM_MIN)
# Adjust offset so the canvas point under mouse stays fixed
cx_after, cy_after = self._screen_to_canvas(mx, my)
self._offset.x -= cx_after - cx_before
self._offset.y -= cy_after - cy_before
# -- press --------------------------------------------------------------
def _handle_press(self, event):
mx, my = event.position.x, event.position.y
self._last_mouse = Vec2(mx, my)
# Middle mouse: begin pan
if event.button == MouseButton.MIDDLE:
self._is_panning = True
return
# Right mouse: pan (laptop-friendly, no middle mouse needed)
if event.button == MouseButton.RIGHT:
self._is_panning = True
return
# Left mouse: Alt+left = pan (laptop), otherwise place/gizmo/pick
if event.button == MouseButton.LEFT:
# Alt+left-click: pan (laptop-friendly)
from simvx.core import Input
if Input._keys.get("alt", False):
self._is_panning = True
return
# Overlay button click intercept
if self._handle_overlay_click(mx, my):
return
# Place mode: create node at canvas position
if self.state.pending_place_type is not None:
cx, cy = self._screen_to_canvas(mx, my)
if self._snap_enabled:
cx = self._snap(cx)
cy = self._snap(cy)
node = self.state.place_node_at(cx, cy)
if node is not None:
root = self._find_editor_root()
if root and root.scene_tree_panel and hasattr(root.scene_tree_panel, "_rebuild_tree"):
root.scene_tree_panel._rebuild_tree()
return
# Check gizmo first
axis = self._pick_gizmo(mx, my)
if axis is not None:
self._begin_gizmo_drag(axis)
return
# Pick node
from simvx.core import Input
self._pick_node(mx, my, additive=Input._keys.get("shift", False))
# -- release ------------------------------------------------------------
def _handle_release(self, event):
if event.button in (MouseButton.MIDDLE, MouseButton.RIGHT):
self._is_panning = False
return
if event.button == MouseButton.LEFT and self._is_panning:
self._is_panning = False
return
if event.button == MouseButton.LEFT and self._is_dragging_node:
self._end_gizmo_drag()
# -- motion -------------------------------------------------------------
def _handle_motion(self, event):
mx, my = event.position.x, event.position.y
dx = mx - self._last_mouse.x
dy = my - self._last_mouse.y
self._last_mouse = Vec2(mx, my)
self._mouse_canvas = Vec2(*self._screen_to_canvas(mx, my))
# Panning
if self._is_panning:
self._offset.x -= dx / self._zoom
self._offset.y -= dy / self._zoom
return
# Dragging selected node
if self._is_dragging_node:
self._update_gizmo_drag(mx, my)
return
# Hover test for gizmo
self._gizmo_hover = self._pick_gizmo(mx, my)
# ======================================================================
# Node picking
# ======================================================================
def _pick_node(self, mx: float, my: float, additive: bool = False):
"""Select the topmost Node2D whose rectangle contains (mx, my)."""
root = self.state.edited_scene.root if self.state.edited_scene else None
if root is None:
if not additive:
self.state.selection.clear()
return
cx, cy = self._screen_to_canvas(mx, my)
hit: Node2D | None = None
# Reverse-order traversal so children drawn later (on top) are picked first.
for node in reversed(list(self._iter_node2d(root))):
gp = node.world_position
if isinstance(node, Control) and hasattr(node, "size"):
nw, nh = float(node.size_x), float(node.size_y)
if gp.x <= cx <= gp.x + nw and gp.y <= cy <= gp.y + nh:
hit = node
break
else:
hw, hh = _node_extent(node)
if gp.x - hw <= cx <= gp.x + hw and gp.y - hh <= cy <= gp.y + hh:
hit = node
break
if hit is not None:
self.state.selection.select(hit, additive=additive)
elif not additive:
self.state.selection.clear()
# ======================================================================
# Gizmo picking and dragging
# ======================================================================
def _gizmo_origin_screen(self) -> tuple[float, float] | None:
"""Screen position of the gizmo origin, or None if no selection."""
sel = self.state.selection.primary
if sel is None or not isinstance(sel, Node2D):
return None
gp = sel.world_position
# For Controls, gizmo at centre of the rect (position is top-left)
if isinstance(sel, Control) and hasattr(sel, "size"):
return self._canvas_to_screen(gp.x + float(sel.size_x) / 2, gp.y + float(sel.size_y) / 2)
return self._canvas_to_screen(gp.x, gp.y)
def _pick_gizmo(self, mx: float, my: float) -> str | None:
"""Return a handle identifier if the mouse is over a gizmo element, else None.
Returns "x", "y" for translate/scale axis handles, "ring" for the rotate
circle, or "centre" for the uniform-scale centre handle.
"""
origin = self._gizmo_origin_screen()
if origin is None:
return None
ox, oy = origin
mode = self.state.gizmo.mode
if mode is GizmoMode.TRANSLATE:
if ox <= mx <= ox + _GIZMO_ARROW_LEN and abs(my - oy) < _GIZMO_PICK_RADIUS:
return "x"
if oy <= my <= oy + _GIZMO_ARROW_LEN and abs(mx - ox) < _GIZMO_PICK_RADIUS:
return "y"
elif mode is GizmoMode.ROTATE:
dist = math.hypot(mx - ox, my - oy)
if abs(dist - _GIZMO_ROTATE_RADIUS) < _GIZMO_PICK_RADIUS:
return "ring"
elif mode is GizmoMode.SCALE:
# Centre handle (uniform scale)
ch = _GIZMO_CENTRE_BOX_HALF
if abs(mx - ox) <= ch and abs(my - oy) <= ch:
return "centre"
# X axis
if ox <= mx <= ox + _GIZMO_ARROW_LEN and abs(my - oy) < _GIZMO_PICK_RADIUS:
return "x"
# Y axis
if oy <= my <= oy + _GIZMO_ARROW_LEN and abs(mx - ox) < _GIZMO_PICK_RADIUS:
return "y"
return None
def _begin_gizmo_drag(self, axis: str):
sel = self.state.selection.primary
if sel is None or not isinstance(sel, Node2D):
return
self._is_dragging_node = True
self._drag_axis = axis
mode = self.state.gizmo.mode
if mode is GizmoMode.TRANSLATE:
self._drag_start_pos = Vec2(sel.position)
elif mode is GizmoMode.ROTATE:
origin = self._gizmo_origin_screen()
if origin is not None:
ox, oy = origin
mx, my = self._last_mouse.x, self._last_mouse.y
self._drag_start_angle = math.atan2(my - oy, mx - ox)
self._drag_start_rotation = sel.rotation
elif mode is GizmoMode.SCALE:
origin = self._gizmo_origin_screen()
if origin is not None:
ox, oy = origin
mx, my = self._last_mouse.x, self._last_mouse.y
self._drag_start_dist = math.hypot(mx - ox, my - oy)
self._drag_start_scale = Vec2(sel.scale)
self._drag_start_pos = Vec2(sel.position) # used for axis constraint
def _update_gizmo_drag(self, mx: float, my: float):
sel = self.state.selection.primary
if sel is None or not isinstance(sel, Node2D):
return
mode = self.state.gizmo.mode
if mode is GizmoMode.TRANSLATE:
self._update_translate_drag(sel, mx, my)
elif mode is GizmoMode.ROTATE:
self._update_rotate_drag(sel, mx, my)
elif mode is GizmoMode.SCALE:
self._update_scale_drag(sel, mx, my)
def _update_translate_drag(self, sel: Node2D, mx: float, my: float):
cx, cy = self._screen_to_canvas(mx, my)
new_pos = Vec2(cx, cy)
if self._drag_axis == "x" and self._drag_start_pos is not None:
new_pos = Vec2(cx, self._drag_start_pos.y)
elif self._drag_axis == "y" and self._drag_start_pos is not None:
new_pos = Vec2(self._drag_start_pos.x, cy)
if self._snap_enabled:
new_pos = self._snap_vec(new_pos)
sel.position = new_pos
def _update_rotate_drag(self, sel: Node2D, mx: float, my: float):
origin = self._gizmo_origin_screen()
if origin is None:
return
ox, oy = origin
current_angle = math.atan2(my - oy, mx - ox)
delta = current_angle - self._drag_start_angle
# Normalise to [-pi, pi]
if delta > math.pi:
delta -= 2 * math.pi
elif delta < -math.pi:
delta += 2 * math.pi
new_rot = self._drag_start_rotation + delta
if self._snap_enabled:
snap_rad = math.radians(_GIZMO_SNAP_ANGLE_DEG)
new_rot = round(new_rot / snap_rad) * snap_rad
sel.rotation = new_rot
def _update_scale_drag(self, sel: Node2D, mx: float, my: float):
origin = self._gizmo_origin_screen()
if origin is None or self._drag_start_scale is None:
return
ox, oy = origin
ref_dist = max(self._drag_start_dist, 1.0)
if self._drag_axis == "centre":
# Uniform scale on both axes
cur_dist = math.hypot(mx - ox, my - oy)
factor = cur_dist / ref_dist
sel.scale = Vec2(self._drag_start_scale.x * factor, self._drag_start_scale.y * factor)
elif self._drag_axis == "x":
dx = mx - ox
factor = dx / _GIZMO_ARROW_LEN
factor = max(factor, 0.01)
sel.scale = Vec2(self._drag_start_scale.x * factor, self._drag_start_scale.y)
elif self._drag_axis == "y":
dy = my - oy
factor = dy / _GIZMO_ARROW_LEN
factor = max(factor, 0.01)
sel.scale = Vec2(self._drag_start_scale.x, self._drag_start_scale.y * factor)
def _end_gizmo_drag(self):
"""Commit the drag as an undoable command."""
sel = self.state.selection.primary
if sel is None or not isinstance(sel, Node2D):
self._reset_drag_state()
return
mode = self.state.gizmo.mode
if mode is GizmoMode.TRANSLATE and self._drag_start_pos is not None:
old_pos = self._drag_start_pos
new_pos = Vec2(sel.position)
if old_pos != new_pos:
sel.position = old_pos
cmd = PropertyCommand(sel, "position", old_pos, new_pos, description=f"Move {sel.name}")
self.state.undo_stack.push(cmd)
self.state.modified = True
elif mode is GizmoMode.ROTATE:
old_rot = self._drag_start_rotation
new_rot = sel.rotation
if old_rot != new_rot:
sel.rotation = old_rot
cmd = PropertyCommand(sel, "rotation", old_rot, new_rot, description=f"Rotate {sel.name}")
self.state.undo_stack.push(cmd)
self.state.modified = True
elif mode is GizmoMode.SCALE and self._drag_start_scale is not None:
old_scale = self._drag_start_scale
new_scale = Vec2(sel.scale)
if old_scale != new_scale:
sel.scale = old_scale
cmd = PropertyCommand(sel, "scale", old_scale, new_scale, description=f"Scale {sel.name}")
self.state.undo_stack.push(cmd)
self.state.modified = True
self._reset_drag_state()
def _reset_drag_state(self):
"""Clear all drag-related state."""
self._is_dragging_node = False
self._drag_start_pos = None
self._drag_axis = None
self._drag_start_angle = 0.0
self._drag_start_rotation = 0.0
self._drag_start_dist = 0.0
self._drag_start_scale = None
# ======================================================================
# Scene traversal helpers
# ======================================================================
@staticmethod
def _iter_node2d(root: Node):
"""Yield all Node2D descendants of *root* in depth-first order."""
stack = list(root.children)
while stack:
node = stack.pop()
if isinstance(node, Node2D):
yield node
stack.extend(reversed(list(node.children)))
# ======================================================================
# process(): per-frame logic (FPS-independent)
# ======================================================================
[docs]
def on_process(self, dt: float):
pass # No continuous state to update; all interaction is event-driven.
# ======================================================================
# Play mode camera save / restore
# ======================================================================
def _on_play_state_changed(self):
"""Save editor camera on play start, restore on stop."""
if self.state.is_playing:
# Save editor camera state on play start
if self._saved_editor_zoom is None:
self._saved_editor_zoom = self._zoom
self._saved_editor_offset = Vec2(self._offset)
else:
# Restore editor camera on stop
if self._saved_editor_zoom is not None:
self._zoom = self._saved_editor_zoom
self._offset = Vec2(self._saved_editor_offset) if self._saved_editor_offset is not None else Vec2(0, 0)
self._saved_editor_zoom = None
self._saved_editor_offset = None
# ======================================================================
# Edit-mode shaded viewport (GPU offscreen render target)
# ======================================================================
def _get_edit_texture(self, width: int, height: int) -> int | None:
"""Return a bindless texture ID for the edit-mode shaded view.
Lazily creates a GameViewportRenderer for the edited scene.
The actual rendering is done via the pre_render hook in GameRenderHook.
Returns None if the GPU renderer is not available.
"""
evp = self._edit_viewport
if evp is not None and evp.ready:
target = (width, height)
if target != self._edit_viewport_pending_size:
self._edit_viewport_pending_size = target
self._edit_viewport_stable_frames = 0
else:
self._edit_viewport_stable_frames += 1
if (target != self._edit_viewport_size
and self._edit_viewport_stable_frames >= _RESIZE_DEBOUNCE_FRAMES):
evp.resize(width, height)
self._edit_viewport_size = target
return evp.texture_id
if self._tree and hasattr(self._tree, "app") and self._tree.app is not None:
engine = getattr(self._tree.app, "_engine", None)
if engine is not None:
try:
from simvx.graphics.renderer.game_viewport import GameViewportRenderer
self._edit_viewport = GameViewportRenderer(engine)
self._edit_viewport.create(max(width, 64), max(height, 64))
self._edit_viewport_size = (width, height)
self._edit_viewport_pending_size = (width, height)
return self._edit_viewport.texture_id
except (ImportError, RuntimeError, AttributeError):
log.exception("GameViewportRenderer init failed; viewport falls back to wireframe")
return None
# ======================================================================
# draw(): render the viewport
# ======================================================================
[docs]
def on_draw(self, renderer):
vx, vy, vw, vh = self.get_global_rect()
# Clip to viewport bounds
renderer.push_clip(vx, vy, vw, vh)
# 1. Background
renderer.draw_rect((vx, vy), (vw, vh), colour=_COL_BG, filled=True)
is_playing = self.state.is_playing
play_mode = getattr(self.state, "play_mode", None)
# During play mode: try to render game texture, otherwise fall back
if is_playing and play_mode is not None:
# Keep the offscreen game target sized to the viewport rect so a
# window/splitter resize during play updates the rendered image.
play_mode.ensure_game_viewport_size(int(vw), int(vh))
game_tex = getattr(play_mode, "game_texture_id", None)
if game_tex is not None and game_tex >= 0:
renderer.draw_texture(game_tex, vx, vy, vw, vh)
else:
# Fall back to existing node rendering (textured/wire)
canvas_x = vx + _RULER_THICKNESS
canvas_y = vy + _RULER_THICKNESS
canvas_w = vw - _RULER_THICKNESS
canvas_h = vh - _RULER_THICKNESS
renderer.push_clip(canvas_x, canvas_y, canvas_w, canvas_h)
self._draw_grid(renderer, canvas_x, canvas_y, canvas_w, canvas_h)
self._draw_origin(renderer, canvas_x, canvas_y, canvas_w, canvas_h)
self._draw_nodes(renderer)
# No gizmo during play
renderer.pop_clip()
# Play mode border overlay
if play_mode is not None:
border_colour = play_mode.get_border_colour()
if border_colour is not None:
t = 3.0
renderer.draw_thick_line(vx, vy, vx + vw, vy, t, colour=border_colour)
renderer.draw_thick_line(vx + vw, vy, vx + vw, vy + vh, t, colour=border_colour)
renderer.draw_thick_line(vx + vw, vy + vh, vx, vy + vh, t, colour=border_colour)
renderer.draw_thick_line(vx, vy + vh, vx, vy, t, colour=border_colour)
else:
# Edit mode: check for GPU shaded view via offscreen renderer
edit_tex = None
if self._view_mode == "shaded":
edit_tex = self._get_edit_texture(int(vw), int(vh))
if edit_tex is not None and edit_tex >= 0:
# GPU-rendered shaded view
renderer.draw_texture(edit_tex, vx, vy, vw, vh)
# Still draw gizmo overlay on top
canvas_x = vx + _RULER_THICKNESS
canvas_y = vy + _RULER_THICKNESS
canvas_w = vw - _RULER_THICKNESS
canvas_h = vh - _RULER_THICKNESS
renderer.push_clip(canvas_x, canvas_y, canvas_w, canvas_h)
self._draw_gizmo(renderer)
renderer.pop_clip()
else:
# 2. Grid (clipped to canvas area inside rulers)
canvas_x = vx + _RULER_THICKNESS
canvas_y = vy + _RULER_THICKNESS
canvas_w = vw - _RULER_THICKNESS
canvas_h = vh - _RULER_THICKNESS
renderer.push_clip(canvas_x, canvas_y, canvas_w, canvas_h)
self._draw_grid(renderer, canvas_x, canvas_y, canvas_w, canvas_h)
# 3. Origin crosshair
self._draw_origin(renderer, canvas_x, canvas_y, canvas_w, canvas_h)
# 4. Nodes
self._draw_nodes(renderer)
# 5. Gizmo
self._draw_gizmo(renderer)
renderer.pop_clip()
# 6. Rulers (always drawn, even over shaded view)
self._draw_rulers(renderer, vx, vy, vw, vh)
# 7. Debug overlay toggle buttons
self._draw_overlay_buttons(renderer, vx, vy, vw, vh)
# 8. Info overlay
self._draw_info_overlay(renderer, vx, vy, vw, vh)
renderer.pop_clip()
# ------------------------------------------------------------------
# Grid
# ------------------------------------------------------------------
def _draw_grid(self, renderer, cx: float, cy: float, cw: float, ch: float):
"""Draw minor and major grid lines inside the canvas area."""
self._canvas.draw_grid(renderer, cx, cy, cw, ch)
# ------------------------------------------------------------------
# Origin crosshair
# ------------------------------------------------------------------
def _draw_origin(self, renderer, cx: float, cy: float, cw: float, ch: float):
"""Draw a red horizontal and green vertical line through the origin."""
self._canvas.draw_origin(renderer, cx, cy, cw, ch)
# ------------------------------------------------------------------
# Node visualisation
# ------------------------------------------------------------------
def _draw_nodes(self, renderer):
"""Draw nodes in the scene (play state + view mode dependent)."""
self._canvas.draw_nodes(renderer)
def _draw_nodes_wire(self, renderer, root: Node):
"""Wire mode: coloured rectangles with labels and gizmo lines."""
self._canvas.draw_nodes_wire(renderer, root)
def _draw_nodes_textured(self, renderer, root: Node, is_playing: bool):
"""Textured/play mode: draw nodes with their visual properties."""
self._canvas.draw_nodes_textured(renderer, root, is_playing)
def _draw_line2d_textured(self, renderer, node) -> bool:
"""Draw a Line2D using its actual points, width, and colour."""
return self._canvas.draw_line2d_textured(renderer, node)
def _draw_polygon2d_textured(self, renderer, node) -> bool:
"""Draw a Polygon2D as a filled shape using its actual vertices and colour."""
return self._canvas.draw_polygon2d_textured(renderer, node)
def _draw_sprite2d_textured(self, renderer, node) -> bool:
"""Draw a Sprite2D as a coloured rect with its actual bounds and colour."""
return self._canvas.draw_sprite2d_textured(renderer, node)
def _draw_single_node_wire(self, renderer, node: Node2D):
"""Draw a single node as a coloured rectangle (fallback for textured mode)."""
self._canvas.draw_single_node_wire(renderer, node)
@staticmethod
def _find_camera2d(root: Node):
"""Find the first Camera2D in the tree."""
return _Scene2DCanvasRenderer.find_camera2d(root)
# ------------------------------------------------------------------
# Move gizmo
# ------------------------------------------------------------------
def _draw_gizmo(self, renderer):
"""Draw the active gizmo for the primary selection."""
self._canvas.draw_gizmo(renderer)
def _draw_gizmo_translate(self, renderer, origin: tuple[float, float]):
"""Draw X/Y move arrows with triangle arrowheads."""
self._canvas.draw_gizmo_translate(renderer, origin)
def _draw_gizmo_rotate(self, renderer, origin: tuple[float, float]):
"""Draw a circle outline for Z-axis rotation with angle indicator."""
self._canvas.draw_gizmo_rotate(renderer, origin)
def _draw_gizmo_scale(self, renderer, origin: tuple[float, float]):
"""Draw X/Y axis lines with filled square endpoints and a centre square."""
self._canvas.draw_gizmo_scale(renderer, origin)
# ------------------------------------------------------------------
# Rulers
# ------------------------------------------------------------------
def _draw_rulers(self, renderer, vx: float, vy: float, vw: float, vh: float):
"""Draw pixel rulers along the top and left edges."""
self._canvas.draw_rulers(renderer, vx, vy, vw, vh)
def _ruler_interval(self) -> float:
"""Choose a 'nice' ruler tick interval based on the current zoom."""
return self._canvas.ruler_interval()
@staticmethod
def _ruler_label(val: float) -> str:
"""Format a ruler value as a compact string."""
return _Scene2DCanvasRenderer.ruler_label(val)
# ------------------------------------------------------------------
# Debug overlay toggles
# ------------------------------------------------------------------
def _is_overlay_visible(self, node: Node2D) -> bool:
"""Check whether the overlay toggle for this node's category is enabled."""
from simvx.core.nodes_2d.camera import Camera2D
from simvx.core.nodes_2d.path import Path2D
from simvx.core.nodes_2d.shapes import Line2D
from simvx.core.nodes_2d.trail import Trail2D
if isinstance(node, Camera2D):
return self._show_cameras
# Collision shapes, areas, raycasts, shapecasts
from simvx.core.physics_nodes import Area2D, CharacterBody2D, CollisionShape2D
try:
from simvx.core.physics import RayCast2D
except ImportError:
RayCast2D = None
try:
from simvx.core.shapecast import ShapeCast2D
except ImportError:
ShapeCast2D = None
collision_types = (CollisionShape2D, Area2D, CharacterBody2D)
if RayCast2D is not None:
collision_types = (*collision_types, RayCast2D)
if ShapeCast2D is not None:
collision_types = (*collision_types, ShapeCast2D)
if isinstance(node, collision_types):
return self._show_collision
# Lights and occluders
try:
from simvx.core.light2d import Light2D, LightOccluder2D
if isinstance(node, (Light2D, LightOccluder2D)):
return self._show_lights
except ImportError:
pass
# Paths, lines, trails
if isinstance(node, (Path2D, Line2D, Trail2D)):
return self._show_paths
# Default: show (markers and other nodes with gizmo lines)
return True
_OVERLAY_BUTTONS = [
("Wire", "_wire"),
("Shaded", "_shaded"),
("Tex", "_textured"),
None, # separator
("Coll", "_collision"),
("Cam", "_camera"),
("Light", "_light"),
("Path", "_path"),
]
_SEPARATOR_W = 6 # gap width for separators
def _overlay_button_rects(self) -> list[tuple[float, float, float, float, str]]:
"""Return [(x, y, w, h, toggle_key), ...] for the debug overlay buttons."""
vx, vy, _, _ = self.get_global_rect()
ox = vx + _OVERLAY_PAD
oy = vy + _OVERLAY_PAD
buttons = []
x_cursor = ox
for entry in self._OVERLAY_BUTTONS:
if entry is None:
x_cursor += self._SEPARATOR_W
continue
_label, key = entry
buttons.append((x_cursor, oy, _OVERLAY_BTN_W, _OVERLAY_BTN_H, key))
x_cursor += _OVERLAY_BTN_W + _OVERLAY_BTN_GAP
return buttons
def _handle_overlay_click(self, mx: float, my: float) -> bool:
"""Check if click hits an overlay button; toggle the state. Returns True if consumed."""
for bx, by, bw, bh, key in self._overlay_button_rects():
if bx <= mx <= bx + bw and by <= my <= by + bh:
if key == "_wire":
self._view_mode = "wire"
elif key == "_shaded":
self._view_mode = "shaded"
elif key == "_textured":
self._view_mode = "textured"
elif key == "_collision":
self._show_collision = not self._show_collision
elif key == "_camera":
self._show_cameras = not self._show_cameras
elif key == "_light":
self._show_lights = not self._show_lights
elif key == "_path":
self._show_paths = not self._show_paths
return True
return False
def _draw_overlay_buttons(self, renderer, vx: float, vy: float, vw: float, vh: float):
"""Draw debug overlay toggle buttons in the top-left corner."""
self._canvas.draw_overlay_buttons(renderer, vx, vy, vw, vh)
# ------------------------------------------------------------------
# Info overlay
# ------------------------------------------------------------------
def _draw_info_overlay(self, renderer, vx: float, vy: float, vw: float, vh: float):
"""Draw zoom level and mouse canvas coordinates in the bottom-right."""
self._canvas.draw_info_overlay(renderer, vx, vy, vw, vh)
def _find_editor_root(self):
"""Walk up the tree to find the Root ancestor."""
from simvx.editor.root import Root
node = self.parent
while node is not None:
if isinstance(node, Root):
return node
node = node.parent
return None
# ======================================================================
# Public API
# ======================================================================
[docs]
def reset_view(self):
"""Reset zoom to 1x and centre on the origin."""
self._zoom = 1.0
self._offset = Vec2(0.0, 0.0)
[docs]
def focus_selection(self):
"""Pan the canvas so the primary selection is centred."""
sel = self.state.selection.primary
if sel is not None and isinstance(sel, Node2D):
gp = sel.world_position
self._offset = Vec2(gp.x, gp.y)
[docs]
def toggle_snap(self):
"""Toggle grid snapping on or off."""
self._snap_enabled = not self._snap_enabled
@property
def zoom(self) -> float:
return self._zoom
[docs]
@zoom.setter
def zoom(self, value: float):
self._zoom = max(_ZOOM_MIN, min(_ZOOM_MAX, value))
@property
def offset(self) -> Vec2:
return Vec2(self._offset)
[docs]
@offset.setter
def offset(self, value):
if isinstance(value, Vec2):
self._offset = Vec2(value)
else:
self._offset = Vec2(value[0], value[1])
[docs]
@property
def snap_enabled(self) -> bool:
return self._snap_enabled
@property
def snap_size(self) -> float:
return self._snap_size
[docs]
@snap_size.setter
def snap_size(self, value: float):
self._snap_size = max(1.0, value)