"""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
"""
from __future__ import annotations
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 EditorState
# ---------------------------------------------------------------------------
# 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
# 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)
# ---------------------------------------------------------------------------
# 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
# ============================================================================
# Viewport2DPanel
# ============================================================================
[docs]
class Viewport2DPanel(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 ``EditorState`` instance that holds the edited scene,
selection, undo stack, etc.
"""
def __init__(self, editor_state: EditorState, **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)
# ======================================================================
# 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."""
# 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
# 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:
shell = self._find_editor_shell()
if shell and shell.scene_tree_panel and hasattr(shell.scene_tree_panel, "_rebuild_tree"):
shell.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.
# ======================================================================
# 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_filled_rect(vx, vy, vw, vh, _COL_BG)
# 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
self._draw_rulers(renderer, vx, vy, vw, vh)
# 7. 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_coloured(sx, cy, sx, cy + ch, 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_coloured(cx, sy, cx + cw, sy, 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_coloured(cx, oy, cx + cw, oy, _COL_ORIGIN_X)
# Y axis (vertical, green)
if cx <= ox <= cx + cw:
renderer.draw_line_coloured(ox, cy, ox, cy + ch, _COL_ORIGIN_Y)
# ------------------------------------------------------------------
# Node visualisation
# ------------------------------------------------------------------
def _draw_nodes(self, renderer):
"""Draw a coloured rectangle for every Node2D in the scene."""
root = self.state.edited_scene.root if self.state.edited_scene else None
if root is None:
return
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_filled_rect(sx, sy, sw, sh, fill)
# Border
is_sel = sel.is_selected(node)
border = _COL_SEL_BORDER if is_sel else _COL_NODE_BORDER
if is_sel:
# Thick 3px selection border using four thick lines
renderer.draw_thick_line_coloured(sx, sy, sx + sw, sy, 3.0, _COL_SEL_BORDER)
renderer.draw_thick_line_coloured(sx + sw, sy, sx + sw, sy + sh, 3.0, _COL_SEL_BORDER)
renderer.draw_thick_line_coloured(sx + sw, sy + sh, sx, sy + sh, 3.0, _COL_SEL_BORDER)
renderer.draw_thick_line_coloured(sx, sy + sh, sx, sy, 3.0, _COL_SEL_BORDER)
else:
renderer.draw_rect_coloured(sx, sy, sw, sh, border)
# Centre handle (filled circle, radius 4)
renderer.draw_filled_circle(cx_s, cy_s, 4, _COL_HANDLE)
# Node name label
scale = 0.7
renderer.draw_text_coloured(node.name, sx + 2, sy - 12, scale, _COL_NODE_BORDER)
# Gizmo lines (collision shapes, paths, raycasts, etc.)
gizmo_fn = getattr(node, 'get_gizmo_lines', None)
if gizmo_fn is not None:
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_coloured(s0x, s0y, s1x, s1y, colour)
# ------------------------------------------------------------------
# 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_coloured(ox, oy, end_x, oy, 3.0, col_x)
renderer.draw_filled_triangle(end_x, oy, end_x - head, oy - head * 0.5, end_x - head, oy + head * 0.5, col_x)
renderer.draw_text_coloured("X", end_x + 4, oy - 6, 0.65, col_x)
col_y = _COL_GIZMO_HOVER if self._gizmo_hover == "y" else _COL_GIZMO_Y
end_y = oy + _GIZMO_ARROW_LEN
renderer.draw_thick_line_coloured(ox, oy, ox, end_y, 3.0, col_y)
renderer.draw_filled_triangle(ox, end_y, ox - head * 0.5, end_y - head, ox + head * 0.5, end_y - head, col_y)
renderer.draw_text_coloured("Y", ox + 6, end_y + 2, 0.65, col_y)
renderer.draw_filled_circle(ox, oy, 4, _COL_HANDLE)
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_coloured(x0, y0, x1, y1, 2.0, 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_coloured(ox, oy, ix, iy, 2.0, col)
renderer.draw_filled_circle(ox, oy, 4, _COL_HANDLE)
renderer.draw_text_coloured("R", ox + r + 6, oy - 6, 0.65, col)
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_coloured(ox, oy, end_x, oy, 3.0, col_x)
renderer.draw_filled_rect(end_x - bh, oy - bh, bh * 2, bh * 2, col_x)
renderer.draw_text_coloured("X", end_x + bh + 2, oy - 6, 0.65, col_x)
# 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_coloured(ox, oy, ox, end_y, 3.0, col_y)
renderer.draw_filled_rect(ox - bh, end_y - bh, bh * 2, bh * 2, col_y)
renderer.draw_text_coloured("Y", ox + bh + 4, end_y + 2, 0.65, col_y)
# 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_filled_rect(ox - ch, oy - ch, ch * 2, ch * 2, col_c)
# ------------------------------------------------------------------
# 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_filled_rect(vx + ruler_t, vy, canvas_w, ruler_t, _COL_RULER_BG)
# Left ruler background
renderer.draw_filled_rect(vx, vy + ruler_t, ruler_t, canvas_h, _COL_RULER_BG)
# Corner square
renderer.draw_filled_rect(vx, vy, ruler_t, ruler_t, _COL_RULER_BG)
# 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_coloured(sx, vy + ruler_t - 8, sx, vy + ruler_t, _COL_RULER_TICK)
# Label
label = self._ruler_label(val)
renderer.draw_text_coloured(label, sx + 2, vy + 2, 0.55, _COL_RULER_TEXT)
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_coloured(vx + ruler_t - 8, sy, vx + ruler_t, sy, _COL_RULER_TICK)
label = self._ruler_label(val)
renderer.draw_text_coloured(label, vx + 2, sy + 2, 0.55, _COL_RULER_TEXT)
val += base_interval
renderer.pop_clip()
# Ruler border lines
renderer.draw_line_coloured(canvas_x, vy + ruler_t, canvas_x + canvas_w, vy + ruler_t, _COL_RULER_TICK)
renderer.draw_line_coloured(vx + ruler_t, canvas_y, vx + ruler_t, canvas_y + canvas_h, _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}"
# ------------------------------------------------------------------
# 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_filled_rect(x_right - bg_w, y_line1 - 4, bg_w + pad, bg_h, (0.10, 0.10, 0.10, 0.70))
renderer.draw_text_coloured(zoom_text, x_right - bg_w + 4, y_line1, scale, _COL_INFO_TEXT)
renderer.draw_text_coloured(coord_text, x_right - bg_w + 4, y_line2, scale, _COL_INFO_TEXT)
# Snap indicator
if self._snap_enabled:
snap_text = f"Snap: {self._snap_size:.0f}px"
renderer.draw_text_coloured(snap_text, x_right - bg_w + 90, y_line1, scale, _COL_SNAP_BADGE)
# ======================================================================
# Editor shell lookup
# ======================================================================
def _find_editor_shell(self):
"""Walk up the tree to find the EditorShell ancestor."""
from simvx.editor.app import EditorShell
node = self.parent
while node is not None:
if isinstance(node, EditorShell):
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
[docs]
def set_snap_size(self, size: float):
"""Set the snapping grid size in canvas pixels."""
self._snap_size = max(1.0, size)
@property
def zoom(self) -> float:
return self._zoom
@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)
@offset.setter
def offset(self, value):
if isinstance(value, Vec2):
self._offset = Vec2(value)
else:
self._offset = Vec2(value[0], value[1])
@property
def snap_enabled(self) -> bool:
return self._snap_enabled
@property
def snap_size(self) -> float:
return self._snap_size