Source code for simvx.editor.panels.scene3d_view

"""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 (
    Control,
    GizmoAxis,
    GizmoMode,
    MeshInstance3D,
    Node3D,
    PropertyCommand,
    Quat,
    Vec2,
    Vec3,
    ray_intersect_sphere,
    screen_to_ray,
)

if TYPE_CHECKING:
    from ..state import State

log = logging.getLogger(__name__)

from enum import StrEnum

from simvx.core.input import MouseButton


[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 def _module_constants(): """Return this module so composition helpers can read its tuned constants (grid spacing, axis colours, overlay/info styling) by attribute access.""" import sys return sys.modules[__name__] # --------------------------------------------------------------------------- # Scene3DView # ---------------------------------------------------------------------------
[docs] class Scene3DView(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: State, **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 # ---- composition helpers (own cohesive responsibility groups) ----- # Each is given this panel via DI so it reads/writes the panel's # shared state (cached matrices, camera, hover axis) instead of # duplicating it. The panel keeps every test-visible / cross-module # method below as a thin delegator to one of these. from . import _scene3d_helpers as _h self._helpers = _h self._projector = _h._Scene3DProjector(self) self._grid_renderer = _h._Scene3DGridRenderer(self, _module_constants()) self._scene_renderer = _h._Scene3DSceneRenderer(self, _module_constants()) # ====================================================================== # Projection helpers (delegate to ``_projector``) # ====================================================================== 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. """ self._projector.build_matrices(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. """ return self._projector.project_point(world_pos, vx, vy, vw, vh) 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. """ return self._projector.project_direction(origin, direction, axis_length, vx, vy, vw, vh) 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. Returns ((sx0, sy0), (sx1, sy1)) or None if fully behind camera. """ return self._projector.project_line(world_p0, world_p1, vx, vy, vw, vh) # ====================================================================== # 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 == MouseButton.LEFT: 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 is not None: # Update game's mouse_pos before the button event so the # forwarded click lands at the cursor's current location. play_mode.forward_input_to_game( "mouse_move", x=rel_x, y=rel_y, viewport_size=(vw, vh), ) play_mode.forward_input_to_game( "mouse_button", button=event.button, pressed=event.pressed, ) elif event.button is None and not event.key: play_mode.forward_input_to_game( "mouse_move", x=rel_x, y=rel_y, viewport_size=(vw, vh), ) if event.key == "scroll_up": play_mode.forward_input_to_game("scroll", dx=0.0, dy=1.0) elif event.key == "scroll_down": play_mode.forward_input_to_game("scroll", dx=0.0, dy=-1.0) elif event.key and event.key not in ("scroll_up", "scroll_down"): # 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 == MouseButton.LEFT: if self._handle_overlay_click(mx, my): return # ---- mouse press --------------------------------------------------- if event.pressed: if event.button == MouseButton.MIDDLE: # 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 == MouseButton.RIGHT: # right button self._last_mouse = (mx, my) self._is_right_orbiting = True elif event.button == MouseButton.LEFT: # 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 == MouseButton.MIDDLE: self._is_orbiting = False self._is_panning = False elif event.button == MouseButton.RIGHT: self._is_right_orbiting = False elif event.button == MouseButton.LEFT: 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 is None: 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. """ return self._projector.gizmo_screen_length(world_origin, vx, vy, vw, vh) 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 GameRenderHook. 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 on_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 on_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: # Keep the offscreen game target sized to the viewport rect so a # window/splitter resize during play updates the rendered image. play_mode.ensure_game_viewport_size(int(vw), int(vh)) game_tex = getattr(play_mode, "game_texture_id", None) if game_tex is not None and game_tex >= 0: # 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) # Submission order is the GPU order: overlay draws after the grid. 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, vy, vw, vh): """Draw an XZ-plane ground grid centred on the camera target.""" self._grid_renderer.draw_grid(renderer, vx, vy, vw, vh) def _draw_grid_lines(self, renderer, center_x, center_z, extent, step, colour, vx, vy, vw, vh): """Render a set of grid lines at a given spacing.""" self._grid_renderer._draw_grid_lines(renderer, center_x, center_z, extent, step, colour, vx, vy, vw, vh) # -- scene objects ------------------------------------------------------- def _draw_scene_objects(self, renderer, vx, vy, vw, vh): """Draw representations of all 3D nodes based on view mode.""" self._scene_renderer.draw_scene_objects(renderer, vx, vy, vw, vh) def _mesh_colour(self, node: MeshInstance3D) -> tuple: """Get material-derived colour for a mesh node.""" return self._scene_renderer.mesh_colour(node) # -- 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.""" return self._scene_renderer.project_mesh_verts(mesh, node, vx, vy, vw, vh) @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, view_mode, vx, vy, vw, vh): """Draw a MeshInstance3D using projected mesh geometry.""" self._scene_renderer.draw_mesh_object(renderer, node, view_mode, vx, vy, vw, vh) def _draw_node3d_icon(self, renderer, node, vx, vy, vw, vh): """Draw a small axis cross for a plain Node3D.""" self._scene_renderer.draw_node3d_icon(renderer, node, vx, vy, vw, vh) def _draw_light_icon(self, renderer, node, vx, vy, vw, vh): """Draw a 3D light representation.""" self._scene_renderer.draw_light_icon(renderer, node, vx, vy, vw, vh) def _draw_camera_icon(self, renderer, node, vx, vy, vw, vh): """Draw a 3D camera frustum wireframe.""" self._scene_renderer.draw_camera_icon(renderer, node, vx, vy, vw, vh) # -- node gizmos (collision shapes, paths, raycasts, etc.) ---------------- def _draw_node_gizmos(self, renderer, vx, vy, vw, vh): """Draw gizmo wireframes for all nodes that implement get_gizmo_lines().""" self._scene_renderer.draw_node_gizmos(renderer, vx, vy, vw, vh) # -- selection highlight ------------------------------------------------- def _draw_selection_highlight(self, renderer, vx, vy, vw, vh): """Draw a fixed-size selection indicator at each selected node's center.""" self._scene_renderer.draw_selection_highlight(renderer, vx, vy, vw, vh) # -- gizmo --------------------------------------------------------------- def _draw_gizmo(self, renderer, vx, vy, vw, vh): """Draw the transform gizmo for the selected node.""" self._scene_renderer.draw_gizmo(renderer, vx, vy, vw, vh) def _axis_colour(self, axis: GizmoAxis) -> tuple: """Return the draw colour for an axis, brightened if hovered/dragging.""" return self._scene_renderer.axis_colour(axis) def _draw_gizmo_translate(self, renderer, origin, axis_len, vx, vy, vw, vh): """Draw translation gizmo.""" self._scene_renderer.draw_gizmo_translate(renderer, origin, axis_len, vx, vy, vw, vh) def _draw_gizmo_rotate(self, renderer, origin, radius, vx, vy, vw, vh): """Draw rotation gizmo.""" self._scene_renderer.draw_gizmo_rotate(renderer, origin, radius, vx, vy, vw, vh) def _draw_gizmo_scale(self, renderer, origin, axis_len, vx, vy, vw, vh): """Draw scale gizmo.""" self._scene_renderer.draw_gizmo_scale(renderer, origin, axis_len, vx, vy, vw, vh) # -- 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.""" self._scene_renderer.draw_collision_overlays(renderer, vx, vy, vw, vh) def _draw_camera_frustum_overlays(self, renderer, vx, vy, vw, vh): """Draw frustum wireframes for all Camera3D nodes in the scene.""" self._scene_renderer.draw_camera_frustum_overlays(renderer, 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).""" self._scene_renderer.draw_camera_frustum_wireframe(renderer, node, vx, vy, vw, vh) def _draw_light_radius_overlays(self, renderer, vx, vy, vw, vh): """Draw range spheres for PointLight3D and cone for SpotLight3D.""" self._scene_renderer.draw_light_radius_overlays(renderer, vx, vy, vw, vh) def _draw_nav_mesh_overlays(self, renderer, vx, vy, vw, vh): """Draw navigation mesh wireframes if any nav-mesh data exists.""" self._scene_renderer.draw_nav_mesh_overlays(renderer, vx, vy, vw, vh) # -- 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, vy, vw, vh): """Draw a small RGB XYZ orientation gizmo in the bottom-left corner.""" self._grid_renderer.draw_axis_indicator(renderer, vx, vy, vw, vh) # -- view info overlay --------------------------------------------------- def _draw_view_info(self, renderer, vx, vy, vw, vh): """Draw camera position / rotation info in the top-right corner.""" self._scene_renderer.draw_view_info(renderer, vx, vy, vw, vh)
[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)