Source code for simvx.editor.panels.viewport3d

"""3D Viewport Panel -- Interactive 3D scene view with camera, grid, gizmos.

Renders the edited scene through an EditorCamera3D with orbit/pan/zoom
controls, draws a reference grid on the XZ plane, and provides gizmo-based
transform manipulation for the selected node.
"""


from __future__ import annotations

import math
from typing import TYPE_CHECKING

import numpy as np

from simvx.core import (
    Camera3D,
    Control,
    GizmoAxis,
    GizmoMode,
    Light3D,
    MeshInstance3D,
    Node3D,
    PropertyCommand,
    Quat,
    Vec2,
    Vec3,
    ray_intersect_sphere,
    screen_to_ray,
)

if TYPE_CHECKING:
    from ..state import EditorState

from enum import StrEnum


[docs] class ViewMode(StrEnum): """3D viewport shading / display mode.""" SOLID = "solid" WIREFRAME = "wireframe" BOUNDING = "bounding"
# --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- # Grid configuration _GRID_EXTENT = 50 # half-extent in world units _GRID_MINOR_STEP = 1.0 # minor grid spacing _GRID_MAJOR_STEP = 10.0 # major grid spacing _MINOR_COLOUR = (0.28, 0.28, 0.30, 0.45) _MAJOR_COLOUR = (0.40, 0.40, 0.44, 0.70) # Axis indicator (drawn in viewport corner) _AXIS_INDICATOR_SIZE = 45 # pixel length of each axis arrow _AXIS_INDICATOR_MARGIN = 14 # pixels from bottom-left corner # Axis / gizmo colours _AXIS_COLOURS = { GizmoAxis.X: (0.90, 0.20, 0.20, 1.0), # red GizmoAxis.Y: (0.25, 0.80, 0.20, 1.0), # green GizmoAxis.Z: (0.20, 0.40, 0.95, 1.0), # blue } _AXIS_HIGHLIGHT = { GizmoAxis.X: (1.0, 0.50, 0.50, 1.0), GizmoAxis.Y: (0.55, 1.0, 0.50, 1.0), GizmoAxis.Z: (0.50, 0.65, 1.0, 1.0), } # Camera control sensitivities _ORBIT_SENSITIVITY = math.radians(0.35) # radians per pixel of mouse movement _PAN_SENSITIVITY = 0.02 # world units per pixel _ZOOM_SENSITIVITY = 1.5 # distance change per scroll step # Picking _PICK_RADIUS_DEFAULT = 0.7 # fallback radius for meshes without collision _GIZMO_HANDLE_SCREEN_PX = 10 # screen-pixel tolerance for gizmo pick # View info overlay _INFO_FONT_SCALE = 0.70 _INFO_COLOUR = (0.65, 0.65, 0.65, 0.85) _INFO_LABEL_COLOUR = (0.45, 0.45, 0.45, 0.85) # Selection highlight _SELECTION_OUTLINE_COLOUR = (1.0, 0.65, 0.15, 0.85) # View mode overlay _OVERLAY_BTN_W = 48 _OVERLAY_BTN_H = 24 _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.75 # --------------------------------------------------------------------------- # Viewport3DPanel # ---------------------------------------------------------------------------
[docs] class Viewport3DPanel(Control): """Interactive 3D viewport panel for the SimVX editor. Features: - Orbit / pan / zoom camera controls (middle-drag, shift+middle, scroll) - Laptop-friendly: Alt+left-drag = orbit, Alt+Shift+left-drag = pan - XZ ground grid (major + minor lines) - RGB axis indicator in corner - Object selection via ray picking - Gizmo overlay for translate / rotate / scale - Undo-integrated gizmo interactions - Camera info overlay """ def __init__(self, editor_state: EditorState, **kwargs): super().__init__(**kwargs) self.state = editor_state self.name = kwargs.get("name", "Viewport3D") self.bg_colour = (0.16, 0.16, 0.18, 1.0) # ---- camera input state ------------------------------------------- self._is_orbiting: bool = False self._is_panning: bool = False self._is_right_orbiting: bool = False self._is_alt_orbiting: bool = False # Alt+left-drag orbit (laptop) self._is_alt_panning: bool = False # Alt+Shift+left-drag pan (laptop) self._last_mouse: tuple[float, float] = (0.0, 0.0) # ---- gizmo interaction state -------------------------------------- self._hovered_axis: GizmoAxis | None = None self._drag_start_value = None # Vec3: position / euler / scale self._drag_node: Node3D | None = None # ---- cached projection artifacts for the current frame ------------ self._vp_matrix: np.ndarray | None = None self._view_matrix: np.ndarray | None = None self._proj_matrix: np.ndarray | None = None self._viewport_rect: tuple[float, float, float, float] = (0, 0, 1, 1) # ====================================================================== # Projection helpers # ====================================================================== def _build_matrices(self, vx: float, vy: float, vw: float, vh: float): """Recompute and cache view / projection / VP matrices.""" cam = self.state.editor_camera aspect = vw / vh if vh > 0 else 1.0 self._view_matrix = cam.view_matrix self._proj_matrix = cam.projection_matrix(aspect) self._vp_matrix = self._proj_matrix @ self._view_matrix self._viewport_rect = (vx, vy, vw, vh) def _project_point( self, world_pos, vx: float, vy: float, vw: float, vh: float, ) -> tuple[float, float, float] | None: """Project a 3D world point to 2D screen coordinates. Returns (screen_x, screen_y, ndc_depth) or None if behind camera. ndc_depth is in [-1, 1]; values outside indicate clipping. """ if self._vp_matrix is None: return None if isinstance(world_pos, Vec3): wp = np.array([world_pos.x, world_pos.y, world_pos.z, 1.0], dtype=np.float32) elif isinstance(world_pos, tuple | list): wp = np.array([world_pos[0], world_pos[1], world_pos[2], 1.0], dtype=np.float32) else: wp = np.array([float(world_pos[0]), float(world_pos[1]), float(world_pos[2]), 1.0], dtype=np.float32) clip = self._vp_matrix @ wp if clip[3] < 1e-4: return None ndc = clip[:3] / clip[3] # Projection already includes Vulkan Y-flip (proj[1,1] *= -1), # so ndc_y is already inverted — map directly to screen without # an additional flip. sx = vx + (ndc[0] * 0.5 + 0.5) * vw sy = vy + (ndc[1] * 0.5 + 0.5) * vh return (sx, sy, float(ndc[2])) def _project_direction( self, origin: Vec3, direction: Vec3, axis_length: float, vx: float, vy: float, vw: float, vh: float, ) -> tuple[tuple[float, float], tuple[float, float]] | None: """Project an axis segment (origin -> origin + direction * axis_length). Returns ((sx0, sy0), (sx1, sy1)) or None if projection fails. """ p0 = self._project_point(origin, vx, vy, vw, vh) end = origin + direction * axis_length p1 = self._project_point(end, vx, vy, vw, vh) if p0 is None or p1 is None: return None return ((p0[0], p0[1]), (p1[0], p1[1])) def _project_line( self, world_p0, world_p1, vx: float, vy: float, vw: float, vh: float, ) -> tuple[tuple[float, float], tuple[float, float]] | None: """Project a 3D line segment to 2D with near-plane clipping. Clips the segment against the camera near plane so lines that partially extend behind the camera are drawn correctly instead of producing inverted projections. Returns ((sx0, sy0), (sx1, sy1)) or None if fully behind camera. """ if self._vp_matrix is None: return None def _to_clip(wp): if isinstance(wp, Vec3): p = np.array([wp.x, wp.y, wp.z, 1.0], dtype=np.float32) elif isinstance(wp, tuple | list): p = np.array([wp[0], wp[1], wp[2], 1.0], dtype=np.float32) else: p = np.array([float(wp[0]), float(wp[1]), float(wp[2]), 1.0], dtype=np.float32) return self._vp_matrix @ p c0 = _to_clip(world_p0) c1 = _to_clip(world_p1) # Clip against near plane (w must be > epsilon for valid projection) _NEAR_W = 1e-4 d0 = c0[3] - _NEAR_W d1 = c1[3] - _NEAR_W if d0 < 0 and d1 < 0: return None # Both behind camera if d0 < 0: t = d0 / (d0 - d1) c0 = c0 + t * (c1 - c0) elif d1 < 0: t = d1 / (d1 - d0) c1 = c1 + t * (c0 - c1) def _to_screen(clip): ndc = clip[:3] / clip[3] sx = vx + (ndc[0] * 0.5 + 0.5) * vw sy = vy + (ndc[1] * 0.5 + 0.5) * vh return (sx, sy) return (_to_screen(c0), _to_screen(c1)) # ====================================================================== # Camera controls # ====================================================================== # -- view mode helper --------------------------------------------------- def _get_view_mode(self) -> ViewMode: """Return the current ViewMode enum from editor state.""" v = self.state.view_mode_3d if v == "wireframe": return ViewMode.WIREFRAME if v == "bounding": return ViewMode.BOUNDING return ViewMode.SOLID def _overlay_button_rects(self) -> list[tuple[float, float, float, float, str]]: """Return [(x, y, w, h, mode_name), ...] for the view overlay buttons.""" vx, vy, _, _ = self.get_global_rect() ox = vx + _OVERLAY_PAD oy = vy + _OVERLAY_PAD buttons = [] for i, (_label, mode) in enumerate([("Solid", "solid"), ("Wire", "wireframe"), ("Bbox", "bounding")]): bx = ox + i * (_OVERLAY_BTN_W + _OVERLAY_BTN_GAP) buttons.append((bx, oy, _OVERLAY_BTN_W, _OVERLAY_BTN_H, mode)) # Grid toggle after the mode buttons with a small gap gx = ox + 3 * (_OVERLAY_BTN_W + _OVERLAY_BTN_GAP) + 6 buttons.append((gx, oy, _OVERLAY_BTN_W, _OVERLAY_BTN_H, "_grid")) return buttons def _handle_overlay_click(self, mx: float, my: float) -> bool: """Check if click hits an overlay button. Returns True if consumed.""" for bx, by, bw, bh, mode in self._overlay_button_rects(): if bx <= mx <= bx + bw and by <= my <= by + bh: if mode == "_grid": self.state.show_grid_3d = not self.state.show_grid_3d else: self.state.view_mode_3d = mode return True return False def _on_gui_input(self, event): """Route mouse / key events to camera, selection, and gizmo logic.""" mx = event.position.x if hasattr(event.position, "x") else event.position[0] my = event.position.y if hasattr(event.position, "x") else event.position[1] if not self.is_point_inside(event.position): return # ---- overlay click intercept (left press only) --------------------- if event.pressed and event.button == 1: if self._handle_overlay_click(mx, my): return # ---- mouse press --------------------------------------------------- if event.pressed: if event.button == 2: # middle button self._last_mouse = (mx, my) if event.key == "shift" or self._is_shift_held(): self._is_panning = True else: self._is_orbiting = True elif event.button == 3: # right button self._last_mouse = (mx, my) self._is_right_orbiting = True elif event.button == 1: # left button # Alt+left = laptop-friendly camera controls if self._is_alt_held(): self._last_mouse = (mx, my) if self._is_shift_held(): self._is_alt_panning = True else: self._is_alt_orbiting = True else: self._handle_left_press(mx, my) # ---- mouse release ------------------------------------------------- elif not event.pressed: if event.button == 2: self._is_orbiting = False self._is_panning = False elif event.button == 3: self._is_right_orbiting = False elif event.button == 1: if self._is_alt_orbiting or self._is_alt_panning: self._is_alt_orbiting = False self._is_alt_panning = False else: self._handle_left_release(mx, my) # ---- mouse motion (button == 0 and pressed is irrelevant) ---------- if event.button == 0: self._handle_mouse_move(mx, my) # ---- scroll (zoom) ------------------------------------------------- if event.key == "scroll_up": self._handle_scroll(1.0) return if event.key == "scroll_down": self._handle_scroll(-1.0) return # ---- keyboard shortcuts (when viewport has focus) ------------------ if event.key: self._handle_key(event.key, event.pressed) # -- camera motion helpers ----------------------------------------------- def _handle_mouse_move(self, mx: float, my: float): dx = mx - self._last_mouse[0] dy = my - self._last_mouse[1] cam = self.state.editor_camera if self._is_orbiting or self._is_right_orbiting or self._is_alt_orbiting: cam.orbit(-dx * _ORBIT_SENSITIVITY, -dy * _ORBIT_SENSITIVITY) if self._is_panning or self._is_alt_panning: pan_scale = cam.distance * _PAN_SENSITIVITY cam.pan(-dx * pan_scale, dy * pan_scale) # Update gizmo dragging if self.state.gizmo.dragging and self._drag_node is not None: self._update_gizmo_drag(mx, my) # Update gizmo hover (when not dragging) if not self.state.gizmo.dragging: self._update_gizmo_hover(mx, my) self._last_mouse = (mx, my) def _handle_scroll(self, scroll_delta: float): self.state.editor_camera.zoom(scroll_delta * _ZOOM_SENSITIVITY) def _is_shift_held(self) -> bool: """Check if Shift is currently held via the Input key state.""" from simvx.core import Input return Input._keys.get("shift", False) def _is_alt_held(self) -> bool: """Check if Alt is currently held via the Input key state.""" from simvx.core import Input return Input._keys.get("alt", False) # -- keyboard shortcut handler ------------------------------------------- def _handle_key(self, key: str, pressed: bool): if not pressed: return key_lower = key.lower() # Gizmo mode shortcuts if key_lower == "w": self.state.gizmo.mode = GizmoMode.TRANSLATE elif key_lower == "e": self.state.gizmo.mode = GizmoMode.ROTATE elif key_lower == "r": self.state.gizmo.mode = GizmoMode.SCALE elif key_lower == "q": self.state.gizmo.cycle_mode() # Delete selected node elif key_lower in ("delete", "x"): sel = self.state.selection.primary if sel is not None: self.state.remove_node(sel) self.state.selection.clear() # Focus on selected elif key_lower == "f": self._focus_on_selected() # Numpad views elif key_lower == "kp_1": self._snap_view(yaw=0, pitch=0) # front elif key_lower == "kp_3": self._snap_view(yaw=math.radians(-90), pitch=0) # right elif key_lower == "kp_7": self._snap_view(yaw=0, pitch=math.radians(-89.9)) # top def _focus_on_selected(self): """Move camera pivot to the selected node's position.""" sel = self.state.selection.primary if sel is not None and isinstance(sel, Node3D): pos = sel.world_position self.state.editor_camera.pivot = Vec3(pos) self.state.editor_camera._update_transform() def _snap_view(self, yaw: float, pitch: float): """Snap the camera to a preset orbit angle.""" cam = self.state.editor_camera cam.yaw = yaw cam.pitch = pitch cam._update_transform() # ====================================================================== # Selection picking # ====================================================================== def _handle_left_press(self, mx: float, my: float): """Left-click: try gizmo pick first, then scene object pick.""" vx, vy, vw, vh = self._viewport_rect # --- gizmo pick first --- gizmo = self.state.gizmo sel = self.state.selection.primary if sel is not None and isinstance(sel, Node3D): picked_axis = self._pick_gizmo_screen(mx, my, sel) if picked_axis is not None: self._begin_gizmo_drag(sel, picked_axis, mx, my) return # --- scene object pick --- cam = self.state.editor_camera ray = screen_to_ray( (mx - vx, my - vy), (vw, vh), cam.view_matrix, cam.projection_matrix(vw / vh if vh > 0 else 1.0), ) origin, direction = ray root = self.state.edited_scene.root if self.state.edited_scene else None if root is None: self.state.selection.clear() return best_t = float("inf") best_node: Node3D | None = None for node in root.find_all(Node3D): if not node.visible or node is self.state.editor_camera: continue pos = node.world_position sx = abs(node.world_scale.x) sy = abs(node.world_scale.y) sz = abs(node.world_scale.z) radius = max(sx, sy, sz, 0.5) * _PICK_RADIUS_DEFAULT t = ray_intersect_sphere(origin, direction, pos, radius) if t is not None and t < best_t: best_t = t best_node = node if best_node is not None: additive = self._is_shift_held() self.state.selection.select(best_node, additive=additive) self.state.modified = True # Position gizmo at node gizmo.position = Vec3(best_node.world_position) gizmo.active = True else: self.state.selection.clear() gizmo.active = False def _handle_left_release(self, mx: float, my: float): """Left button release: finalize gizmo drag with undo command.""" gizmo = self.state.gizmo if gizmo.dragging and self._drag_node is not None: gizmo.end_drag() self._commit_gizmo_undo() self._drag_node = None # ====================================================================== # Gizmo picking / dragging # ====================================================================== def _pick_gizmo_screen( self, mx: float, my: float, node: Node3D, ) -> GizmoAxis | None: """Screen-space gizmo axis picking using projected handle endpoints.""" vx, vy, vw, vh = self._viewport_rect origin_3d = node.world_position origin_2d = self._project_point(origin_3d, vx, vy, vw, vh) if origin_2d is None: return None cam = self.state.editor_camera gizmo = self.state.gizmo # Adaptive axis length: constant screen-pixel length screen_axis_len = self._gizmo_screen_length(origin_3d, vx, vy, vw, vh) gizmo.axis_length = screen_axis_len # Ray-based picking through the Gizmo class ray = screen_to_ray( (mx - vx, my - vy), (vw, vh), cam.view_matrix, cam.projection_matrix(vw / vh if vh > 0 else 1.0), ) gizmo.position = Vec3(origin_3d) return gizmo.pick_axis(ray[0], ray[1]) def _gizmo_screen_length( self, world_origin: Vec3, vx: float, vy: float, vw: float, vh: float, ) -> float: """Compute a world-space axis length that appears ~100px on screen. Uses camera distance and vertical FOV directly so the result is independent of viewport aspect ratio and camera orientation. """ target_px = 100.0 cam = self.state.editor_camera cam_pos = cam.world_position dist = float(np.linalg.norm(world_origin - cam_pos)) if dist < 1e-6: return 1.5 fov_rad = math.radians(cam.fov) # World units visible across the viewport height at this distance world_height = 2.0 * dist * math.tan(fov_rad * 0.5) # Clamp effective vh to prevent explosion at very small viewports effective_vh = max(vh, 100.0) world_per_px = world_height / effective_vh # Clamp max world-space length to 40% of camera distance return min(target_px * world_per_px, dist * 0.4) def _begin_gizmo_drag( self, node: Node3D, axis: GizmoAxis, mx: float, my: float, ): """Start a gizmo drag operation and record the starting value.""" gizmo = self.state.gizmo vx, vy, vw, vh = self._viewport_rect cam = self.state.editor_camera ray = screen_to_ray( (mx - vx, my - vy), (vw, vh), cam.view_matrix, cam.projection_matrix(vw / vh if vh > 0 else 1.0), ) gizmo.position = Vec3(node.world_position) gizmo.begin_drag(axis, ray[0], ray[1]) self._drag_node = node # Snapshot current value for undo mode = gizmo.mode if mode is GizmoMode.TRANSLATE: self._drag_start_value = Vec3(node.position) elif mode is GizmoMode.ROTATE: self._drag_start_value = Quat(node.rotation) elif mode is GizmoMode.SCALE: self._drag_start_value = Vec3(node.scale) def _update_gizmo_drag(self, mx: float, my: float): """Apply incremental gizmo delta to the dragged node.""" gizmo = self.state.gizmo node = self._drag_node if node is None or not gizmo.dragging: return vx, vy, vw, vh = self._viewport_rect cam = self.state.editor_camera ray = screen_to_ray( (mx - vx, my - vy), (vw, vh), cam.view_matrix, cam.projection_matrix(vw / vh if vh > 0 else 1.0), ) delta = gizmo.update_drag(ray[0], ray[1]) mode = gizmo.mode if mode is GizmoMode.TRANSLATE: node.position = Vec3( node.position.x + delta.x, node.position.y + delta.y, node.position.z + delta.z, ) gizmo.position = Vec3(node.world_position) elif mode is GizmoMode.ROTATE: euler = node.rotation.euler_angles() node.rotation = Quat.from_euler(euler.x + delta.x, euler.y + delta.y, euler.z + delta.z) elif mode is GizmoMode.SCALE: node.scale = Vec3( node.scale.x + delta.x, node.scale.y + delta.y, node.scale.z + delta.z, ) self.state.modified = True def _commit_gizmo_undo(self): """Push a PropertyCommand capturing the full drag as one undo step.""" node = self._drag_node if node is None or self._drag_start_value is None: return mode = self.state.gizmo.mode if mode is GizmoMode.TRANSLATE: attr = "position" new_value = Vec3(node.position) elif mode is GizmoMode.ROTATE: attr = "rotation" new_value = Quat(node.rotation) elif mode is GizmoMode.SCALE: attr = "scale" new_value = Vec3(node.scale) else: return old_value = self._drag_start_value # Only push if the value actually changed if old_value == new_value: return mode_name = mode.name.lower() cmd = PropertyCommand( node, attr, old_value, new_value, description=f"{mode_name.capitalize()} {node.name}", ) # Push without re-executing (it was already applied during drag) self.state.undo_stack._undo.append(cmd) self.state.undo_stack._redo.clear() self.state.undo_stack.changed.emit() self._drag_start_value = None def _update_gizmo_hover(self, mx: float, my: float): """Highlight the gizmo axis currently under the cursor.""" sel = self.state.selection.primary if sel is None or not isinstance(sel, Node3D): self._hovered_axis = None return self._hovered_axis = self._pick_gizmo_screen(mx, my, sel) self.state.gizmo.hover_axis = self._hovered_axis # ====================================================================== # Drawing # ======================================================================
[docs] def process(self, dt: float): """Per-frame update: keep gizmo position in sync with selection.""" sel = self.state.selection.primary if sel is not None and isinstance(sel, Node3D): self.state.gizmo.position = Vec3(sel.world_position) self.state.gizmo.active = True else: self.state.gizmo.active = False
[docs] def draw(self, renderer): """Main draw entry point called each frame by the editor.""" vx, vy, vw, vh = self.get_global_rect() if vw < 1 or vh < 1: return # Background renderer.draw_filled_rect(vx, vy, vw, vh, self.bg_colour) renderer.push_clip(vx, vy, vw, vh) # Rebuild camera matrices self._build_matrices(vx, vy, vw, vh) # Draw layers (back-to-front) if self.state.show_grid_3d: self._draw_grid(renderer, vx, vy, vw, vh) self._draw_scene_objects(renderer, vx, vy, vw, vh) self._draw_node_gizmos(renderer, vx, vy, vw, vh) self._draw_selection_highlight(renderer, vx, vy, vw, vh) self._draw_gizmo(renderer, vx, vy, vw, vh) self._draw_axis_indicator(renderer, vx, vy, vw, vh) # Force a new layer so the overlay renders on top of grid lines renderer.new_layer() self._draw_view_overlay(renderer, vx, vy, vw, vh) self._draw_view_info(renderer, vx, vy, vw, vh) renderer.pop_clip()
# -- grid --------------------------------------------------------------- def _draw_grid( self, renderer, vx: float, vy: float, vw: float, vh: float, ): """Draw an XZ-plane ground grid centred on the camera target.""" cam = self.state.editor_camera cx = cam.pivot.x cz = cam.pivot.z # Snap grid origin to major step increments for visual stability snap_x = math.floor(cx / _GRID_MAJOR_STEP) * _GRID_MAJOR_STEP snap_z = math.floor(cz / _GRID_MAJOR_STEP) * _GRID_MAJOR_STEP # Determine visible extent based on camera distance extent = min(_GRID_EXTENT, cam.distance * 3) # Minor lines self._draw_grid_lines( renderer, snap_x, snap_z, extent, _GRID_MINOR_STEP, _MINOR_COLOUR, vx, vy, vw, vh, ) # Major lines self._draw_grid_lines( renderer, snap_x, snap_z, extent, _GRID_MAJOR_STEP, _MAJOR_COLOUR, vx, vy, vw, vh, ) # Draw axis centre lines (X = red, Z = blue) through world origin x_seg = self._project_line( Vec3(-extent, 0, 0), Vec3(extent, 0, 0), vx, vy, vw, vh) if x_seg: renderer.draw_line_coloured( x_seg[0][0], x_seg[0][1], x_seg[1][0], x_seg[1][1], (0.65, 0.22, 0.22, 0.60)) z_seg = self._project_line( Vec3(0, 0, -extent), Vec3(0, 0, extent), vx, vy, vw, vh) if z_seg: renderer.draw_line_coloured( z_seg[0][0], z_seg[0][1], z_seg[1][0], z_seg[1][1], (0.22, 0.30, 0.65, 0.60)) def _draw_grid_lines( self, renderer, center_x: float, center_z: float, extent: float, step: float, colour: tuple, vx: float, vy: float, vw: float, vh: float, ): """Render a set of grid lines at a given spacing.""" n = int(extent / step) for i in range(-n, n + 1): offset = i * step # Lines parallel to Z-axis seg = self._project_line( Vec3(center_x + offset, 0, center_z - extent), Vec3(center_x + offset, 0, center_z + extent), vx, vy, vw, vh, ) if seg: renderer.draw_line_coloured( seg[0][0], seg[0][1], seg[1][0], seg[1][1], colour) # Lines parallel to X-axis seg = self._project_line( Vec3(center_x - extent, 0, center_z + offset), Vec3(center_x + extent, 0, center_z + offset), vx, vy, vw, vh, ) if seg: renderer.draw_line_coloured( seg[0][0], seg[0][1], seg[1][0], seg[1][1], colour) # -- scene objects ------------------------------------------------------- def _draw_scene_objects( self, renderer, vx: float, vy: float, vw: float, vh: float, ): """Draw representations of all 3D nodes based on view mode.""" root = self.state.edited_scene.root if self.state.edited_scene else None if root is None: return view_mode = self._get_view_mode() for node in root.find_all(Node3D): if not node.visible: continue if node is self.state.editor_camera: continue if isinstance(node, MeshInstance3D): self._draw_mesh_object(renderer, node, view_mode, vx, vy, vw, vh) elif isinstance(node, Light3D): self._draw_light_icon(renderer, node, vx, vy, vw, vh) elif isinstance(node, Camera3D): self._draw_camera_icon(renderer, node, vx, vy, vw, vh) else: self._draw_node3d_icon(renderer, node, vx, vy, vw, vh) def _mesh_colour(self, node: MeshInstance3D) -> tuple: """Get material-derived colour for a mesh node.""" mat = node.material if mat and hasattr(mat, 'colour'): mc = mat.colour return (min(mc[0] * 1.3, 1.0), min(mc[1] * 1.3, 1.0), min(mc[2] * 1.3, 1.0), 0.85) return (0.7, 0.7, 0.7, 0.7) # -- vectorised mesh projection ------------------------------------------ def _project_mesh_verts(self, mesh, node: MeshInstance3D, vx, vy, vw, vh): """Batch-project mesh vertices to screen coords via numpy. Returns (sx, sy, valid) float32 arrays of length N, or None. """ if self._vp_matrix is None: return None positions = mesh.positions if positions is None or len(positions) == 0: return None N = len(positions) model = node.model_matrix mvp = self._vp_matrix @ model homo = np.empty((N, 4), dtype=np.float32) homo[:, :3] = positions homo[:, 3] = 1.0 clip = homo @ mvp.T # (N, 4) valid = clip[:, 3] > 1e-4 w = np.where(valid, clip[:, 3], 1.0) ndc_x = clip[:, 0] / w ndc_y = clip[:, 1] / w sx = vx + (ndc_x * 0.5 + 0.5) * vw sy = vy + (ndc_y * 0.5 + 0.5) * vh return sx, sy, valid @staticmethod def _get_mesh_edges(mesh) -> list: """Extract unique edges from mesh triangle indices (cached on mesh).""" cache = getattr(mesh, '_editor_edges', None) if cache is not None: return cache edges = set() idx = mesh.indices if idx is not None and len(idx) >= 3: for i in range(0, len(idx) - 2, 3): a, b, c = int(idx[i]), int(idx[i + 1]), int(idx[i + 2]) edges.add((min(a, b), max(a, b))) edges.add((min(b, c), max(b, c))) edges.add((min(a, c), max(a, c))) result = sorted(edges) mesh._editor_edges = result return result def _draw_mesh_object( self, renderer, node: MeshInstance3D, view_mode: ViewMode, vx: float, vy: float, vw: float, vh: float, ): """Draw a MeshInstance3D using projected mesh geometry.""" mesh = node.mesh if mesh is None: self._draw_node3d_icon(renderer, node, vx, vy, vw, vh) return colour = self._mesh_colour(node) # Project actual mesh vertices proj = self._project_mesh_verts(mesh, node, vx, vy, vw, vh) if proj is None: self._draw_node3d_icon(renderer, node, vx, vy, vw, vh) return sx, sy, valid = proj if view_mode is ViewMode.SOLID: # Fill mesh triangles (front-faces only via screen-space winding) fill_colour = (colour[0], colour[1], colour[2], 0.35) idx = mesh.indices if idx is not None: for i in range(0, len(idx) - 2, 3): a, b, c = int(idx[i]), int(idx[i + 1]), int(idx[i + 2]) if not (valid[a] and valid[b] and valid[c]): continue # Backface cull in screen space cross = ((sx[b] - sx[a]) * (sy[c] - sy[a]) - (sy[b] - sy[a]) * (sx[c] - sx[a])) if cross < 0: renderer.draw_filled_triangle( sx[a], sy[a], sx[b], sy[b], sx[c], sy[c], fill_colour) if view_mode is not ViewMode.BOUNDING: # Draw actual mesh edges (subsampled for dense meshes) edges = self._get_mesh_edges(mesh) max_edges = 200 step = max(1, len(edges) // max_edges) lw = 1.5 if view_mode is ViewMode.SOLID else 2.0 for i in range(0, len(edges), step): a, b = edges[i] if valid[a] and valid[b]: renderer.draw_thick_line_coloured( float(sx[a]), float(sy[a]), float(sx[b]), float(sy[b]), lw, colour) else: # BOUNDING mode — thin AABB wireframe pos = node.world_position scale = node.world_scale try: bb_min, bb_max = mesh.bounding_box() hx = abs(float(bb_max[0] - bb_min[0])) * 0.5 * abs(float(scale[0])) hy = abs(float(bb_max[1] - bb_min[1])) * 0.5 * abs(float(scale[1])) hz = abs(float(bb_max[2] - bb_min[2])) * 0.5 * abs(float(scale[2])) except Exception: hx = hy = hz = abs(float(scale[0])) * 0.5 corners = [ Vec3(pos[0] + dx * hx, pos[1] + dy * hy, pos[2] + dz * hz) for dx, dy, dz in [ (-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1), (-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1), ] ] proj_c = [self._project_point(c, vx, vy, vw, vh) for c in corners] for a, b in [(0,1),(1,2),(2,3),(3,0),(4,5),(5,6),(6,7),(7,4),(0,4),(1,5),(2,6),(3,7)]: pa, pb = proj_c[a], proj_c[b] if pa is not None and pb is not None: renderer.draw_line_coloured(pa[0], pa[1], pb[0], pb[1], colour) # Center dot + name center_p = self._project_point(node.world_position, vx, vy, vw, vh) if center_p: renderer.draw_filled_circle(center_p[0], center_p[1], 3.5, colour) renderer.draw_text_coloured( node.name, center_p[0] + 6, center_p[1] - 5, 0.55, (0.75, 0.75, 0.75, 0.70)) def _draw_node3d_icon(self, renderer, node: Node3D, vx, vy, vw, vh): """Draw a small axis cross for a plain Node3D so it's visible in the viewport.""" p = self._project_point(node.world_position, vx, vy, vw, vh) if p is None: return sz = 10 renderer.draw_thick_line_coloured(p[0] - sz, p[1], p[0] + sz, p[1], 2.0, (0.90, 0.35, 0.35, 0.80)) renderer.draw_thick_line_coloured(p[0], p[1] - sz, p[0], p[1] + sz, 2.0, (0.35, 0.80, 0.35, 0.80)) renderer.draw_filled_circle(p[0], p[1], 3.0, (0.85, 0.85, 0.85, 0.80)) renderer.draw_text_coloured(node.name, p[0] + sz + 4, p[1] - 5, 0.55, (0.70, 0.70, 0.70, 0.75)) def _draw_light_icon(self, renderer, node, vx, vy, vw, vh): """Draw a 3D light representation with a filled circle and emanating rays.""" pos = node.world_position p = self._project_point(pos, vx, vy, vw, vh) if p is None: return c = (1.0, 0.90, 0.30, 0.90) fill_c = (1.0, 0.90, 0.30, 0.50) # Inner filled circle renderer.draw_filled_circle(p[0], p[1], 8, fill_c) renderer.draw_filled_circle(p[0], p[1], 4, c) # 8 emanating rays ray_inner, ray_outer = 10, 18 for i in range(8): angle = i * math.tau / 8 ix = p[0] + math.cos(angle) * ray_inner iy = p[1] + math.sin(angle) * ray_inner ox = p[0] + math.cos(angle) * ray_outer oy = p[1] + math.sin(angle) * ray_outer renderer.draw_thick_line_coloured(ix, iy, ox, oy, 2.0, c) # For DirectionalLight, draw a direction arrow in world space from simvx.core import DirectionalLight3D if isinstance(node, DirectionalLight3D): fwd_end = pos + node.forward * 2.5 fp = self._project_point(fwd_end, vx, vy, vw, vh) if fp is not None: renderer.draw_thick_line_coloured(p[0], p[1], fp[0], fp[1], 2.5, c) # Arrowhead dx, dy = fp[0] - p[0], fp[1] - p[1] ln = math.sqrt(dx * dx + dy * dy) if ln > 1: ndx, ndy = dx / ln, dy / ln px, py = -ndy, ndx renderer.draw_filled_triangle( fp[0], fp[1], fp[0] - ndx * 8 + px * 4, fp[1] - ndy * 8 + py * 4, fp[0] - ndx * 8 - px * 4, fp[1] - ndy * 8 - py * 4, c) renderer.draw_text_coloured( node.name, p[0] + ray_outer + 4, p[1] - 5, 0.55, (0.90, 0.85, 0.40, 0.75)) def _draw_camera_icon(self, renderer, node, vx, vy, vw, vh): """Draw a 3D camera frustum wireframe.""" pos = node.world_position p = self._project_point(pos, vx, vy, vw, vh) if p is None: return c = (0.55, 0.70, 0.95, 0.90) fill_c = (0.55, 0.70, 0.95, 0.25) # Compute frustum corners in world space fwd = node.forward up_hint = Vec3(0, 1, 0) right = np.cross(fwd, up_hint) rn = np.linalg.norm(right) if rn < 1e-6: right = np.cross(fwd, Vec3(0, 0, 1)) rn = np.linalg.norm(right) right = Vec3(*(right / rn)) up = Vec3(*np.cross(right, fwd)) # Near and far plane sizes (fixed world-space sizes for icon) nd, fd = 0.4, 1.8 # near/far distances nh, fh = 0.25, 0.8 # near/far half-heights nw, fw = 0.35, 1.1 # near/far half-widths near_center = pos + fwd * nd far_center = pos + fwd * fd near_corners = [ near_center + right * s1 * nw + up * s2 * nh for s1, s2 in [(-1, -1), (1, -1), (1, 1), (-1, 1)] ] far_corners = [ far_center + right * s1 * fw + up * s2 * fh for s1, s2 in [(-1, -1), (1, -1), (1, 1), (-1, 1)] ] pn = [self._project_point(c, vx, vy, vw, vh) for c in near_corners] pf = [self._project_point(c, vx, vy, vw, vh) for c in far_corners] # Near rect for i in range(4): j = (i + 1) % 4 if pn[i] and pn[j]: renderer.draw_thick_line_coloured(pn[i][0], pn[i][1], pn[j][0], pn[j][1], 2.0, c) # Far rect for i in range(4): j = (i + 1) % 4 if pf[i] and pf[j]: renderer.draw_thick_line_coloured(pf[i][0], pf[i][1], pf[j][0], pf[j][1], 2.0, c) # Connecting edges (near to far) for i in range(4): if pn[i] and pf[i]: renderer.draw_thick_line_coloured(pn[i][0], pn[i][1], pf[i][0], pf[i][1], 1.5, c) # Fill near face if all(pn): renderer.draw_filled_quad( pn[0][0], pn[0][1], pn[1][0], pn[1][1], pn[2][0], pn[2][1], pn[3][0], pn[3][1], fill_c) # Camera body (small filled rect behind near plane) renderer.draw_filled_circle(p[0], p[1], 4, c) renderer.draw_text_coloured( node.name, p[0] + 8, p[1] - 5, 0.55, (0.65, 0.75, 0.95, 0.75)) # -- node gizmos (collision shapes, paths, raycasts, etc.) ---------------- def _draw_node_gizmos( self, renderer, vx: float, vy: float, vw: float, vh: float, ): """Draw gizmo wireframes for all nodes that implement get_gizmo_lines().""" 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 root.find_all(Node3D): if not node.visible or node is self.state.editor_camera: continue gizmo_fn = getattr(node, 'get_gizmo_lines', None) if gizmo_fn is None: continue lines = gizmo_fn() if not lines: continue colour = tuple(getattr(node, 'gizmo_colour', (0.7, 0.7, 0.7, 0.6))) # Brighten alpha for selected nodes is_sel = sel.is_selected(node) if not is_sel and len(colour) >= 4: colour = (colour[0], colour[1], colour[2], colour[3] * 0.6) for p0, p1 in lines: seg = self._project_line(p0, p1, vx, vy, vw, vh) if seg is not None: renderer.draw_line_coloured(seg[0][0], seg[0][1], seg[1][0], seg[1][1], colour) # -- selection highlight ------------------------------------------------- def _draw_selection_highlight( self, renderer, vx: float, vy: float, vw: float, vh: float, ): """Draw a fixed-size selection indicator at each selected node's center.""" for node in self.state.selection: if not isinstance(node, Node3D) or not node.visible: continue p = self._project_point(node.world_position, vx, vy, vw, vh) if p is None: continue cx, cy = p[0], p[1] c = _SELECTION_OUTLINE_COLOUR # Crosshair arms (16px) arm = 16.0 renderer.draw_thick_line_coloured(cx - arm, cy, cx + arm, cy, 1.5, c) renderer.draw_thick_line_coloured(cx, cy - arm, cx, cy + arm, 1.5, c) # Circle (12px radius, 24 segments) r = 12.0 segments = 24 for i in range(segments): a0 = 2.0 * math.pi * i / segments a1 = 2.0 * math.pi * (i + 1) / segments renderer.draw_thick_line_coloured( cx + r * math.cos(a0), cy + r * math.sin(a0), cx + r * math.cos(a1), cy + r * math.sin(a1), 1.5, c, ) # -- gizmo --------------------------------------------------------------- def _draw_gizmo( self, renderer, vx: float, vy: float, vw: float, vh: float, ): """Draw the transform gizmo for the selected node.""" sel = self.state.selection.primary if sel is None or not isinstance(sel, Node3D): return gizmo = self.state.gizmo origin = sel.world_position axis_len = self._gizmo_screen_length(origin, vx, vy, vw, vh) gizmo.axis_length = axis_len mode = gizmo.mode if mode is GizmoMode.TRANSLATE: self._draw_gizmo_translate(renderer, origin, axis_len, vx, vy, vw, vh) elif mode is GizmoMode.ROTATE: self._draw_gizmo_rotate(renderer, origin, axis_len, vx, vy, vw, vh) elif mode is GizmoMode.SCALE: self._draw_gizmo_scale(renderer, origin, axis_len, vx, vy, vw, vh) # Mode label near gizmo origin origin_2d = self._project_point(origin, vx, vy, vw, vh) if origin_2d is not None: label = mode.name.capitalize() renderer.draw_text_coloured( label, origin_2d[0] + 12, origin_2d[1] - 18, 0.6, (0.85, 0.85, 0.85, 0.6)) def _axis_colour(self, axis: GizmoAxis) -> tuple: """Return the draw colour for an axis, brightened if hovered/dragging.""" if (self._hovered_axis is axis or self.state.gizmo._drag_axis is axis): return _AXIS_HIGHLIGHT.get(axis, (1, 1, 1, 1)) return _AXIS_COLOURS.get(axis, (0.7, 0.7, 0.7, 1)) def _draw_gizmo_translate( self, renderer, origin: Vec3, axis_len: float, vx: float, vy: float, vw: float, vh: float, ): """Draw translation gizmo: three thick arrows with filled arrowheads.""" axes = [ (GizmoAxis.X, Vec3(1, 0, 0)), (GizmoAxis.Y, Vec3(0, 1, 0)), (GizmoAxis.Z, Vec3(0, 0, 1)), ] for axis_enum, axis_dir in axes: seg = self._project_direction( origin, axis_dir, axis_len, vx, vy, vw, vh) if seg is None: continue (x0, y0), (x1, y1) = seg colour = self._axis_colour(axis_enum) # Thick shaft (2px) renderer.draw_thick_line_coloured(x0, y0, x1, y1, 2.0, colour) # Filled triangle arrowhead dx = x1 - x0 dy = y1 - y0 ln = math.sqrt(dx * dx + dy * dy) if ln < 1: continue ndx, ndy = dx / ln, dy / ln head = 10.0 perp_x, perp_y = -ndy, ndx renderer.draw_filled_triangle( x1, y1, x1 - ndx * head + perp_x * head * 0.4, y1 - ndy * head + perp_y * head * 0.4, x1 - ndx * head - perp_x * head * 0.4, y1 - ndy * head - perp_y * head * 0.4, colour) def _draw_gizmo_rotate( self, renderer, origin: Vec3, radius: float, vx: float, vy: float, vw: float, vh: float, ): """Draw rotation gizmo: three thick circles around principal axes.""" segments = 48 axes = [ (GizmoAxis.X, Vec3(0, 1, 0), Vec3(0, 0, 1)), (GizmoAxis.Y, Vec3(1, 0, 0), Vec3(0, 0, 1)), (GizmoAxis.Z, Vec3(1, 0, 0), Vec3(0, 1, 0)), ] for axis_enum, u_dir, v_dir in axes: colour = self._axis_colour(axis_enum) prev_pt = None for i in range(segments + 1): angle = (2 * math.pi * i) / segments cos_a = math.cos(angle) sin_a = math.sin(angle) world_pt = Vec3( origin.x + (u_dir.x * cos_a + v_dir.x * sin_a) * radius, origin.y + (u_dir.y * cos_a + v_dir.y * sin_a) * radius, origin.z + (u_dir.z * cos_a + v_dir.z * sin_a) * radius, ) sp = self._project_point(world_pt, vx, vy, vw, vh) if sp is not None and prev_pt is not None: renderer.draw_thick_line_coloured( prev_pt[0], prev_pt[1], sp[0], sp[1], 2.0, colour) prev_pt = sp def _draw_gizmo_scale( self, renderer, origin: Vec3, axis_len: float, vx: float, vy: float, vw: float, vh: float, ): """Draw scale gizmo: three thick lines with filled squares at ends.""" axes = [ (GizmoAxis.X, Vec3(1, 0, 0)), (GizmoAxis.Y, Vec3(0, 1, 0)), (GizmoAxis.Z, Vec3(0, 0, 1)), ] box_half = 5 for axis_enum, axis_dir in axes: seg = self._project_direction( origin, axis_dir, axis_len, vx, vy, vw, vh) if seg is None: continue (x0, y0), (x1, y1) = seg colour = self._axis_colour(axis_enum) renderer.draw_thick_line_coloured(x0, y0, x1, y1, 2.0, colour) renderer.draw_filled_rect( x1 - box_half, y1 - box_half, box_half * 2, box_half * 2, colour) # -- view settings overlay ----------------------------------------------- def _draw_view_overlay( self, renderer, vx: float, vy: float, vw: float, vh: float, ): """Draw view mode selector buttons in the top-left corner.""" buttons = self._overlay_button_rects() current_mode = self.state.view_mode_3d grid_on = self.state.show_grid_3d # Background panel total_w = buttons[-1][0] + buttons[-1][2] - buttons[0][0] + _OVERLAY_PAD * 2 renderer.draw_filled_rect( buttons[0][0] - _OVERLAY_PAD, buttons[0][1] - _OVERLAY_PAD, total_w, _OVERLAY_BTN_H + _OVERLAY_PAD * 2, _OVERLAY_BG) labels = ["Solid", "Wire", "Bbox", "Grid"] for i, (bx, by, bw, bh, mode) in enumerate(buttons): is_active = (mode == current_mode) if mode != "_grid" else grid_on 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_filled_rect(bx, by, bw, bh, bg) label = labels[i] # Center text in button tw = renderer.text_width(label, _OVERLAY_FONT_SCALE) tx = bx + (bw - tw) * 0.5 ty = by + (_OVERLAY_BTN_H - 14 * _OVERLAY_FONT_SCALE) * 0.5 renderer.draw_text_coloured(label, tx, ty, _OVERLAY_FONT_SCALE, tc) # -- axis indicator ------------------------------------------------------ def _draw_axis_indicator( self, renderer, vx: float, vy: float, vw: float, vh: float, ): """Draw a small RGB XYZ orientation gizmo in the bottom-left corner.""" cam = self.state.editor_camera # Compute rotation-only view (no translation) yaw_rad = cam.yaw pitch_rad = cam.pitch # Screen-space axis endpoints derived from camera orientation cx = vx + _AXIS_INDICATOR_MARGIN + _AXIS_INDICATOR_SIZE cy = vy + vh - _AXIS_INDICATOR_MARGIN - _AXIS_INDICATOR_SIZE axes = [ (Vec3(1, 0, 0), (0.90, 0.20, 0.20, 1.0), "X"), (Vec3(0, 1, 0), (0.25, 0.80, 0.20, 1.0), "Y"), (Vec3(0, 0, 1), (0.20, 0.40, 0.95, 1.0), "Z"), ] # Apply camera yaw/pitch to each axis direction cos_y = math.cos(yaw_rad) sin_y = math.sin(yaw_rad) cos_p = math.cos(pitch_rad) sin_p = math.sin(pitch_rad) for world_dir, colour, label in axes: # Rotate by yaw around Y rx = world_dir.x * cos_y + world_dir.z * sin_y rz = -world_dir.x * sin_y + world_dir.z * cos_y ry = world_dir.y # Rotate by pitch around X (screen-relative) final_x = rx final_y = ry * cos_p - rz * sin_p ry * sin_p + rz * cos_p # Project to 2D (orthographic, ignore depth) sx = cx + final_x * _AXIS_INDICATOR_SIZE sy = cy - final_y * _AXIS_INDICATOR_SIZE renderer.draw_line_coloured(cx, cy, sx, sy, colour) # Label at tip renderer.draw_text_coloured( label, sx + 3, sy - 5, 0.55, colour) # Background circle renderer.draw_rect_coloured( cx - _AXIS_INDICATOR_SIZE - 4, cy - _AXIS_INDICATOR_SIZE - 4, (_AXIS_INDICATOR_SIZE + 4) * 2, (_AXIS_INDICATOR_SIZE + 4) * 2, (0.12, 0.12, 0.14, 0.4)) # -- view info overlay --------------------------------------------------- def _draw_view_info( self, renderer, vx: float, vy: float, vw: float, vh: float, ): """Draw camera position / rotation info in the top-right corner.""" cam = self.state.editor_camera pos = cam.position pivot = cam.pivot lines = [ f"Pos: ({pos.x:.1f}, {pos.y:.1f}, {pos.z:.1f})", f"Target: ({pivot.x:.1f}, {pivot.y:.1f}, {pivot.z:.1f})", f"Dist: {cam.distance:.1f}", f"Yaw: {math.degrees(cam.yaw):.1f} Pitch: {math.degrees(cam.pitch):.1f}", ] # Selection info sel = self.state.selection.primary if sel is not None and isinstance(sel, Node3D): sp = sel.world_position lines.append(f"Sel: {sel.name}") lines.append(f" Pos: ({sp.x:.2f}, {sp.y:.2f}, {sp.z:.2f})") lines.append(f" Mode: {self.state.gizmo.mode.name}") right_margin = 10 line_height = 14 y_start = vy + 8 for i, text in enumerate(lines): tw = len(text) * 6 # rough text width estimate tx = vx + vw - tw - right_margin ty = y_start + i * line_height col = _INFO_LABEL_COLOUR if text.startswith(" ") else _INFO_COLOUR renderer.draw_text_coloured(text, tx, ty, _INFO_FONT_SCALE, col) # Gizmo mode shortcuts hint (bottom left) hint = "[W] Translate [E] Rotate [R] Scale [F] Focus | Alt+LMB Orbit Alt+Shift+LMB Pan" renderer.draw_text_coloured( hint, vx + 10, vy + vh - 18, 0.55, (0.40, 0.40, 0.42, 0.65)) # ====================================================================== # Public API # ======================================================================
[docs] def handle_scroll(self, delta: float): """External scroll event forwarding (e.g. from GLFW callback).""" self._handle_scroll(delta)
[docs] def set_viewport_size(self, width: float, height: float): """Resize the viewport panel.""" self.size = Vec2(width, height)