Source code for simvx.core.nodes_3d.camera

"""Camera3D, OrbitCamera3D, EditorCamera3D -- 3D camera nodes."""

from __future__ import annotations

import math

import numpy as np

from ..descriptors import Property
from ..math.matrices import look_at, perspective
from ..math.types import Vec3
from .node3d import Node3D


[docs] class Camera3D(Node3D): """3D perspective camera providing view and projection matrices. The first ``Camera3D`` found in the scene tree is used by the renderer. Position and orientation are inherited from ``Node3D``; the camera adds projection parameters (field of view, clip planes). Attributes: fov: Vertical field of view in degrees (1 -- 179). near: Near clip plane distance. far: Far clip plane distance. Example:: camera = Camera3D(position=(0, 5, 10), name="MainCamera") camera.look_at((0, 0, 0)) camera.fov = 75.0 """ fov = Property(60.0, range=(1, 179), hint="Field of view in degrees", group="Camera") near = Property(0.1, range=(0.001, 100), group="Camera") far = Property(100.0, range=(1, 100000), group="Camera") cull_mask = Property( 0xFFFFFFFF, range=(0, 0xFFFFFFFF), hint="Cull mask bitmask (32 layers, all visible by default)" ) gizmo_colour = Property((0.8, 0.8, 0.8, 0.5), hint="Editor gizmo colour")
[docs] def set_cull_mask_layer(self, index: int, enabled: bool = True) -> None: """Enable or disable a specific cull mask layer (0-31).""" if not 0 <= index < 32: raise ValueError(f"Cull mask layer index must be 0-31, got {index}") if enabled: self.cull_mask = self.cull_mask | (1 << index) else: self.cull_mask = self.cull_mask & ~(1 << index)
[docs] def is_cull_mask_layer_enabled(self, index: int) -> bool: """Check if a specific cull mask layer is enabled (0-31).""" if not 0 <= index < 32: raise ValueError(f"Cull mask layer index must be 0-31, got {index}") return bool(self.cull_mask & (1 << index))
[docs] def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]: """Return frustum wireframe lines based on fov, near, far.""" pos = self.world_position fwd = self.forward up_hint = Vec3(0, 1, 0) right_raw = np.cross(fwd, up_hint) rn = np.linalg.norm(right_raw) if rn < 1e-6: right_raw = np.cross(fwd, Vec3(0, 0, 1)) rn = np.linalg.norm(right_raw) right = Vec3(*(right_raw / rn)) up = Vec3(*np.cross(right, fwd)) # Use near=0.5 and far=3.0 as visual proxy (not actual clip planes) nd, fd = 0.5, min(3.0, float(self.far)) aspect = 16.0 / 9.0 half_fov = math.radians(float(self.fov) * 0.5) nh = nd * math.tan(half_fov) nw = nh * aspect fh = fd * math.tan(half_fov) fw = fh * aspect nc = pos + fwd * nd fc = pos + fwd * fd near_corners = [ nc + right * s1 * nw + up * s2 * nh for s1, s2 in [(-1, -1), (1, -1), (1, 1), (-1, 1)] ] far_corners = [ fc + right * s1 * fw + up * s2 * fh for s1, s2 in [(-1, -1), (1, -1), (1, 1), (-1, 1)] ] lines: list[tuple[Vec3, Vec3]] = [] # Near rect for i in range(4): lines.append((near_corners[i], near_corners[(i + 1) % 4])) # Far rect for i in range(4): lines.append((far_corners[i], far_corners[(i + 1) % 4])) # Connecting edges for i in range(4): lines.append((near_corners[i], far_corners[i])) return lines
@property def view_matrix(self) -> np.ndarray: """View matrix computed from this node's global transform. Returns: 4x4 view matrix as numpy array (row-major) """ eye = np.array(self.world_position, dtype=np.float32) center = eye + np.array(self.forward, dtype=np.float32) up = np.array(self.up, dtype=np.float32) return look_at(eye, center, up)
[docs] def projection_matrix(self, aspect: float = 16 / 9) -> np.ndarray: """Perspective projection matrix for given aspect ratio (Vulkan clip space). Args: aspect: Aspect ratio (width / height) Returns: 4x4 projection matrix as numpy array (row-major) Includes Y-flip for Vulkan rendering """ proj = perspective( np.radians(self.fov), aspect, self.near, self.far ) proj[1, 1] *= -1 # Flip Y-axis for Vulkan return proj
[docs] class OrbitCamera3D(Camera3D): """General-purpose orbit camera with pan and zoom controls.""" def __init__(self, **kwargs): super().__init__(**kwargs) self.pivot = Vec3() self.distance = 20.0 self.yaw = math.radians(45.0) # radians, angled 3/4 view self.pitch = math.radians(-30.0) # radians, looking slightly down self._update_transform()
[docs] def orbit(self, dyaw: float, dpitch: float): """Orbit the camera around its pivot point by yaw and pitch deltas (radians).""" self.yaw += dyaw self.pitch = max(math.radians(-89.9), min(math.radians(89.9), self.pitch + dpitch)) self._update_transform()
[docs] def pan(self, dx: float, dz: float): """Pan the camera pivot horizontally in the XZ plane.""" right = Vec3(math.cos(self.yaw), 0, math.sin(self.yaw)) forward = Vec3(-math.sin(self.yaw), 0, math.cos(self.yaw)) self.pivot += right * dx + forward * dz self._update_transform()
[docs] def zoom(self, delta: float): """Zoom the camera by adjusting its distance to the pivot.""" self.distance = max(1.0, self.distance - delta) self._update_transform()
def _update_transform(self): cp = math.cos(self.pitch) # Negate pitch so negative pitch = camera above pivot (looking down) offset = Vec3( cp * math.sin(self.yaw), -math.sin(self.pitch), cp * math.cos(self.yaw), ) * self.distance self.position = self.pivot + offset # When looking nearly straight down/up, use Z as the up hint # to avoid degenerate cross product (same convention as game cameras) if abs(self.pitch) > math.radians(80): self.look_at(self.pivot, up=Vec3(0, 0, -1)) else: self.look_at(self.pivot)
# Backward-compat alias -- editor code may still import the old name EditorCamera3D = OrbitCamera3D