Source code for simvx.editor.panels.scene2d_view

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