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