"""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 set_viewport_size(self, width: float, height: float):
"""Resize the viewport panel."""
self.size = Vec2(width, height)