use glam::{Mat4, Vec2, Vec3};
use crate::scene::bounds::Aabb;
use crate::tree::Rect;
pub const DEFAULT_FOV_Y_RADIANS: f32 = std::f32::consts::FRAC_PI_4;
const MAX_PITCH: f32 = std::f32::consts::FRAC_PI_2 - 0.087; const MIN_DISTANCE: f32 = 1.0e-3;
const MAX_DISTANCE: f32 = 1.0e6;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Framing {
#[default]
Auto,
Fit,
Manual,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum CameraControls {
#[default]
Orbit,
Blender,
OnShape,
Maya,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Focus {
Bounds(Aabb),
Point { target: Vec3, distance: f32 },
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CameraState {
pub target: Vec3,
pub distance: f32,
pub yaw: f32,
pub pitch: f32,
}
impl Default for CameraState {
fn default() -> Self {
Self {
target: Vec3::ZERO,
distance: 1.0 / (DEFAULT_FOV_Y_RADIANS * 0.5).sin(),
yaw: std::f32::consts::FRAC_PI_4, pitch: std::f32::consts::FRAC_PI_6, }
}
}
impl CameraState {
pub fn orbit(&mut self, d_yaw: f32, d_pitch: f32) {
self.yaw += d_yaw;
self.pitch = (self.pitch + d_pitch).clamp(-MAX_PITCH, MAX_PITCH);
}
pub fn zoom_by(&mut self, factor: f32) {
if factor.is_finite() && factor > 0.0 {
self.distance = (self.distance * factor).clamp(MIN_DISTANCE, MAX_DISTANCE);
}
}
pub fn pan_by(&mut self, delta: Vec3) {
self.target += delta;
}
pub fn fit_distance(radius: f32) -> f32 {
(radius.max(1e-4) / (DEFAULT_FOV_Y_RADIANS * 0.5).sin()).clamp(MIN_DISTANCE, MAX_DISTANCE)
}
pub fn fitted(&self, content: Aabb) -> CameraState {
let (center, radius) = sphere_of(content);
CameraState {
target: center,
distance: Self::fit_distance(radius),
yaw: self.yaw,
pitch: self.pitch,
}
}
pub fn framing(content: Aabb) -> CameraState {
CameraState::default().fitted(content)
}
pub fn look_at(&mut self, target: Vec3, distance: f32) {
self.target = target;
self.distance = distance.clamp(MIN_DISTANCE, MAX_DISTANCE);
}
pub fn focused(&self, focus: Focus) -> CameraState {
match focus {
Focus::Bounds(b) => self.fitted(b),
Focus::Point { target, distance } => {
let mut c = *self;
c.look_at(target, distance);
c
}
}
}
pub fn eye(&self) -> Vec3 {
let (sy, cy) = self.yaw.sin_cos();
let (sp, cp) = self.pitch.sin_cos();
let dir = Vec3::new(cp * sy, sp, cp * cy);
self.target + dir * self.distance
}
pub fn resolve(&self, view_bounds: Aabb) -> ResolvedCamera {
let fov_y = DEFAULT_FOV_Y_RADIANS;
let eye = self.eye();
let (vc, vr) = if view_bounds.is_valid() {
let (c, r) = sphere_of(view_bounds);
(c, r)
} else {
(self.target, self.distance.max(1e-4))
};
let d = (eye - vc).length();
let near = (d - vr).max(self.distance * 0.02).max(1e-3);
let far = (d + vr).max(near * 8.0);
ResolvedCamera {
eye,
target: self.target,
up: Vec3::Y,
fov_y,
near,
far,
}
}
}
fn sphere_of(bounds: Aabb) -> (Vec3, f32) {
if bounds.is_valid() {
let r = bounds.bounding_radius();
(bounds.center(), if r > 1e-4 { r } else { 1.0 })
} else {
(Vec3::ZERO, 1.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ResolvedCamera {
pub eye: Vec3,
pub target: Vec3,
pub up: Vec3,
pub fov_y: f32,
pub near: f32,
pub far: f32,
}
impl ResolvedCamera {
pub fn view(&self) -> Mat4 {
Mat4::look_at_rh(self.eye, self.target, self.up)
}
pub fn proj(&self, aspect: f32) -> Mat4 {
Mat4::perspective_rh(self.fov_y, aspect.max(1e-4), self.near, self.far)
}
pub fn view_proj(&self, aspect: f32) -> Mat4 {
self.proj(aspect) * self.view()
}
pub fn project_to_screen(&self, world: Vec3, viewport: Rect) -> Option<Vec2> {
self.project_to_screen_with_depth(world, viewport)
.map(|(p, _)| p)
}
pub fn project_to_screen_with_depth(&self, world: Vec3, viewport: Rect) -> Option<(Vec2, f32)> {
let aspect = viewport.w / viewport.h.max(1e-4);
let clip = self.view_proj(aspect) * world.extend(1.0);
if clip.w <= 0.0 {
return None;
}
let ndc = clip.truncate() / clip.w; let sx = viewport.x + (ndc.x * 0.5 + 0.5) * viewport.w;
let sy = viewport.y + (1.0 - (ndc.y * 0.5 + 0.5)) * viewport.h; Some((Vec2::new(sx, sy), ndc.z))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unit_box() -> Aabb {
Aabb::from_points([Vec3::splat(-1.0), Vec3::splat(1.0)])
}
#[test]
fn fitted_frames_bounds() {
let cam = CameraState::framing(unit_box());
assert!((cam.target - Vec3::ZERO).length() < 1e-5);
assert!(cam.distance > unit_box().bounding_radius());
let r = cam.resolve(unit_box());
assert!(r.near > 0.0 && r.far > r.near);
let mut tilted = CameraState::default();
tilted.orbit(0.3, -0.2);
let f = tilted.fitted(unit_box());
assert_eq!((f.yaw, f.pitch), (tilted.yaw, tilted.pitch));
}
#[test]
fn target_projects_near_viewport_centre() {
let cam = CameraState::framing(unit_box()).resolve(unit_box());
let vp = Rect::new(0.0, 0.0, 200.0, 100.0);
let p = cam
.project_to_screen(cam.target, vp)
.expect("target in front");
assert!((p.x - 100.0).abs() < 0.5, "x={}", p.x);
assert!((p.y - 50.0).abs() < 0.5, "y={}", p.y);
}
#[test]
fn point_behind_camera_is_culled() {
let cam = CameraState::framing(unit_box()).resolve(unit_box());
let behind = cam.eye + (cam.eye - cam.target);
assert!(
cam.project_to_screen(behind, Rect::new(0.0, 0.0, 200.0, 100.0))
.is_none()
);
}
#[test]
fn orbit_and_zoom_move_the_eye() {
let base = CameraState::framing(unit_box());
let base_eye = base.eye();
let mut s = base;
s.orbit(0.5, 0.0);
assert!((s.eye() - base_eye).length() > 1e-3, "orbit moved eye");
let mut z = base;
z.zoom_by(2.0);
assert!((z.distance - 2.0 * base.distance).abs() < 1e-3);
}
#[test]
fn pitch_clamps_near_pole() {
let mut s = CameraState::default();
s.orbit(0.0, 100.0); assert!(s.pitch <= MAX_PITCH + 1e-6);
}
#[test]
fn near_far_track_view_bounds_not_content_radius() {
let cam = CameraState::framing(unit_box());
let grid = Aabb::from_points([Vec3::splat(-10.0), Vec3::splat(10.0)]);
let r = cam.resolve(grid);
assert!(
r.near <= cam.distance * 0.05,
"near should hug the camera, got {} (distance {})",
r.near,
cam.distance
);
let far_corner = Vec3::splat(10.0).max(Vec3::splat(-10.0));
let dist_to_far = (cam.eye() - far_corner).length();
assert!(
r.far >= dist_to_far,
"far {} must cover the far grid corner at {}",
r.far,
dist_to_far
);
}
}