"""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