"""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,
Node,
Node2D,
PropertyCommand,
Vec2,
)
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)
# ======================================================================
# 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 == 1:
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 > 0:
play_mode.forward_input_to_game(
"mouse_button", button=event.button, pressed=event.pressed,
)
elif event.button == 0 and not event.key and not event.char:
play_mode.forward_input_to_game("mouse_move", x=rel_x, y=rel_y)
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 > 0:
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 == 2:
self._is_panning = True
return
# Right mouse: pan (laptop-friendly, no middle mouse needed)
if event.button == 3:
self._is_panning = True
return
# Left mouse: Alt+left = pan (laptop), otherwise place/gizmo/pick
if event.button == 1:
# 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 (2, 3):
self._is_panning = False
return
if event.button == 1 and self._is_panning:
self._is_panning = False
return
if event.button == 1 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 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 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:
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."""
# Visible canvas bounds in canvas coords
left, top = self._screen_to_canvas(cx, cy)
right, bottom = self._screen_to_canvas(cx + cw, cy + ch)
# -- minor lines ----------------------------------------------------
step = _MINOR_GRID_SIZE
start_x = math.floor(left / step) * step
start_y = math.floor(top / step) * step
gx = start_x
while gx <= right:
sx, _ = self._canvas_to_screen(gx, 0)
is_major = (abs(gx) % _MAJOR_GRID_SIZE) < 0.5
col = _COL_MAJOR_GRID if is_major else _COL_MINOR_GRID
renderer.draw_line((sx, cy), (sx, cy + ch), colour=col)
gx += step
gy = start_y
while gy <= bottom:
_, sy = self._canvas_to_screen(0, gy)
is_major = (abs(gy) % _MAJOR_GRID_SIZE) < 0.5
col = _COL_MAJOR_GRID if is_major else _COL_MINOR_GRID
renderer.draw_line((cx, sy), (cx + cw, sy), colour=col)
gy += step
# ------------------------------------------------------------------
# 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."""
ox, oy = self._canvas_to_screen(0, 0)
# X axis (horizontal, red)
if cy <= oy <= cy + ch:
renderer.draw_line((cx, oy), (cx + cw, oy), colour=_COL_ORIGIN_X)
# Y axis (vertical, green)
if cx <= ox <= cx + cw:
renderer.draw_line((ox, cy), (ox, cy + ch), colour=_COL_ORIGIN_Y)
# ------------------------------------------------------------------
# Node visualisation
# ------------------------------------------------------------------
def _draw_nodes(self, renderer):
"""Draw nodes in the scene. Behaviour depends on play state and view mode.
- Playing: render game tree nodes via their draw() methods.
- Wire mode (default): coloured rectangles with name labels.
- Textured mode: call node draw() methods for real visuals.
"""
# Determine which root to render
is_playing = self.state.is_playing
play_mode = getattr(self.state, 'play_mode', None)
if is_playing and play_mode is not None and play_mode.game_tree is not None:
root = play_mode.game_tree.root
else:
root = self.state.edited_scene.root if self.state.edited_scene else None
if root is None:
return
use_textured = is_playing or self._view_mode == "textured"
if use_textured:
self._draw_nodes_textured(renderer, root, is_playing)
else:
self._draw_nodes_wire(renderer, root)
# Draw play mode border overlay
if is_playing and play_mode is not None:
border_colour = play_mode.get_border_colour()
if border_colour is not None:
vx, vy, vw, vh = self.get_global_rect()
t = 3.0 # border thickness
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)
def _draw_nodes_wire(self, renderer, root: Node):
"""Wire mode: coloured rectangles with labels and gizmo lines."""
sel = self.state.selection
for node in self._iter_node2d(root):
gp = node.world_position
# Controls use position as top-left; plain Node2D uses position as centre
if isinstance(node, Control) and hasattr(node, "size"):
nw, nh = float(node.size_x), float(node.size_y)
sx, sy = self._canvas_to_screen(gp.x, gp.y)
cx_s, cy_s = self._canvas_to_screen(gp.x + nw / 2, gp.y + nh / 2)
else:
hw, hh = _node_extent(node)
nw, nh = hw * 2, hh * 2
sx, sy = self._canvas_to_screen(gp.x - hw, gp.y - hh)
cx_s, cy_s = self._canvas_to_screen(gp.x, gp.y)
sw = nw * self._zoom
sh = nh * self._zoom
# Fill
fill = _node_colour(node.name)
renderer.draw_rect((sx, sy), (sw, sh), colour=fill, filled=True)
# Border
is_sel = sel.is_selected(node)
if is_sel:
renderer.draw_thick_line(sx, sy, sx + sw, sy, 3.0, colour=_COL_SEL_BORDER)
renderer.draw_thick_line(sx + sw, sy, sx + sw, sy + sh, 3.0, colour=_COL_SEL_BORDER)
renderer.draw_thick_line(sx + sw, sy + sh, sx, sy + sh, 3.0, colour=_COL_SEL_BORDER)
renderer.draw_thick_line(sx, sy + sh, sx, sy, 3.0, colour=_COL_SEL_BORDER)
else:
renderer.draw_rect((sx, sy), (sw, sh), colour=_COL_NODE_BORDER)
# Centre handle
renderer.draw_circle((cx_s, cy_s), 4, colour=_COL_HANDLE, filled=True)
# Node name label
renderer.draw_text(node.name, (sx + 2, sy - 12), colour=_COL_NODE_BORDER, scale=0.7)
# Gizmo lines (collision shapes, paths, raycasts, cameras, lights, etc.)
gizmo_fn = getattr(node, 'get_gizmo_lines', None)
if gizmo_fn is not None and self._is_overlay_visible(node):
gizmo_lines = gizmo_fn()
if gizmo_lines:
colour = tuple(getattr(node, 'gizmo_colour', (0.7, 0.7, 0.7, 0.6)))
if not is_sel and len(colour) >= 4:
colour = (colour[0], colour[1], colour[2], colour[3] * 0.6)
for p0, p1 in gizmo_lines:
s0x, s0y = self._canvas_to_screen(p0.x, p0.y)
s1x, s1y = self._canvas_to_screen(p1.x, p1.y)
renderer.draw_line((s0x, s0y), (s1x, s1y), colour=colour)
def _draw_nodes_textured(self, renderer, root: Node, is_playing: bool):
"""Textured/play mode: draw nodes with their visual properties (colours, shapes, lines).
Uses the editor's 2D canvas coordinate system. Known node types (Line2D,
Polygon2D, Sprite2D) are drawn using their actual colours/shapes/bounds.
Unknown nodes fall back to coloured rectangles.
"""
from simvx.core.nodes_2d.shapes import Line2D, Polygon2D
sel = self.state.selection
# Apply game camera if playing and a Camera2D is present
game_cam = self._find_camera2d(root) if is_playing else None
saved_zoom, saved_offset = self._zoom, Vec2(self._offset)
if game_cam is not None:
cam_pos = game_cam.world_position
self._offset = Vec2(cam_pos.x, cam_pos.y)
cam_zoom = getattr(game_cam, 'zoom', None)
if cam_zoom is not None:
self._zoom = float(cam_zoom.x) if hasattr(cam_zoom, 'x') else float(cam_zoom)
try:
from simvx.core.animation.sprite import Sprite2D
except ImportError:
Sprite2D = None
for node in self._iter_node2d(root):
drawn = False
if isinstance(node, Line2D):
drawn = self._draw_line2d_textured(renderer, node)
elif isinstance(node, Polygon2D):
drawn = self._draw_polygon2d_textured(renderer, node)
elif Sprite2D is not None and isinstance(node, Sprite2D):
drawn = self._draw_sprite2d_textured(renderer, node)
if not drawn:
self._draw_single_node_wire(renderer, node)
# Selection highlight
is_sel = sel.is_selected(node)
if is_sel:
gp = node.world_position
if isinstance(node, Control) and hasattr(node, "size"):
nw, nh = float(node.size_x), float(node.size_y)
sx, sy = self._canvas_to_screen(gp.x, gp.y)
else:
hw, hh = _node_extent(node)
nw, nh = hw * 2, hh * 2
sx, sy = self._canvas_to_screen(gp.x - hw, gp.y - hh)
sw, sh = nw * self._zoom, nh * self._zoom
renderer.draw_thick_line(sx, sy, sx + sw, sy, 3.0, colour=_COL_SEL_BORDER)
renderer.draw_thick_line(sx + sw, sy, sx + sw, sy + sh, 3.0, colour=_COL_SEL_BORDER)
renderer.draw_thick_line(sx + sw, sy + sh, sx, sy + sh, 3.0, colour=_COL_SEL_BORDER)
renderer.draw_thick_line(sx, sy + sh, sx, sy, 3.0, colour=_COL_SEL_BORDER)
# Restore camera if it was overridden
if game_cam is not None:
self._zoom = saved_zoom
self._offset = saved_offset
def _draw_line2d_textured(self, renderer, node) -> bool:
"""Draw a Line2D using its actual points, width, and colour."""
pts = node.points
if len(pts) < 2:
return False
colour = tuple(node.colour) if hasattr(node, 'colour') else (1, 1, 1, 1)
width = max(1.0, float(node.width) * self._zoom) if hasattr(node, 'width') else 2.0
transformed = node.transform_points([Vec2(p[0], p[1]) for p in pts])
for i in range(len(transformed) - 1):
s0x, s0y = self._canvas_to_screen(transformed[i].x, transformed[i].y)
s1x, s1y = self._canvas_to_screen(transformed[i + 1].x, transformed[i + 1].y)
renderer.draw_thick_line(s0x, s0y, s1x, s1y, width, colour=colour)
return True
def _draw_polygon2d_textured(self, renderer, node) -> bool:
"""Draw a Polygon2D as a filled shape using its actual vertices and colour."""
verts = node.polygon
if len(verts) < 3:
return False
colour = tuple(node.colour) if hasattr(node, 'colour') else (1, 1, 1, 1)
transformed = node.transform_points([Vec2(v[0], v[1]) for v in verts])
# Draw as filled triangles (fan from first vertex)
for i in range(1, len(transformed) - 1):
s0x, s0y = self._canvas_to_screen(transformed[0].x, transformed[0].y)
s1x, s1y = self._canvas_to_screen(transformed[i].x, transformed[i].y)
s2x, s2y = self._canvas_to_screen(transformed[i + 1].x, transformed[i + 1].y)
renderer.fill_triangle(s0x, s0y, s1x, s1y, s2x, s2y, colour=colour)
# Draw outline
border = (colour[0], colour[1], colour[2], min(1.0, colour[3] + 0.3))
for i in range(len(transformed)):
j = (i + 1) % len(transformed)
s0x, s0y = self._canvas_to_screen(transformed[i].x, transformed[i].y)
s1x, s1y = self._canvas_to_screen(transformed[j].x, transformed[j].y)
renderer.draw_line((s0x, s0y), (s1x, s1y), colour=border)
return True
def _draw_sprite2d_textured(self, renderer, node) -> bool:
"""Draw a Sprite2D as a coloured rect with its actual bounds and colour."""
gp = node.world_position
w = float(node.width) if node.width > 0 else 64.0
h = float(node.height) if node.height > 0 else 64.0
s = node.world_scale
hw, hh = w * abs(s.x) * 0.5, h * abs(s.y) * 0.5
sx, sy = self._canvas_to_screen(gp.x - hw, gp.y - hh)
sw, sh = hw * 2 * self._zoom, hh * 2 * self._zoom
colour = tuple(node.colour) if hasattr(node, 'colour') else (1, 1, 1, 0.5)
# Filled rect with sprite colour
fill = (colour[0], colour[1], colour[2], colour[3] * 0.5)
renderer.draw_rect((sx, sy), (sw, sh), colour=fill, filled=True)
renderer.draw_rect((sx, sy), (sw, sh), colour=colour)
# Label with texture path
tex = getattr(node, 'texture', None)
label = tex.rsplit('/', 1)[-1] if isinstance(tex, str) else node.name
renderer.draw_text(label, (sx + 2, sy - 12), colour=colour, scale=0.65)
return True
def _draw_single_node_wire(self, renderer, node: Node2D):
"""Draw a single node as a coloured rectangle (fallback for textured mode)."""
gp = node.world_position
if isinstance(node, Control) and hasattr(node, "size"):
nw, nh = float(node.size_x), float(node.size_y)
sx, sy = self._canvas_to_screen(gp.x, gp.y)
else:
hw, hh = _node_extent(node)
nw, nh = hw * 2, hh * 2
sx, sy = self._canvas_to_screen(gp.x - hw, gp.y - hh)
sw, sh = nw * self._zoom, nh * self._zoom
fill = _node_colour(node.name)
renderer.draw_rect((sx, sy), (sw, sh), colour=fill, filled=True)
renderer.draw_rect((sx, sy), (sw, sh), colour=_COL_NODE_BORDER)
renderer.draw_text(node.name, (sx + 2, sy - 12), colour=_COL_NODE_BORDER, scale=0.7)
@staticmethod
def _find_camera2d(root: Node):
"""Find the first Camera2D in the tree."""
try:
from simvx.core.nodes_2d.camera import Camera2D
for node in root.find_all(Camera2D, recursive=True):
return node
except ImportError:
pass
return None
# ------------------------------------------------------------------
# Move gizmo
# ------------------------------------------------------------------
def _draw_gizmo(self, renderer):
"""Draw the active gizmo for the primary selection."""
origin = self._gizmo_origin_screen()
if origin is None:
return
mode = self.state.gizmo.mode
if mode is GizmoMode.TRANSLATE:
self._draw_gizmo_translate(renderer, origin)
elif mode is GizmoMode.ROTATE:
self._draw_gizmo_rotate(renderer, origin)
elif mode is GizmoMode.SCALE:
self._draw_gizmo_scale(renderer, origin)
def _draw_gizmo_translate(self, renderer, origin: tuple[float, float]):
"""Draw X/Y move arrows with triangle arrowheads."""
ox, oy = origin
head = _GIZMO_ARROW_HEAD
col_x = _COL_GIZMO_HOVER if self._gizmo_hover == "x" else _COL_GIZMO_X
end_x = ox + _GIZMO_ARROW_LEN
renderer.draw_thick_line(ox, oy, end_x, oy, 3.0, colour=col_x)
renderer.fill_triangle(end_x, oy, end_x - head, oy - head * 0.5, end_x - head, oy + head * 0.5, colour=col_x)
renderer.draw_text("X", (end_x + 4, oy - 6), colour=col_x, scale=0.65)
col_y = _COL_GIZMO_HOVER if self._gizmo_hover == "y" else _COL_GIZMO_Y
end_y = oy + _GIZMO_ARROW_LEN
renderer.draw_thick_line(ox, oy, ox, end_y, 3.0, colour=col_y)
renderer.fill_triangle(ox, end_y, ox - head * 0.5, end_y - head, ox + head * 0.5, end_y - head, colour=col_y)
renderer.draw_text("Y", (ox + 6, end_y + 2), colour=col_y, scale=0.65)
renderer.draw_circle((ox, oy), 4, colour=_COL_HANDLE, filled=True)
def _draw_gizmo_rotate(self, renderer, origin: tuple[float, float]):
"""Draw a circle outline for Z-axis rotation with angle indicator."""
ox, oy = origin
col = _COL_GIZMO_HOVER if self._gizmo_hover == "ring" else _COL_GIZMO_Z
r = _GIZMO_ROTATE_RADIUS
segs = _GIZMO_ROTATE_SEGMENTS
# Draw circle as segmented thick lines
for i in range(segs):
a0 = 2 * math.pi * i / segs
a1 = 2 * math.pi * (i + 1) / segs
x0 = ox + r * math.cos(a0)
y0 = oy + r * math.sin(a0)
x1 = ox + r * math.cos(a1)
y1 = oy + r * math.sin(a1)
renderer.draw_thick_line(x0, y0, x1, y1, 2.0, colour=col)
# Current rotation indicator line
sel = self.state.selection.primary
if sel is not None and isinstance(sel, Node2D):
angle = sel.rotation
ix = ox + r * math.cos(angle)
iy = oy + r * math.sin(angle)
renderer.draw_thick_line(ox, oy, ix, iy, 2.0, colour=col)
renderer.draw_circle((ox, oy), 4, colour=_COL_HANDLE, filled=True)
renderer.draw_text("R", (ox + r + 6, oy - 6), colour=col, scale=0.65)
def _draw_gizmo_scale(self, renderer, origin: tuple[float, float]):
"""Draw X/Y axis lines with filled square endpoints and a centre square."""
ox, oy = origin
bh = _GIZMO_SCALE_BOX_HALF
# X axis
col_x = _COL_GIZMO_HOVER if self._gizmo_hover == "x" else _COL_GIZMO_X
end_x = ox + _GIZMO_ARROW_LEN
renderer.draw_thick_line(ox, oy, end_x, oy, 3.0, colour=col_x)
renderer.draw_rect((end_x - bh, oy - bh), (bh * 2, bh * 2), colour=col_x, filled=True)
renderer.draw_text("X", (end_x + bh + 2, oy - 6), colour=col_x, scale=0.65)
# Y axis
col_y = _COL_GIZMO_HOVER if self._gizmo_hover == "y" else _COL_GIZMO_Y
end_y = oy + _GIZMO_ARROW_LEN
renderer.draw_thick_line(ox, oy, ox, end_y, 3.0, colour=col_y)
renderer.draw_rect((ox - bh, end_y - bh), (bh * 2, bh * 2), colour=col_y, filled=True)
renderer.draw_text("Y", (ox + bh + 4, end_y + 2), colour=col_y, scale=0.65)
# Centre uniform-scale handle
ch = _GIZMO_CENTRE_BOX_HALF
col_c = _COL_GIZMO_HOVER if self._gizmo_hover == "centre" else _COL_GIZMO_CENTRE
renderer.draw_rect((ox - ch, oy - ch), (ch * 2, ch * 2), colour=col_c, filled=True)
# ------------------------------------------------------------------
# Rulers
# ------------------------------------------------------------------
def _draw_rulers(self, renderer, vx: float, vy: float, vw: float, vh: float):
"""Draw pixel rulers along the top and left edges."""
ruler_t = _RULER_THICKNESS
canvas_x = vx + ruler_t
canvas_y = vy + ruler_t
canvas_w = vw - ruler_t
canvas_h = vh - ruler_t
# Top ruler background
renderer.draw_rect((vx + ruler_t, vy), (canvas_w, ruler_t), colour=_COL_RULER_BG, filled=True)
# Left ruler background
renderer.draw_rect((vx, vy + ruler_t), (ruler_t, canvas_h), colour=_COL_RULER_BG, filled=True)
# Corner square
renderer.draw_rect((vx, vy), (ruler_t, ruler_t), colour=_COL_RULER_BG, filled=True)
# Determine tick spacing based on zoom (aim for ~80px between labels)
base_interval = self._ruler_interval()
# -- horizontal ruler (top) -----------------------------------------
left_c, _ = self._screen_to_canvas(canvas_x, vy)
right_c, _ = self._screen_to_canvas(canvas_x + canvas_w, vy)
start = math.floor(left_c / base_interval) * base_interval
renderer.push_clip(canvas_x, vy, canvas_w, ruler_t)
val = start
while val <= right_c:
sx, _ = self._canvas_to_screen(val, 0)
# Main tick
renderer.draw_line((sx, vy + ruler_t - 8), (sx, vy + ruler_t), colour=_COL_RULER_TICK)
# Label
label = self._ruler_label(val)
renderer.draw_text(label, (sx + 2, vy + 2), colour=_COL_RULER_TEXT, scale=0.55)
val += base_interval
renderer.pop_clip()
# -- vertical ruler (left) ------------------------------------------
_, top_c = self._screen_to_canvas(vx, canvas_y)
_, bot_c = self._screen_to_canvas(vx, canvas_y + canvas_h)
start = math.floor(top_c / base_interval) * base_interval
renderer.push_clip(vx, canvas_y, ruler_t, canvas_h)
val = start
while val <= bot_c:
_, sy = self._canvas_to_screen(0, val)
renderer.draw_line((vx + ruler_t - 8, sy), (vx + ruler_t, sy), colour=_COL_RULER_TICK)
label = self._ruler_label(val)
renderer.draw_text(label, (vx + 2, sy + 2), colour=_COL_RULER_TEXT, scale=0.55)
val += base_interval
renderer.pop_clip()
# Ruler border lines
renderer.draw_line(
(canvas_x, vy + ruler_t), (canvas_x + canvas_w, vy + ruler_t), colour=_COL_RULER_TICK
)
renderer.draw_line(
(vx + ruler_t, canvas_y), (vx + ruler_t, canvas_y + canvas_h), colour=_COL_RULER_TICK
)
def _ruler_interval(self) -> float:
"""Choose a 'nice' ruler tick interval based on the current zoom."""
# Target ~80 screen pixels between ticks
target_canvas = 80.0 / self._zoom
# Round to a nice number: 1, 2, 5, 10, 20, 50, 100, ...
magnitude = 10 ** math.floor(math.log10(max(target_canvas, 1e-6)))
residual = target_canvas / magnitude
if residual < 1.5:
nice = 1.0
elif residual < 3.5:
nice = 2.0
elif residual < 7.5:
nice = 5.0
else:
nice = 10.0
return nice * magnitude
@staticmethod
def _ruler_label(val: float) -> str:
"""Format a ruler value as a compact string."""
if val == 0:
return "0"
if abs(val) >= 1000:
return f"{val:.0f}"
if val == int(val):
return str(int(val))
return f"{val:.1f}"
# ------------------------------------------------------------------
# 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.engine 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."""
buttons = self._overlay_button_rects()
if not buttons:
return
toggle_states = {
"_wire": self._view_mode == "wire",
"_shaded": self._view_mode == "shaded",
"_textured": self._view_mode == "textured",
"_collision": self._show_collision,
"_camera": self._show_cameras,
"_light": self._show_lights,
"_path": self._show_paths,
}
# Background panel
total_w = buttons[-1][0] + buttons[-1][2] - buttons[0][0] + _OVERLAY_PAD * 2
renderer.draw_rect(
(buttons[0][0] - _OVERLAY_PAD, buttons[0][1] - _OVERLAY_PAD),
(total_w, _OVERLAY_BTN_H + _OVERLAY_PAD * 2),
colour=_OVERLAY_BG, filled=True)
# Build label lookup from key -> label
key_labels = {key: label for label, key in (e for e in self._OVERLAY_BUTTONS if e is not None)}
for bx, by, bw, bh, key in buttons:
is_active = toggle_states.get(key, False)
bg = _OVERLAY_BTN_ACTIVE if is_active else _OVERLAY_BTN_NORMAL
tc = _OVERLAY_BTN_TEXT_ACTIVE if is_active else _OVERLAY_BTN_TEXT
renderer.draw_rect((bx, by), (bw, bh), colour=bg, filled=True)
label = key_labels.get(key, "")
# Centre text in button
tw = renderer.text_width(label, _OVERLAY_FONT_SCALE) if hasattr(renderer, 'text_width') else len(label) * 6
tx = bx + (bw - tw) * 0.5
ty = by + (_OVERLAY_BTN_H - 14 * _OVERLAY_FONT_SCALE) * 0.5
renderer.draw_text(label, (tx, ty), colour=tc, scale=_OVERLAY_FONT_SCALE)
# ------------------------------------------------------------------
# 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."""
scale = 0.65
pad = 8.0
zoom_text = f"Zoom: {self._zoom:.1f}x"
coord_text = f"({self._mouse_canvas.x:.1f}, {self._mouse_canvas.y:.1f})"
# Position in bottom-right corner
y_line1 = vy + vh - 32
y_line2 = vy + vh - 16
x_right = vx + vw - pad
# Background for readability
bg_w = 160.0
bg_h = 36.0
renderer.draw_rect(
(x_right - bg_w, y_line1 - 4), (bg_w + pad, bg_h), colour=(0.10, 0.10, 0.10, 0.70), filled=True
)
renderer.draw_text(zoom_text, (x_right - bg_w + 4, y_line1), colour=_COL_INFO_TEXT, scale=scale)
renderer.draw_text(coord_text, (x_right - bg_w + 4, y_line2), colour=_COL_INFO_TEXT, scale=scale)
# Snap indicator
if self._snap_enabled:
snap_text = f"Snap: {self._snap_size:.0f}px"
renderer.draw_text(
snap_text, (x_right - bg_w + 90, y_line1), colour=_COL_SNAP_BADGE, scale=scale
)
# ======================================================================
# Editor root lookup
# ======================================================================
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)