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