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,
    MouseButton,
    Node,
    Node2D,
    PropertyCommand,
    Vec2,
)

from ._scene2d_helpers import _Scene2DCanvasRenderer

if TYPE_CHECKING:
    from simvx.editor.state import State

log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

# Zoom limits
_ZOOM_MIN = 0.1
_ZOOM_MAX = 10.0
_ZOOM_STEP = 0.1

# Grid
_MINOR_GRID_SIZE = 50  # pixels between minor grid lines
_MAJOR_GRID_SIZE = 200  # pixels between major grid lines

# Ruler
_RULER_THICKNESS = 30  # pixel width/height of rulers

# Default node visualisation size (used when a node has no explicit extent)
_DEFAULT_NODE_SIZE = 40.0

# Frames the offscreen target size must be stable before recreating GPU
# resources. Prevents Vulkan thrashing during a user-driven window drag.
_RESIZE_DEBOUNCE_FRAMES = 6

# Gizmo dimensions (screen pixels, unscaled)
_GIZMO_ARROW_LEN = 60.0
_GIZMO_ARROW_HEAD = 10.0
_GIZMO_PICK_RADIUS = 8.0
_GIZMO_ROTATE_RADIUS = 60.0  # circle radius for rotate gizmo
_GIZMO_ROTATE_SEGMENTS = 48  # line segments to approximate the circle
_GIZMO_SCALE_BOX_HALF = 5.0  # half-size of endpoint squares on scale gizmo
_GIZMO_CENTRE_BOX_HALF = 6.0  # half-size of centre uniform-scale handle
_GIZMO_SNAP_ANGLE_DEG = 15.0  # angular snap increment in degrees

# Colours (RGBA floats)
_COL_BG = (0.15, 0.15, 0.17, 1.0)
_COL_MINOR_GRID = (0.22, 0.22, 0.24, 1.0)
_COL_MAJOR_GRID = (0.30, 0.30, 0.33, 1.0)
_COL_ORIGIN_X = (0.96, 0.26, 0.28, 1.0)
_COL_ORIGIN_Y = (0.40, 0.84, 0.36, 1.0)
_COL_RULER_BG = (0.12, 0.12, 0.12, 1.0)
_COL_RULER_TEXT = (0.55, 0.55, 0.55, 1.0)
_COL_RULER_TICK = (0.40, 0.40, 0.40, 1.0)
_COL_NODE_FILL = (0.30, 0.55, 0.80, 0.35)
_COL_NODE_BORDER = (0.40, 0.65, 0.90, 0.80)
_COL_SEL_BORDER = (1.00, 0.85, 0.20, 1.00)
_COL_HANDLE = (1.00, 1.00, 1.00, 0.90)
_COL_GIZMO_X = (0.96, 0.26, 0.28, 1.0)
_COL_GIZMO_Y = (0.40, 0.84, 0.36, 1.0)
_COL_GIZMO_HOVER = (1.00, 0.90, 0.20, 1.0)
_COL_GIZMO_Z = (0.30, 0.50, 0.95, 1.0)  # blue for Z-rotation ring
_COL_GIZMO_CENTRE = (0.85, 0.85, 0.85, 1.0)  # white-ish for uniform scale handle
_COL_INFO_TEXT = (0.70, 0.70, 0.70, 1.0)
_COL_SNAP_BADGE = (0.50, 0.80, 1.00, 1.0)

# Debug overlay toggle button styling (matches 3D viewport pattern)
_OVERLAY_BTN_W = 48
_OVERLAY_BTN_H = 22
_OVERLAY_BTN_GAP = 3
_OVERLAY_PAD = 6
_OVERLAY_BG = (0.06, 0.06, 0.08, 0.90)
_OVERLAY_BTN_NORMAL = (0.24, 0.24, 0.27, 0.90)
_OVERLAY_BTN_ACTIVE = (0.35, 0.55, 0.80, 1.0)
_OVERLAY_BTN_TEXT = (0.80, 0.80, 0.80, 1.0)
_OVERLAY_BTN_TEXT_ACTIVE = (1.0, 1.0, 1.0, 1.0)
_OVERLAY_FONT_SCALE = 0.65

# Distinct gizmo overlay colours per category
_COL_OVERLAY_COLLISION = (0.2, 0.8, 0.2, 0.7)   # green -- collision shapes, areas
_COL_OVERLAY_CAMERA = (0.6, 0.4, 0.9, 0.7)       # purple -- camera bounds
_COL_OVERLAY_LIGHT = (1.0, 0.9, 0.3, 0.6)        # yellow -- lights & occluders
_COL_OVERLAY_PATH = (1.0, 0.8, 0.0, 0.7)         # amber -- paths, trails, lines

# ---------------------------------------------------------------------------
# Helper: deterministic colour per node name (for distinct visual identity)
# ---------------------------------------------------------------------------

def _node_colour(name: str) -> tuple[float, float, float, float]:
    """Return a pastel RGBA colour derived from the node name hash."""
    h = hash(name) & 0xFFFFFFFF
    r = 0.35 + 0.45 * ((h >> 0) & 0xFF) / 255.0
    g = 0.35 + 0.45 * ((h >> 8) & 0xFF) / 255.0
    b = 0.35 + 0.45 * ((h >> 16) & 0xFF) / 255.0
    return (r, g, b, 0.50)

# ---------------------------------------------------------------------------
# Helper: estimate visual size of a Node2D in canvas pixels
# ---------------------------------------------------------------------------

def _node_extent(node: Node2D) -> tuple[float, float]:
    """Return (half_w, half_h) of the node's visual footprint."""
    # Controls have a real size: use it
    if isinstance(node, Control) and hasattr(node, "size"):
        return node.size_x / 2, node.size_y / 2
    sx = abs(node.scale.x) if hasattr(node, "scale") else 1.0
    sy = abs(node.scale.y) if hasattr(node, "scale") else 1.0
    base = _DEFAULT_NODE_SIZE * 0.5
    return base * sx, base * sy

# ============================================================================
# Scene2DView
# ============================================================================

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