Source code for simvx.editor.panels.viewport2d

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