use glam::Vec3;
use uzor_urx_3d::PerspectiveCamera;
const ORBIT_RADIANS_PER_PX: f32 = 0.008;
const PITCH_LIMIT: f32 = 1.483_53;
const MIN_DISTANCE: f32 = 1.0;
const MAX_DISTANCE: f32 = 100_000.0;
const DEFAULT_FOV_Y: f32 = 1.047_197_6;
const PAN_UNITS_PER_PX_PER_DISTANCE: f32 = 0.002;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Camera3D {
pub target: Vec3,
pub distance: f32,
pub yaw: f32,
pub pitch: f32,
pub fov_y: f32,
}
impl Default for Camera3D {
fn default() -> Self {
Self { target: Vec3::ZERO, distance: 500.0, yaw: 0.0, pitch: 0.35, fov_y: DEFAULT_FOV_Y }
}
}
impl Camera3D {
fn orbit_offset(&self) -> Vec3 {
let (sp, cp) = self.pitch.sin_cos();
let (sy, cy) = self.yaw.sin_cos();
Vec3::new(self.distance * cp * sy, self.distance * sp, self.distance * cp * cy)
}
pub fn eye(&self) -> Vec3 {
self.target + self.orbit_offset()
}
pub fn forward(&self) -> Vec3 {
-self.orbit_offset().normalize_or_zero()
}
pub fn right(&self) -> Vec3 {
self.forward().cross(Vec3::Y).normalize_or_zero()
}
pub fn to_perspective(&self, aspect: f32) -> PerspectiveCamera {
let mut camera = PerspectiveCamera::new(self.eye(), self.target, aspect);
camera.z_near = (self.distance * 0.001).max(0.05);
camera.z_far = (self.distance * 4.0).max(2_000.0);
camera.fov_y = self.fov_y;
camera
}
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
self.yaw += delta_x * ORBIT_RADIANS_PER_PX;
self.pitch = (self.pitch + delta_y * ORBIT_RADIANS_PER_PX).clamp(-PITCH_LIMIT, PITCH_LIMIT);
}
pub fn free_look(&mut self, delta_x: f32, delta_y: f32) {
let eye = self.eye();
self.orbit(delta_x, delta_y);
self.target = eye - self.orbit_offset();
}
pub fn translate_local(&mut self, right: f32, up: f32, forward: f32) {
let forward_axis = self.forward();
let right_axis = self.right();
let up_axis = right_axis.cross(forward_axis).normalize_or_zero();
self.target += right_axis * right + up_axis * up + forward_axis * forward;
}
pub fn dolly(&mut self, factor: f32) {
self.distance = (self.distance * factor).clamp(MIN_DISTANCE, MAX_DISTANCE);
}
pub fn pan(&mut self, delta_x: f32, delta_y: f32) {
let forward = self.forward();
let right = self.right();
let up = right.cross(forward).normalize_or_zero();
let scale = PAN_UNITS_PER_PX_PER_DISTANCE * self.distance;
self.target -= right * (delta_x * scale);
self.target += up * (delta_y * scale);
}
pub fn fit_bounds(&mut self, min: Vec3, max: Vec3, aspect: f32, padding: f32) {
let center = (min + max) * 0.5;
self.target = center;
let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 16.0 / 9.0 };
let half_fov_y = (self.to_perspective(aspect).fov_y * 0.5).max(1e-4);
let tan_half_y = half_fov_y.tan().max(1e-6);
let tan_half_x = (tan_half_y * aspect).max(1e-6);
let forward = self.forward();
let right = self.right();
let up = right.cross(forward).normalize_or_zero();
let corners = [
Vec3::new(min.x, min.y, min.z),
Vec3::new(min.x, min.y, max.z),
Vec3::new(min.x, max.y, min.z),
Vec3::new(min.x, max.y, max.z),
Vec3::new(max.x, min.y, min.z),
Vec3::new(max.x, min.y, max.z),
Vec3::new(max.x, max.y, min.z),
Vec3::new(max.x, max.y, max.z),
];
let mut required_distance = 0.0f32;
for corner in corners {
let local = corner - center;
let forward_offset = local.dot(forward);
let vertical = local.dot(up).abs() / tan_half_y - forward_offset;
let horizontal = local.dot(right).abs() / tan_half_x - forward_offset;
required_distance = required_distance.max(vertical).max(horizontal);
}
let padding = if padding.is_finite() && padding > 0.0 { padding } else { 1.0 };
self.distance = (required_distance.max(MIN_DISTANCE) * padding).clamp(MIN_DISTANCE, MAX_DISTANCE);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_perspective_places_the_eye_at_distance_from_target_along_the_orbit_offset() {
let camera = Camera3D { target: Vec3::ZERO, distance: 10.0, yaw: 0.0, pitch: 0.0, ..Camera3D::default() };
let persp = camera.to_perspective(16.0 / 9.0);
assert!((persp.eye - Vec3::new(0.0, 0.0, 10.0)).length() < 1e-4);
assert_eq!(persp.target, Vec3::ZERO);
}
#[test]
fn fov_y_defaults_to_the_prior_hardcoded_perspective_camera_value() {
let default_camera = Camera3D::default();
assert_eq!(default_camera.fov_y, DEFAULT_FOV_Y);
let baseline = PerspectiveCamera::new(Vec3::ZERO, Vec3::ZERO, 1.0);
assert!((default_camera.to_perspective(1.0).fov_y - baseline.fov_y).abs() < 1e-7);
}
#[test]
fn a_caller_overridden_fov_y_reaches_the_produced_perspective_camera() {
let camera = Camera3D { fov_y: 30_f32.to_radians(), ..Camera3D::default() };
let persp = camera.to_perspective(16.0 / 9.0);
assert!((persp.fov_y - 30_f32.to_radians()).abs() < 1e-6);
assert_ne!(persp.fov_y, DEFAULT_FOV_Y, "sanity: the override must actually differ from the default");
}
#[test]
fn fit_bounds_uses_a_shorter_distance_for_a_wider_overridden_fov() {
let mut narrow = Camera3D { fov_y: 20_f32.to_radians(), ..Camera3D::default() };
narrow.fit_bounds(Vec3::splat(-10.0), Vec3::splat(10.0), 16.0 / 9.0, 1.1);
let mut wide = Camera3D { fov_y: 90_f32.to_radians(), ..Camera3D::default() };
wide.fit_bounds(Vec3::splat(-10.0), Vec3::splat(10.0), 16.0 / 9.0, 1.1);
assert!(wide.distance < narrow.distance, "a wider fov must fit the same box at a shorter distance: narrow={} wide={}", narrow.distance, wide.distance);
}
#[test]
fn orbit_advances_yaw_and_clamps_pitch_within_limits() {
let mut camera = Camera3D { yaw: 0.0, pitch: 0.0, ..Camera3D::default() };
camera.orbit(100.0, 100_000.0);
assert!(camera.yaw > 0.0);
assert!(camera.pitch <= PITCH_LIMIT + 1e-6);
camera.pitch = 0.0;
camera.orbit(0.0, -100_000.0);
assert!(camera.pitch >= -PITCH_LIMIT - 1e-6);
}
#[test]
fn free_look_rotates_forward_axis_without_orbiting_the_eye() {
let mut camera = Camera3D {
target: Vec3::new(12.0, -4.0, 8.0),
distance: 25.0,
yaw: 0.3,
pitch: -0.2,
..Camera3D::default()
};
let eye_before = camera.eye();
let target_before = camera.target;
let forward_before = camera.forward();
camera.free_look(40.0, -15.0);
assert!((camera.eye() - eye_before).length() < 1e-4, "free look must preserve camera position");
assert!((camera.target - target_before).length() > 1.0, "the aim point must move with orientation");
assert!((camera.forward() - forward_before).length() > 0.1, "the forward axis must rotate");
assert!(((camera.target - camera.eye()).length() - camera.distance).abs() < 1e-4);
}
#[test]
fn local_translation_moves_eye_and_aim_together() {
let mut camera = Camera3D {
target: Vec3::new(-3.0, 5.0, 9.0),
distance: 18.0,
yaw: 0.7,
pitch: 0.25,
..Camera3D::default()
};
let eye_before = camera.eye();
let target_before = camera.target;
let forward_before = camera.forward();
camera.translate_local(6.0, -2.0, 11.0);
let eye_delta = camera.eye() - eye_before;
let target_delta = camera.target - target_before;
assert!((eye_delta - target_delta).length() < 1e-4);
assert!((camera.forward() - forward_before).length() < 1e-6);
assert!(((camera.target - camera.eye()).length() - camera.distance).abs() < 1e-4);
}
#[test]
fn dolly_scales_distance_and_clamps_within_bounds() {
let mut camera = Camera3D { distance: 10.0, ..Camera3D::default() };
camera.dolly(2.0);
assert!((camera.distance - 20.0).abs() < 1e-4);
camera.dolly(1e12);
assert!(camera.distance <= MAX_DISTANCE);
camera.distance = 10.0;
camera.dolly(1e-12);
assert!(camera.distance >= MIN_DISTANCE);
}
#[test]
fn pan_moves_the_target_and_leaves_distance_untouched() {
let mut camera = Camera3D { target: Vec3::ZERO, distance: 10.0, yaw: 0.0, pitch: 0.0, ..Camera3D::default() };
camera.pan(50.0, 0.0);
assert!(camera.target.length() > 0.0);
assert!((camera.distance - 10.0).abs() < 1e-6);
}
#[test]
fn fit_bounds_centers_the_target_on_the_aabb_and_keeps_every_corner_inside_ndc() {
let mut camera = Camera3D { target: Vec3::new(999.0, -50.0, 12.0), distance: 5.0, yaw: 0.6, pitch: -0.3, ..Camera3D::default() };
let min = Vec3::new(-40.0, -10.0, -25.0);
let max = Vec3::new(60.0, 30.0, 15.0);
let aspect = 16.0 / 9.0;
camera.fit_bounds(min, max, aspect, 1.1);
let center = (min + max) * 0.5;
assert!((camera.target - center).length() < 1e-3);
let persp = camera.to_perspective(aspect);
let view_proj = persp.view_proj();
let corners = [
Vec3::new(min.x, min.y, min.z),
Vec3::new(min.x, min.y, max.z),
Vec3::new(min.x, max.y, min.z),
Vec3::new(min.x, max.y, max.z),
Vec3::new(max.x, min.y, min.z),
Vec3::new(max.x, min.y, max.z),
Vec3::new(max.x, max.y, min.z),
Vec3::new(max.x, max.y, max.z),
];
for corner in corners {
let clip = view_proj * corner.extend(1.0);
assert!(clip.w > 1e-5, "corner {corner:?} must be in front of the fitted camera");
let ndc_x = clip.x / clip.w;
let ndc_y = clip.y / clip.w;
assert!(ndc_x.abs() <= 1.0 + 1e-3, "corner {corner:?} escaped horizontal NDC: {ndc_x}");
assert!(ndc_y.abs() <= 1.0 + 1e-3, "corner {corner:?} escaped vertical NDC: {ndc_y}");
}
}
#[test]
fn fit_bounds_preserves_yaw_and_pitch_a_dolly_and_retarget_only() {
let mut camera = Camera3D { target: Vec3::ZERO, distance: 5.0, yaw: 1.2, pitch: -0.4, ..Camera3D::default() };
camera.fit_bounds(Vec3::new(-10.0, -10.0, -10.0), Vec3::new(10.0, 10.0, 10.0), 1.5, 1.1);
assert_eq!(camera.yaw, 1.2, "fit_bounds must not re-orient the camera");
assert_eq!(camera.pitch, -0.4, "fit_bounds must not re-orient the camera");
}
#[test]
fn fit_bounds_on_a_degenerate_point_aabb_does_not_panic_and_clamps_distance() {
let mut camera = Camera3D::default();
let point = Vec3::new(5.0, 5.0, 5.0);
camera.fit_bounds(point, point, 16.0 / 9.0, 1.1);
assert_eq!(camera.target, point);
assert!(camera.distance >= MIN_DISTANCE && camera.distance <= MAX_DISTANCE);
}
#[test]
fn fit_bounds_uses_a_larger_distance_for_a_bigger_box() {
let mut small = Camera3D::default();
small.fit_bounds(Vec3::splat(-5.0), Vec3::splat(5.0), 16.0 / 9.0, 1.1);
let mut big = Camera3D::default();
big.fit_bounds(Vec3::splat(-50.0), Vec3::splat(50.0), 16.0 / 9.0, 1.1);
assert!(big.distance > small.distance, "a larger AABB must need a larger fitted distance");
}
#[test]
fn fit_bounds_padding_scales_the_resulting_distance() {
let mut tight = Camera3D::default();
tight.fit_bounds(Vec3::splat(-10.0), Vec3::splat(10.0), 16.0 / 9.0, 1.0);
let mut padded = Camera3D::default();
padded.fit_bounds(Vec3::splat(-10.0), Vec3::splat(10.0), 16.0 / 9.0, 1.1);
assert!((padded.distance / tight.distance - 1.1).abs() < 1e-3, "a 1.1 padding must scale the distance by ~10%: tight={} padded={}", tight.distance, padded.distance);
}
}