Source code for simvx.editor.panels.viewport3d

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

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

import logging
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

log = logging.getLogger(__name__)

from enum import StrEnum


[docs] class ViewMode(StrEnum): """3D viewport shading / display mode.""" SOLID = "solid" WIREFRAME = "wireframe" BOUNDING = "bounding" TEXTURED = "textured"
# --------------------------------------------------------------------------- # 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 # Frames the offscreen target size must be stable before we recreate GPU # resources. At 60fps this is ~100ms — imperceptible to the user but keeps # a user-driven window drag from triggering continuous Vulkan resource churn. _RESIZE_DEBOUNCE_FRAMES = 6 _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) # ---- edit-mode textured viewport (offscreen render-to-texture) ---- self._edit_viewport = None # GameViewportRenderer | None self._edit_viewport_size: tuple[int, int] = (0, 0) # Debounce GPU target resize: recreating VkImage + Draw2DPass pipeline # every frame of a user drag tanks frame rate. Wait until the target # size has been stable for N frames before resizing the offscreen # target. The existing texture is stretched via draw_texture during # the drag — visually acceptable. self._edit_viewport_pending_size: tuple[int, int] = (0, 0) self._edit_viewport_stable_frames: int = 0 # ====================================================================== # Projection helpers # ====================================================================== def _build_matrices(self, vx: float, vy: float, vw: float, vh: float): """Recompute and cache view / projection / VP matrices. During play mode, uses the game's camera (if available) so the viewport shows the player's perspective. """ play_mode = getattr(self.state, 'play_mode', None) if play_mode is not None and self.state.is_playing: cam = play_mode.get_active_camera() or self.state.editor_camera else: 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 if v == "textured": return ViewMode.TEXTURED 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. Includes view mode buttons, grid toggle, and debug overlay toggles (collision shapes, camera frustums, light radius, nav mesh). """ vx, vy, _, _ = self.get_global_rect() ox = vx + _OVERLAY_PAD oy = vy + _OVERLAY_PAD buttons = [] view_modes = [("Solid", "solid"), ("Wire", "wireframe"), ("Bbox", "bounding"), ("Tex", "textured")] for i, (_label, mode) in enumerate(view_modes): 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 + len(view_modes) * (_OVERLAY_BTN_W + _OVERLAY_BTN_GAP) + 6 buttons.append((gx, oy, _OVERLAY_BTN_W, _OVERLAY_BTN_H, "_grid")) # Debug overlay toggles on second row oy2 = oy + _OVERLAY_BTN_H + _OVERLAY_BTN_GAP debug_items = [ ("Coll", "_collision"), ("Cam", "_camera"), ("Light", "_light"), ("Nav", "_nav"), ] for i, (_label, mode) in enumerate(debug_items): bx = ox + i * (_OVERLAY_BTN_W + _OVERLAY_BTN_GAP) buttons.append((bx, oy2, _OVERLAY_BTN_W, _OVERLAY_BTN_H, mode)) 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 elif mode == "_collision": self.state.show_collision_shapes = not self.state.show_collision_shapes elif mode == "_camera": self.state.show_camera_frustums = not self.state.show_camera_frustums elif mode == "_light": self.state.show_light_radius = not self.state.show_light_radius elif mode == "_nav": self.state.show_nav_mesh = not self.state.show_nav_mesh 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. During play mode, input is forwarded to the game's isolated input state instead of being consumed by editor orbit/pan/zoom/selection. Only overlay button clicks (view mode toggles) pass through. """ 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 # ---- 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: # Overlay buttons always pass through (view mode, grid toggle, etc.) if event.pressed and event.button == 1: if self._handle_overlay_click(mx, my): return # Forward all other input to the game if play_mode.should_route_input_to_game(): vx, vy, vw, vh = self._viewport_rect if self._viewport_rect != (0, 0, 1, 1) else 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: 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"): # Forward keyboard to game (key names map to Key enum values) 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 # ---- 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 _is_ctrl_held(self) -> bool: """Check if Ctrl is currently held via the Input key state.""" from simvx.core import Input return Input._keys.get("ctrl", False) def _handle_left_press(self, mx: float, my: float): """Left-click: try gizmo pick first, then scene object pick. Supports Ctrl+Click for additive selection and Shift+Click for additive selection (same behaviour in viewport). """ 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_ctrl_held() or 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: if not self._is_ctrl_held(): 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 # ====================================================================== def _get_edit_texture(self, width: int, height: int) -> int | None: """Return a bindless texture ID for the edit-mode textured view. Lazily creates a GameViewportRenderer for the edited scene. The actual rendering is done via the pre_render hook in EditorShell. Returns None if the GPU renderer is not available. """ evp = self._edit_viewport if evp is not None and evp.ready: # Debounce: only resize once the target size has been stable for # a few frames. During a live drag we keep the existing texture. 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 # Try to create the edit viewport 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; 3D viewport falls back to wireframe") return None
[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_rect((vx, vy), (vw, vh), colour=self.bg_colour, filled=True) renderer.push_clip(vx, vy, vw, vh) # Rebuild camera matrices self._build_matrices(vx, vy, vw, vh) 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 to wireframe 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: # Render the game scene via the offscreen texture renderer.draw_texture(game_tex, vx, vy, vw, vh) else: # Fall back to wireframe rendering of the game tree if self.state.show_grid_3d: self._draw_grid(renderer, vx, vy, vw, vh) self._draw_scene_objects(renderer, vx, vy, vw, vh) else: # Edit mode: check for textured view via offscreen renderer view_mode = self._get_view_mode() edit_tex = self._get_edit_texture(int(vw), int(vh)) if view_mode is ViewMode.TEXTURED else None if edit_tex is not None and edit_tex >= 0: # GPU-rendered textured view renderer.draw_texture(edit_tex, vx, vy, vw, vh) else: # Standard wireframe/solid rendering via Draw2D 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) # Debug overlays if self.state.show_collision_shapes: self._draw_collision_overlays(renderer, vx, vy, vw, vh) if self.state.show_camera_frustums: self._draw_camera_frustum_overlays(renderer, vx, vy, vw, vh) if self.state.show_light_radius: self._draw_light_radius_overlays(renderer, vx, vy, vw, vh) if self.state.show_nav_mesh: self._draw_nav_mesh_overlays(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) # Play mode border overlay play_mode = getattr(self.state, 'play_mode', None) if self.state.is_playing and 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) # 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( (x_seg[0][0], x_seg[0][1]), (x_seg[1][0], x_seg[1][1]), colour=(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( (z_seg[0][0], z_seg[0][1]), (z_seg[1][0], z_seg[1][1]), colour=(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( (seg[0][0], seg[0][1]), (seg[1][0], seg[1][1]), colour=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( (seg[0][0], seg[0][1]), (seg[1][0], seg[1][1]), colour=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.""" # Use game tree during play mode, edited scene otherwise 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 view_mode = self._get_view_mode() # Determine which camera to skip (don't render the camera we're looking through) skip_cam = self.state.editor_camera if is_playing and play_mode is not None: game_cam = play_mode.get_active_camera() if game_cam is not None: skip_cam = game_cam for node in root.find_all(Node3D): if not node.visible: continue if node is skip_cam: 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 ------------------------------------------ _NEAR_W_EPS = 1e-4 # Clip plane: treat vertices with w <= this as behind camera def _project_mesh_verts(self, mesh, node: MeshInstance3D, vx, vy, vw, vh): """Batch-project mesh vertices. Returns (clip, sx, sy, valid) or None. ``clip`` is an (N, 4) array of homogeneous clip-space coordinates; ``sx``/``sy`` are screen-space coordinates (invalid where valid=False); ``valid[i]`` is True when clip[i, 3] > _NEAR_W_EPS (vertex in front of camera). """ 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] > self._NEAR_W_EPS 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 clip, sx, sy, valid @staticmethod def _clip_to_screen(clip, vx, vy, vw, vh): """Convert a single clip-space 4-vector to (screen_x, screen_y).""" w = clip[3] return ( vx + (clip[0] / w * 0.5 + 0.5) * vw, vy + (clip[1] / w * 0.5 + 0.5) * vh, ) @classmethod def _clip_triangle_near(cls, a, b, c): """Sutherland-Hodgman clip of a triangle against the near plane (w > eps). Returns 0, 3, or 4 clip-space vertices preserving the original winding. """ eps = cls._NEAR_W_EPS poly = (a, b, c) out = [] prev = poly[-1] prev_in = prev[3] > eps for curr in poly: curr_in = curr[3] > eps if curr_in != prev_in: t = (prev[3] - eps) / (prev[3] - curr[3]) out.append(prev + t * (curr - prev)) if curr_in: out.append(curr) prev = curr prev_in = curr_in return out @classmethod def _clip_edge_near(cls, a, b): """Clip a line segment (a, b) against the near plane. Returns (a', b') clip-space points of the visible segment, or None. """ eps = cls._NEAR_W_EPS a_in = a[3] > eps b_in = b[3] > eps if a_in and b_in: return a, b if not a_in and not b_in: return None t = (a[3] - eps) / (a[3] - b[3]) crossing = a + t * (b - a) return (crossing, b) if b_in else (a, crossing) @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 clip, sx, sy, valid = proj if view_mode in (ViewMode.SOLID, ViewMode.TEXTURED): # Fill mesh triangles (front-faces only via screen-space winding). # Triangles crossing the near plane are clipped in clip space so # they don't pop out entirely when the camera zooms past an edge. alpha = 0.85 if view_mode is ViewMode.TEXTURED else 0.35 fill_colour = (colour[0], colour[1], colour[2], alpha) 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]) n_in = int(valid[a]) + int(valid[b]) + int(valid[c]) if n_in == 0: continue if n_in == 3: # 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.fill_triangle( sx[a], sy[a], sx[b], sy[b], sx[c], sy[c], colour=fill_colour) continue # Partial — clip against the near plane poly = self._clip_triangle_near(clip[a], clip[b], clip[c]) if len(poly) < 3: continue screen = [self._clip_to_screen(p, vx, vy, vw, vh) for p in poly] # Backface cull using first fan triangle's winding p0 = screen[0] p1 = screen[1] p2 = screen[2] cross = ((p1[0] - p0[0]) * (p2[1] - p0[1]) - (p1[1] - p0[1]) * (p2[0] - p0[0])) if cross >= 0: continue # Fan-triangulate for k in range(1, len(screen) - 1): q1 = screen[k] q2 = screen[k + 1] renderer.fill_triangle( p0[0], p0[1], q1[0], q1[1], q2[0], q2[1], colour=fill_colour) if view_mode not in (ViewMode.BOUNDING, ViewMode.TEXTURED): # Draw actual mesh edges (subsampled for dense meshes). # Edges crossing the near plane are clipped to the visible segment. 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( float(sx[a]), float(sy[a]), float(sx[b]), float(sy[b]), lw, colour=colour) elif valid[a] or valid[b]: seg = self._clip_edge_near(clip[a], clip[b]) if seg is None: continue p0 = self._clip_to_screen(seg[0], vx, vy, vw, vh) p1 = self._clip_to_screen(seg[1], vx, vy, vw, vh) renderer.draw_thick_line(p0[0], p0[1], p1[0], p1[1], lw, colour=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((pa[0], pa[1]), (pb[0], pb[1]), colour=colour) # Center dot + name center_p = self._project_point(node.world_position, vx, vy, vw, vh) if center_p: renderer.draw_circle((center_p[0], center_p[1]), 3.5, colour=colour, filled=True) renderer.draw_text( node.name, (center_p[0] + 6, center_p[1] - 5), colour=(0.75, 0.75, 0.75, 0.70), scale=0.55) 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(p[0] - sz, p[1], p[0] + sz, p[1], 2.0, colour=(0.90, 0.35, 0.35, 0.80)) renderer.draw_thick_line(p[0], p[1] - sz, p[0], p[1] + sz, 2.0, colour=(0.35, 0.80, 0.35, 0.80)) renderer.draw_circle((p[0], p[1]), 3.0, colour=(0.85, 0.85, 0.85, 0.80), filled=True) renderer.draw_text( node.name, (p[0] + sz + 4, p[1] - 5), colour=(0.70, 0.70, 0.70, 0.75), scale=0.55 ) 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_circle((p[0], p[1]), 8, colour=fill_c, filled=True) renderer.draw_circle((p[0], p[1]), 4, colour=c, filled=True) # 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(ix, iy, ox, oy, 2.0, colour=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(p[0], p[1], fp[0], fp[1], 2.5, colour=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.fill_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, colour=c) renderer.draw_text( node.name, (p[0] + ray_outer + 4, p[1] - 5), colour=(0.90, 0.85, 0.40, 0.75), scale=0.55) 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(pn[i][0], pn[i][1], pn[j][0], pn[j][1], 2.0, colour=c) # Far rect for i in range(4): j = (i + 1) % 4 if pf[i] and pf[j]: renderer.draw_thick_line(pf[i][0], pf[i][1], pf[j][0], pf[j][1], 2.0, colour=c) # Connecting edges (near to far) for i in range(4): if pn[i] and pf[i]: renderer.draw_thick_line(pn[i][0], pn[i][1], pf[i][0], pf[i][1], 1.5, colour=c) # Fill near face if all(pn): renderer.fill_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], colour=fill_c) # Camera body (small filled rect behind near plane) renderer.draw_circle((p[0], p[1]), 4, colour=c, filled=True) renderer.draw_text( node.name, (p[0] + 8, p[1] - 5), colour=(0.65, 0.75, 0.95, 0.75), scale=0.55) # -- 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((seg[0][0], seg[0][1]), (seg[1][0], seg[1][1]), colour=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(cx - arm, cy, cx + arm, cy, 1.5, colour=c) renderer.draw_thick_line(cx, cy - arm, cx, cy + arm, 1.5, colour=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( cx + r * math.cos(a0), cy + r * math.sin(a0), cx + r * math.cos(a1), cy + r * math.sin(a1), 1.5, colour=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( label, (origin_2d[0] + 12, origin_2d[1] - 18), colour=(0.85, 0.85, 0.85, 0.6), scale=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(x0, y0, x1, y1, 2.0, colour=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.fill_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=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( prev_pt[0], prev_pt[1], sp[0], sp[1], 2.0, colour=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(x0, y0, x1, y1, 2.0, colour=colour) renderer.draw_rect( (x1 - box_half, y1 - box_half), (box_half * 2, box_half * 2), colour=colour, filled=True) # -- debug overlays ------------------------------------------------------ _COLLISION_COLOUR = (0.2, 0.9, 0.2, 0.5) _CAMERA_FRUSTUM_COLOUR = (0.5, 0.7, 1.0, 0.5) _LIGHT_RADIUS_COLOUR = (1.0, 0.85, 0.3, 0.4) _NAV_MESH_COLOUR = (0.3, 0.6, 1.0, 0.4) def _draw_collision_overlays(self, renderer, vx, vy, vw, vh): """Draw collision shape wireframes for all CollisionShape3D nodes.""" from simvx.core import CollisionShape3D root = self.state.edited_scene.root if self.state.edited_scene else None if root is None: return for node in root.find_all(CollisionShape3D): if not node.visible: continue lines = node.get_gizmo_lines() for p0, p1 in lines: seg = self._project_line(p0, p1, vx, vy, vw, vh) if seg: renderer.draw_thick_line( seg[0][0], seg[0][1], seg[1][0], seg[1][1], 1.5, colour=self._COLLISION_COLOUR) def _draw_camera_frustum_overlays(self, renderer, vx, vy, vw, vh): """Draw frustum wireframes for all Camera3D nodes in the scene.""" root = self.state.edited_scene.root if self.state.edited_scene else None if root is None: return for node in root.find_all(Camera3D): if not node.visible or node is self.state.editor_camera: continue self._draw_camera_frustum_wireframe(renderer, node, vx, vy, vw, vh) def _draw_camera_frustum_wireframe(self, renderer, node, vx, vy, vw, vh): """Draw a camera's full frustum (near + far planes + connecting lines).""" pos = node.world_position 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) if rn < 1e-6: return right = Vec3(*(right / rn)) up = Vec3(*np.cross(right, fwd)) fov = math.radians(getattr(node, 'fov', 60.0)) near_d = getattr(node, 'near', 0.1) far_d = min(getattr(node, 'far', 100.0), 20.0) # Cap at 20 for visibility aspect = 16.0 / 9.0 near_h = near_d * math.tan(fov * 0.5) near_w = near_h * aspect far_h = far_d * math.tan(fov * 0.5) far_w = far_h * aspect near_c = pos + fwd * near_d far_c = pos + fwd * far_d nc = [near_c + right * s1 * near_w + up * s2 * near_h for s1, s2 in [(-1, -1), (1, -1), (1, 1), (-1, 1)]] fc = [far_c + right * s1 * far_w + up * s2 * far_h for s1, s2 in [(-1, -1), (1, -1), (1, 1), (-1, 1)]] c = self._CAMERA_FRUSTUM_COLOUR # Near rect for i in range(4): j = (i + 1) % 4 seg = self._project_line(nc[i], nc[j], vx, vy, vw, vh) if seg: renderer.draw_thick_line(seg[0][0], seg[0][1], seg[1][0], seg[1][1], 1.5, colour=c) # Far rect for i in range(4): j = (i + 1) % 4 seg = self._project_line(fc[i], fc[j], vx, vy, vw, vh) if seg: renderer.draw_thick_line(seg[0][0], seg[0][1], seg[1][0], seg[1][1], 1.5, colour=c) # Connecting edges for i in range(4): seg = self._project_line(nc[i], fc[i], vx, vy, vw, vh) if seg: renderer.draw_thick_line(seg[0][0], seg[0][1], seg[1][0], seg[1][1], 1.0, colour=c) def _draw_light_radius_overlays(self, renderer, vx, vy, vw, vh): """Draw range spheres for PointLight3D and cone for SpotLight3D.""" from simvx.core import PointLight3D, SpotLight3D root = self.state.edited_scene.root if self.state.edited_scene else None if root is None: return c = self._LIGHT_RADIUS_COLOUR for node in root.find_all(Light3D): if not node.visible: continue gizmo_fn = getattr(node, 'get_gizmo_lines', None) if gizmo_fn is None: continue if not isinstance(node, (PointLight3D, SpotLight3D)): continue lines = gizmo_fn() for p0, p1 in lines: seg = self._project_line(p0, p1, vx, vy, vw, vh) if seg: renderer.draw_thick_line( seg[0][0], seg[0][1], seg[1][0], seg[1][1], 1.5, colour=c) def _draw_nav_mesh_overlays(self, renderer, vx, vy, vw, vh): """Draw navigation mesh wireframes if any NavigationAgent3D nodes exist.""" root = self.state.edited_scene.root if self.state.edited_scene else None if root is None: return c = self._NAV_MESH_COLOUR # Look for any node with navigation mesh data for node in root.find_all(Node3D): if not node.visible: continue nav_mesh = getattr(node, 'navigation_mesh', None) if nav_mesh is None: continue verts = getattr(nav_mesh, 'vertices', None) tris = getattr(nav_mesh, 'triangles', None) or getattr(nav_mesh, 'indices', None) if verts is None or tris is None: continue # Draw triangle edges for i in range(0, len(tris) - 2, 3): a, b, cc_idx = int(tris[i]), int(tris[i + 1]), int(tris[i + 2]) for v0, v1 in [(a, b), (b, cc_idx), (cc_idx, a)]: if v0 < len(verts) and v1 < len(verts): p0 = Vec3(float(verts[v0][0]), float(verts[v0][1]), float(verts[v0][2])) p1 = Vec3(float(verts[v1][0]), float(verts[v1][1]), float(verts[v1][2])) seg = self._project_line(p0, p1, vx, vy, vw, vh) if seg: renderer.draw_thick_line( seg[0][0], seg[0][1], seg[1][0], seg[1][1], 1.0, colour=c) # -- view settings overlay ----------------------------------------------- def _draw_view_overlay( self, renderer, vx: float, vy: float, vw: float, vh: float, ): """Draw view mode selector and debug overlay toggle buttons.""" buttons = self._overlay_button_rects() current_mode = self.state.view_mode_3d # Background panel spanning both rows first = buttons[0] last = buttons[-1] total_w = last[0] + last[2] - first[0] + _OVERLAY_PAD * 2 total_h = last[1] + last[3] - first[1] + _OVERLAY_PAD * 2 renderer.draw_rect( (first[0] - _OVERLAY_PAD, first[1] - _OVERLAY_PAD), (total_w, total_h), colour=_OVERLAY_BG, filled=True) labels = ["Solid", "Wire", "Bbox", "Tex", "Grid", "Coll", "Cam", "Light", "Nav"] toggle_states = { "_grid": self.state.show_grid_3d, "_collision": self.state.show_collision_shapes, "_camera": self.state.show_camera_frustums, "_light": self.state.show_light_radius, "_nav": self.state.show_nav_mesh, } for i, (bx, by, bw, bh, mode) in enumerate(buttons): if mode in toggle_states: is_active = toggle_states[mode] else: is_active = mode == current_mode 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 = labels[i] if i < len(labels) else mode 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(label, (tx, ty), colour=tc, scale=_OVERLAY_FONT_SCALE) # -- 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((cx, cy), (sx, sy), colour=colour) # Label at tip renderer.draw_text(label, (sx + 3, sy - 5), colour=colour, scale=0.55) # Background circle renderer.draw_rect( (cx - _AXIS_INDICATOR_SIZE - 4, cy - _AXIS_INDICATOR_SIZE - 4), ((_AXIS_INDICATOR_SIZE + 4) * 2, (_AXIS_INDICATOR_SIZE + 4) * 2), colour=(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.""" # Show the active camera during play (game camera), editor camera otherwise. # Game cameras are plain Camera3D subclasses without orbit state, so # only position is guaranteed. play_mode = getattr(self.state, 'play_mode', None) if play_mode is not None and self.state.is_playing: cam = play_mode.get_active_camera() or self.state.editor_camera else: cam = self.state.editor_camera pos = cam.position lines = [f"Pos: ({pos.x:.1f}, {pos.y:.1f}, {pos.z:.1f})"] pivot = getattr(cam, "pivot", None) if pivot is not None: lines.append(f"Target: ({pivot.x:.1f}, {pivot.y:.1f}, {pivot.z:.1f})") lines.append(f"Dist: {cam.distance:.1f}") lines.append(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(text, (tx, ty), colour=col, scale=_INFO_FONT_SCALE) # 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( hint, (vx + 10, vy + vh - 18), colour=(0.40, 0.40, 0.42, 0.65), scale=0.55) # ====================================================================== # 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)