use crate::camera_projection::CameraProjection;
use crate::input::InputEvent;
use glam::{DMat4, DVec3, DVec4};
use rustial_math::{Ellipsoid, GeoCoord, Globe, WorldCoord};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CameraMode {
Orthographic,
#[default]
Perspective,
}
#[derive(Debug, Clone)]
pub struct Camera {
target: GeoCoord,
projection: CameraProjection,
distance: f64,
pitch: f64,
yaw: f64,
mode: CameraMode,
fov_y: f64,
viewport_width: u32,
viewport_height: u32,
}
const MAX_PITCH: f64 = std::f64::consts::FRAC_PI_2 - 0.001;
#[inline]
fn normalize_yaw(yaw: f64) -> f64 {
let two_pi = std::f64::consts::TAU;
let mut y = yaw % two_pi;
if y > std::f64::consts::PI {
y -= two_pi;
}
if y < -std::f64::consts::PI {
y += two_pi;
}
y
}
impl Default for Camera {
fn default() -> Self {
Self {
target: GeoCoord::from_lat_lon(0.0, 0.0),
projection: CameraProjection::default(),
distance: 10_000_000.0,
pitch: 0.0,
yaw: 0.0,
mode: CameraMode::default(),
fov_y: std::f64::consts::FRAC_PI_4,
viewport_width: 800,
viewport_height: 600,
}
}
}
impl Camera {
fn sync_projection_state(&mut self) {
if matches!(
self.projection,
CameraProjection::VerticalPerspective { .. }
) {
self.projection = CameraProjection::vertical_perspective(self.target, self.distance);
}
}
fn local_basis(&self) -> (DVec3, DVec3, DVec3) {
match self.projection {
CameraProjection::Globe => {
let lat = self.target.lat.to_radians();
let lon = self.target.lon.to_radians();
let (sin_lat, cos_lat) = lat.sin_cos();
let (sin_lon, cos_lon) = lon.sin_cos();
let east = DVec3::new(-sin_lon, cos_lon, 0.0);
let north = DVec3::new(-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat);
let up = DVec3::new(cos_lat * cos_lon, cos_lat * sin_lon, sin_lat);
(east, north, up)
}
_ => (DVec3::X, DVec3::Y, DVec3::Z),
}
}
fn view_up_from_eye(&self, eye: DVec3, target_world: DVec3) -> DVec3 {
const BLEND_RAD: f64 = 0.15;
let (sy, cy) = self.yaw.sin_cos();
let (east, north, _) = self.local_basis();
let yaw_up = east * sy + north * cy;
let right = east * cy - north * sy;
let look = (target_world - eye).normalize_or_zero();
let pitched_up = right.cross(look).normalize_or_zero();
let t = (self.pitch / BLEND_RAD).clamp(0.0, 1.0);
let up = (pitched_up * t + yaw_up * (1.0 - t)).normalize_or_zero();
if up.length_squared() < 0.5 {
DVec3::Z
} else {
up
}
}
fn screen_to_geo_on_globe(&self, px: f64, py: f64) -> Option<GeoCoord> {
let (origin, dir) = self.screen_to_ray(px, py);
let radius = Ellipsoid::WGS84.a;
let a = dir.dot(dir);
let b = 2.0 * origin.dot(dir);
let c = origin.dot(origin) - radius * radius;
let disc = b * b - 4.0 * a * c;
if disc < 0.0 {
return None;
}
let sqrt_disc = disc.sqrt();
let t0 = (-b - sqrt_disc) / (2.0 * a);
let t1 = (-b + sqrt_disc) / (2.0 * a);
let t = [t0, t1]
.into_iter()
.filter(|t| *t >= 0.0)
.min_by(|a, b| a.total_cmp(b))?;
let hit = origin + dir * t;
Some(Globe::unproject(&WorldCoord::new(hit.x, hit.y, hit.z)))
}
pub fn view_up_vector(&self) -> DVec3 {
let eye = self.eye_offset();
self.view_up_from_eye(eye, DVec3::ZERO)
}
#[inline]
pub fn target(&self) -> &GeoCoord {
&self.target
}
#[inline]
pub fn distance(&self) -> f64 {
self.distance
}
#[inline]
pub fn projection(&self) -> CameraProjection {
self.projection
}
#[inline]
pub fn pitch(&self) -> f64 {
self.pitch
}
#[inline]
pub fn yaw(&self) -> f64 {
self.yaw
}
#[inline]
pub fn mode(&self) -> CameraMode {
self.mode
}
#[inline]
pub fn fov_y(&self) -> f64 {
self.fov_y
}
#[inline]
pub fn viewport_width(&self) -> u32 {
self.viewport_width
}
#[inline]
pub fn viewport_height(&self) -> u32 {
self.viewport_height
}
#[inline]
pub fn set_target(&mut self, target: GeoCoord) {
self.target = target;
self.sync_projection_state();
}
#[inline]
pub fn set_projection(&mut self, projection: CameraProjection) {
self.projection = projection;
self.sync_projection_state();
}
pub fn set_distance(&mut self, d: f64) {
debug_assert!(
d.is_finite() && d > 0.0,
"Camera::set_distance: invalid {d}"
);
if d.is_finite() && d > 0.0 {
self.distance = d;
}
}
pub fn set_pitch(&mut self, p: f64) {
debug_assert!(p.is_finite(), "Camera::set_pitch: non-finite {p}");
if p.is_finite() {
self.pitch = p.clamp(0.0, MAX_PITCH);
}
}
pub fn set_yaw(&mut self, y: f64) {
debug_assert!(y.is_finite(), "Camera::set_yaw: non-finite {y}");
if y.is_finite() {
self.yaw = normalize_yaw(y);
}
}
pub fn set_mode(&mut self, mode: CameraMode) {
if mode == self.mode {
return;
}
let half_tan = (self.fov_y / 2.0).tan();
match (self.mode, mode) {
(CameraMode::Perspective, CameraMode::Orthographic) => {
self.distance *= half_tan;
}
(CameraMode::Orthographic, CameraMode::Perspective) => {
if half_tan.abs() > 1e-12 {
self.distance /= half_tan;
}
}
_ => {}
}
self.mode = mode;
}
pub fn set_fov_y(&mut self, fov: f64) {
debug_assert!(
fov.is_finite() && fov > 0.0,
"Camera::set_fov_y: invalid {fov}"
);
if fov.is_finite() && fov > 0.0 {
self.fov_y = fov;
}
}
#[inline]
pub fn set_viewport(&mut self, width: u32, height: u32) {
self.viewport_width = width;
self.viewport_height = height;
}
pub fn eye_offset(&self) -> DVec3 {
let (sp, cp) = self.pitch.sin_cos();
let (sy, cy) = self.yaw.sin_cos();
let (east, north, up) = self.local_basis();
east * (-self.distance * sp * sy)
+ north * (-self.distance * sp * cy)
+ up * (self.distance * cp)
}
pub fn view_matrix(&self, target_world: DVec3) -> DMat4 {
let eye = target_world + self.eye_offset();
let up = self.view_up_from_eye(eye, target_world);
DMat4::look_at_rh(eye, target_world, up)
}
pub fn perspective_matrix(&self) -> DMat4 {
let aspect = self.viewport_width as f64 / self.viewport_height.max(1) as f64;
let near = self.distance * 0.001;
let pitch_far_scale = if self.pitch > 0.01 {
(1.0 / self.pitch.cos().abs().max(0.05)).min(100.0)
} else {
1.0
};
let far = self.distance * 10.0 * pitch_far_scale;
DMat4::perspective_rh(self.fov_y, aspect, near, far)
}
pub fn orthographic_matrix(&self) -> DMat4 {
let half_h = self.distance;
let aspect = self.viewport_width as f64 / self.viewport_height.max(1) as f64;
let half_w = half_h * aspect;
let near = -self.distance * 100.0;
let far = self.distance * 100.0;
DMat4::orthographic_rh(-half_w, half_w, -half_h, half_h, near, far)
}
pub fn projection_matrix(&self) -> DMat4 {
match self.mode {
CameraMode::Perspective => self.perspective_matrix(),
CameraMode::Orthographic => self.orthographic_matrix(),
}
}
pub fn target_world(&self) -> DVec3 {
self.projection.project(&self.target).position
}
pub fn view_projection_matrix(&self) -> DMat4 {
let target_world = self.target_world();
self.projection_matrix() * self.view_matrix(target_world)
}
pub fn absolute_view_projection_matrix(&self) -> DMat4 {
let target_world = self.target_world();
self.projection_matrix() * self.view_matrix(target_world)
}
pub fn covering_camera(&self, fractional_zoom: f64) -> Option<rustial_math::CoveringCamera> {
if self.projection != CameraProjection::WebMercator {
return None;
}
if self.mode != CameraMode::Perspective {
return None;
}
let world_size = rustial_math::WebMercator::world_size();
let target_world = self.target_world();
let eye = target_world + self.eye_offset();
let half = world_size * 0.5;
let cam_x = (eye.x + half) / world_size;
let cam_y = (half - eye.y) / world_size;
let center_x = (target_world.x + half) / world_size;
let center_y = (half - target_world.y) / world_size;
let cam_to_center_z = eye.z / world_size;
Some(rustial_math::CoveringCamera {
camera_x: cam_x,
camera_y: cam_y,
camera_to_center_z: cam_to_center_z.abs(),
center_x,
center_y,
pitch_rad: self.pitch,
fov_deg: self.fov_y.to_degrees(),
zoom: fractional_zoom,
display_tile_size: 256,
})
}
pub fn flat_tile_view(&self) -> Option<rustial_math::FlatTileView> {
if self.projection != CameraProjection::WebMercator {
return None;
}
match self.mode {
CameraMode::Perspective => Some(rustial_math::FlatTileView::new(
rustial_math::WorldCoord::new(
self.target_world().x,
self.target_world().y,
self.target_world().z,
),
self.distance,
self.pitch,
self.yaw,
self.fov_y,
self.viewport_width,
self.viewport_height,
)),
CameraMode::Orthographic => None,
}
}
pub fn screen_to_ray(&self, px: f64, py: f64) -> (DVec3, DVec3) {
let w = self.viewport_width.max(1) as f64;
let h = self.viewport_height.max(1) as f64;
let target_world = self.target_world();
let view = self.view_matrix(target_world);
let proj = self.projection_matrix();
let vp_inv = (proj * view).inverse();
let ndc_x = (2.0 * px / w) - 1.0;
let ndc_y = 1.0 - (2.0 * py / h);
let near_ndc = DVec4::new(ndc_x, ndc_y, -1.0, 1.0);
let far_ndc = DVec4::new(ndc_x, ndc_y, 1.0, 1.0);
let near_world = vp_inv * near_ndc;
let far_world = vp_inv * far_ndc;
if near_world.w.abs() < 1e-12 || far_world.w.abs() < 1e-12 {
return (DVec3::ZERO, -DVec3::Z);
}
let near = DVec3::new(
near_world.x / near_world.w,
near_world.y / near_world.w,
near_world.z / near_world.w,
);
let far = DVec3::new(
far_world.x / far_world.w,
far_world.y / far_world.w,
far_world.z / far_world.w,
);
let dir = (far - near).normalize();
if dir.is_nan() {
return (DVec3::ZERO, -DVec3::Z);
}
(near, dir)
}
pub fn screen_to_geo(&self, px: f64, py: f64) -> Option<GeoCoord> {
if matches!(self.projection, CameraProjection::Globe) {
return self.screen_to_geo_on_globe(px, py);
}
let (origin, dir) = self.screen_to_ray(px, py);
if dir.z.abs() < 1e-12 {
return None; }
let t = -origin.z / dir.z;
if t < 0.0 {
return None; }
let hit = origin + dir * t;
let world = rustial_math::WorldCoord::new(hit.x, hit.y, 0.0);
Some(self.projection.unproject(&world))
}
pub fn geo_to_screen(&self, geo: &GeoCoord) -> Option<(f64, f64)> {
let w = self.viewport_width.max(1) as f64;
let h = self.viewport_height.max(1) as f64;
let world_pos = self.projection.project(geo);
let target_world = self.target_world();
let view = self.view_matrix(target_world);
let proj = self.projection_matrix();
let vp = proj * view;
let clip = vp
* DVec4::new(
world_pos.position.x,
world_pos.position.y,
world_pos.position.z,
1.0,
);
if clip.w <= 0.0 {
return None;
}
let ndc_x = clip.x / clip.w;
let ndc_y = clip.y / clip.w;
let px = (ndc_x + 1.0) * 0.5 * w;
let py = (1.0 - ndc_y) * 0.5 * h;
Some((px, py))
}
pub fn meters_per_pixel(&self) -> f64 {
let visible_height = match self.mode {
CameraMode::Perspective => 2.0 * self.distance * (self.fov_y / 2.0).tan(),
CameraMode::Orthographic => 2.0 * self.distance,
};
visible_height / self.viewport_height.max(1) as f64
}
pub fn near_meters_per_pixel(&self) -> f64 {
let center_mpp = self.meters_per_pixel();
if self.pitch.abs() < 0.01 {
return center_mpp;
}
match self.mode {
CameraMode::Orthographic => center_mpp,
CameraMode::Perspective => {
let h = self.distance * self.pitch.cos();
if h <= 0.0 {
return center_mpp;
}
let half_fov = self.fov_y / 2.0;
let near_angle = (self.pitch - half_fov).max(0.01);
let rad_per_px = self.fov_y / self.viewport_height.max(1) as f64;
let cos_near = near_angle.cos();
let near_mpp = h * rad_per_px / (cos_near * cos_near);
near_mpp.clamp(center_mpp * 0.125, center_mpp)
}
}
}
}
#[derive(Debug, Clone)]
pub struct CameraConstraints {
pub min_distance: f64,
pub max_distance: f64,
pub min_pitch: f64,
pub max_pitch: f64,
}
impl Default for CameraConstraints {
fn default() -> Self {
Self {
min_distance: 1.0,
max_distance: 40_000_000.0,
min_pitch: 0.0,
max_pitch: std::f64::consts::FRAC_PI_2 - 0.01,
}
}
}
pub struct CameraController;
impl CameraController {
fn retarget_for_screen_anchor(camera: &mut Camera, desired: GeoCoord, actual: GeoCoord) {
if matches!(
camera.projection(),
CameraProjection::Globe | CameraProjection::VerticalPerspective { .. }
) {
let mut target = *camera.target();
target.lat = (target.lat + (desired.lat - actual.lat)).clamp(-90.0, 90.0);
let lon_delta = desired.lon - actual.lon;
let mut lon = target.lon + lon_delta;
lon = ((lon + 180.0) % 360.0 + 360.0) % 360.0 - 180.0;
target.lon = lon;
camera.set_target(target);
return;
}
let desired = camera.projection().project(&desired);
let actual = camera.projection().project(&actual);
let current = camera.projection().project(camera.target());
let shift_x = actual.position.x - desired.position.x;
let shift_y = actual.position.y - desired.position.y;
let extent = camera.projection().max_extent();
let full = camera.projection().world_size();
let mut new_x = current.position.x - shift_x;
let new_y = (current.position.y - shift_y).clamp(-extent, extent);
new_x = ((new_x + extent) % full + full) % full - extent;
camera.set_target(camera.projection().unproject(&WorldCoord::new(
new_x,
new_y,
current.position.z,
)));
}
pub fn zoom(
camera: &mut Camera,
factor: f64,
cursor_x: Option<f64>,
cursor_y: Option<f64>,
constraints: &CameraConstraints,
) {
if !factor.is_finite() || factor <= 0.0 {
return;
}
let anchor = match (cursor_x, cursor_y) {
(Some(x), Some(y)) => camera.screen_to_geo(x, y).map(|geo| (x, y, geo)),
_ => None,
};
camera.set_distance(
(camera.distance() / factor).clamp(constraints.min_distance, constraints.max_distance),
);
if let Some((x, y, desired)) = anchor {
if let Some(actual) = camera.screen_to_geo(x, y) {
Self::retarget_for_screen_anchor(camera, desired, actual);
}
}
}
pub fn rotate(
camera: &mut Camera,
delta_yaw: f64,
delta_pitch: f64,
constraints: &CameraConstraints,
) {
camera.set_yaw(camera.yaw() + delta_yaw);
camera.set_pitch(
(camera.pitch() + delta_pitch).clamp(constraints.min_pitch, constraints.max_pitch),
);
}
pub fn pan(
camera: &mut Camera,
dx: f64,
dy: f64,
cursor_x: Option<f64>,
cursor_y: Option<f64>,
) {
let px = cursor_x.unwrap_or(camera.viewport_width() as f64 * 0.5);
let py = cursor_y.unwrap_or(camera.viewport_height() as f64 * 0.5);
if matches!(
camera.projection(),
CameraProjection::Globe | CameraProjection::VerticalPerspective { .. }
) {
if let (Some(geo_a), Some(geo_b)) = (
camera.screen_to_geo(px, py),
camera.screen_to_geo(px + dx, py + dy),
) {
Self::retarget_for_screen_anchor(camera, geo_a, geo_b);
return;
}
}
if let (Some(geo_a), Some(geo_b)) = (
camera.screen_to_geo(px, py),
camera.screen_to_geo(px + dx, py + dy),
) {
Self::retarget_for_screen_anchor(camera, geo_a, geo_b);
return;
}
let mpp = camera.meters_per_pixel();
let (sy, cy) = camera.yaw().sin_cos();
let world_dx = (dx * cy + dy * sy) * mpp;
let world_dy = (-dx * sy + dy * cy) * mpp;
let current = camera.projection.project(camera.target());
let mut new_x = current.position.x - world_dx;
let mut new_y = current.position.y + world_dy;
let extent = camera.projection.max_extent();
let full = camera.projection.world_size();
new_x = ((new_x + extent) % full + full) % full - extent;
new_y = new_y.clamp(-extent, extent);
camera.set_target(camera.projection.unproject(&WorldCoord::new(
new_x,
new_y,
current.position.z,
)));
}
pub fn handle_event(camera: &mut Camera, event: InputEvent, constraints: &CameraConstraints) {
match event {
InputEvent::Pan { dx, dy, x, y } => Self::pan(camera, dx, dy, x, y),
InputEvent::Zoom { factor, x, y } => Self::zoom(camera, factor, x, y, constraints),
InputEvent::Rotate {
delta_yaw,
delta_pitch,
} => Self::rotate(camera, delta_yaw, delta_pitch, constraints),
InputEvent::Resize { width, height } => {
camera.set_viewport(width, height);
}
InputEvent::Touch(_) => {
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_camera_top_down() {
let cam = Camera::default();
let offset = cam.eye_offset();
assert!(offset.x.abs() < 1e-6);
assert!(offset.y.abs() < 1e-6);
assert!((offset.z - cam.distance()).abs() < 1e-6);
}
#[test]
fn eye_offset_pitched_yaw_zero() {
let mut cam = Camera::default();
cam.set_pitch(std::f64::consts::FRAC_PI_4);
cam.set_distance(100.0);
let offset = cam.eye_offset();
assert!(offset.x.abs() < 1e-6, "x should be ~0, got {}", offset.x);
assert!(offset.y < -1.0, "y should be negative, got {}", offset.y);
assert!(offset.z > 1.0, "z should be positive, got {}", offset.z);
}
#[test]
fn eye_offset_pitched_yaw_90() {
let mut cam = Camera::default();
cam.set_pitch(std::f64::consts::FRAC_PI_4);
cam.set_yaw(std::f64::consts::FRAC_PI_2);
cam.set_distance(100.0);
let offset = cam.eye_offset();
assert!(offset.x < -1.0, "x should be negative for east-facing");
assert!(offset.y.abs() < 1e-6, "y should be ~0");
assert!(offset.z > 1.0, "z should be positive");
}
#[test]
fn view_matrix_no_flip_through_pitch_range() {
let mut cam = Camera::default();
cam.set_distance(1000.0);
let target = DVec3::ZERO;
let steps = 100;
let max_pitch = std::f64::consts::FRAC_PI_2 - 0.02;
for i in 0..=steps {
cam.set_pitch(max_pitch * (i as f64 / steps as f64));
let view = cam.view_matrix(target);
let eye = target + cam.eye_offset();
assert!(
eye.z > 0.0,
"eye should be above ground at pitch={:.3}",
cam.pitch()
);
for col in 0..4 {
let c = view.col(col);
assert!(
c.x.is_finite() && c.y.is_finite() && c.z.is_finite() && c.w.is_finite(),
"non-finite view matrix at pitch={:.3}",
cam.pitch()
);
}
}
}
#[test]
fn view_matrix_stable_through_yaw_range() {
let mut cam = Camera::default();
cam.set_distance(1000.0);
cam.set_pitch(0.5);
let target = DVec3::ZERO;
for i in 0..=36 {
cam.set_yaw((i as f64 / 36.0) * std::f64::consts::TAU);
let view = cam.view_matrix(target);
for col in 0..4 {
let c = view.col(col);
assert!(
c.x.is_finite() && c.y.is_finite() && c.z.is_finite() && c.w.is_finite(),
"non-finite view matrix at yaw={:.3}",
cam.yaw()
);
}
}
}
#[test]
fn view_matrix_no_north_south_flip_at_yaw_pi() {
let mut cam = Camera::default();
cam.set_distance(1000.0);
cam.set_yaw(std::f64::consts::PI);
let target = DVec3::ZERO;
let steps = 50;
let max_pitch = std::f64::consts::FRAC_PI_2 - 0.05;
let mut prev_right_x: Option<f64> = None;
for i in 0..=steps {
cam.set_pitch(max_pitch * (i as f64 / steps as f64));
let view = cam.view_matrix(target);
let right_x = view.col(0).x;
if let Some(prev) = prev_right_x {
assert!(
right_x * prev > -1e-6,
"screen-right flipped sign at pitch={:.3}: was {prev:.4}, now {right_x:.4}",
cam.pitch()
);
}
prev_right_x = Some(right_x);
}
}
#[test]
fn zoom_clamp() {
let mut cam = Camera::default();
let constraints = CameraConstraints::default();
CameraController::zoom(&mut cam, 1e20, None, None, &constraints);
assert!(cam.distance() >= constraints.min_distance);
CameraController::zoom(&mut cam, 1e-20, None, None, &constraints);
assert!(cam.distance() <= constraints.max_distance);
}
#[test]
fn zoom_nan_ignored() {
let mut cam = Camera::default();
let original = cam.distance();
let constraints = CameraConstraints::default();
CameraController::zoom(&mut cam, f64::NAN, None, None, &constraints);
assert_eq!(cam.distance(), original);
}
#[test]
fn zoom_zero_ignored() {
let mut cam = Camera::default();
let original = cam.distance();
let constraints = CameraConstraints::default();
CameraController::zoom(&mut cam, 0.0, None, None, &constraints);
assert_eq!(cam.distance(), original);
}
#[test]
fn zoom_negative_ignored() {
let mut cam = Camera::default();
let original = cam.distance();
let constraints = CameraConstraints::default();
CameraController::zoom(&mut cam, -2.0, None, None, &constraints);
assert_eq!(cam.distance(), original);
}
#[test]
fn zoom_infinity_ignored() {
let mut cam = Camera::default();
let original = cam.distance();
let constraints = CameraConstraints::default();
CameraController::zoom(&mut cam, f64::INFINITY, None, None, &constraints);
assert_eq!(cam.distance(), original);
}
#[test]
fn zoom_around_center_keeps_target_stable() {
let mut cam = Camera::default();
cam.set_target(GeoCoord::from_lat_lon(51.1, 17.0));
cam.set_distance(100_000.0);
cam.set_viewport(800, 600);
let before = *cam.target();
let constraints = CameraConstraints::default();
CameraController::zoom(&mut cam, 1.1, Some(400.0), Some(300.0), &constraints);
let after = *cam.target();
assert!((after.lat - before.lat).abs() < 1e-6);
assert!((after.lon - before.lon).abs() < 1e-6);
}
#[test]
fn zoom_around_cursor_preserves_anchor_location() {
let mut cam = Camera::default();
cam.set_target(GeoCoord::from_lat_lon(51.1, 17.0));
cam.set_distance(100_000.0);
cam.set_viewport(800, 600);
let constraints = CameraConstraints::default();
let desired = cam.screen_to_geo(650.0, 420.0).expect("anchor before zoom");
CameraController::zoom(&mut cam, 1.1, Some(650.0), Some(420.0), &constraints);
let actual = cam.screen_to_geo(650.0, 420.0).expect("anchor after zoom");
assert!((actual.lat - desired.lat).abs() < 1e-4);
assert!((actual.lon - desired.lon).abs() < 1e-4);
assert!((cam.target().lat - 51.1).abs() > 1e-5 || (cam.target().lon - 17.0).abs() > 1e-5);
}
#[test]
fn perspective_matrix_not_zero() {
let cam = Camera::default();
let m = cam.perspective_matrix();
assert!(m.col(0).x.abs() > 0.0);
}
#[test]
fn orthographic_matrix_not_zero() {
let mut cam = Camera::default();
cam.set_mode(CameraMode::Orthographic);
let m = cam.orthographic_matrix();
assert!(m.col(0).x.abs() > 0.0);
}
#[test]
fn projection_matrix_matches_mode() {
let mut cam = Camera::default();
cam.set_mode(CameraMode::Perspective);
let p = cam.projection_matrix();
assert_eq!(p, cam.perspective_matrix());
cam.set_mode(CameraMode::Orthographic);
let o = cam.projection_matrix();
assert_eq!(o, cam.orthographic_matrix());
}
#[test]
fn far_plane_grows_with_pitch() {
let mut cam = Camera::default();
cam.set_distance(10_000.0);
let m0 = cam.perspective_matrix();
cam.set_pitch(1.2);
let m1 = cam.perspective_matrix();
let depth0 = m0.col(2).z;
let depth1 = m1.col(2).z;
assert!(
(depth1 - depth0).abs() > 1e-6,
"far plane should differ with pitch"
);
}
#[test]
fn perspective_matrix_finite_at_max_pitch() {
let mut cam = Camera::default();
cam.set_pitch(std::f64::consts::FRAC_PI_2 - 0.01);
cam.set_distance(10_000.0);
let m = cam.perspective_matrix();
for col in 0..4 {
let c = m.col(col);
assert!(
c.x.is_finite() && c.y.is_finite() && c.z.is_finite() && c.w.is_finite(),
"perspective matrix should be finite at max pitch"
);
}
}
#[test]
fn screen_to_geo_center_returns_target() {
let mut cam = Camera::default();
cam.set_target(GeoCoord::from_lat_lon(51.1, 17.0));
cam.set_distance(100_000.0);
cam.set_viewport(800, 600);
let geo = cam.screen_to_geo(400.0, 300.0);
assert!(geo.is_some(), "center of screen should hit ground");
let geo = geo.expect("center hit");
assert!(
(geo.lat - 51.1).abs() < 0.1,
"lat should be near 51.1, got {}",
geo.lat
);
assert!(
(geo.lon - 17.0).abs() < 0.1,
"lon should be near 17.0, got {}",
geo.lon
);
}
#[test]
fn screen_to_geo_off_center_differs() {
let mut cam = Camera::default();
cam.set_distance(100_000.0);
cam.set_viewport(800, 600);
let center = cam.screen_to_geo(400.0, 300.0).expect("center");
let corner = cam.screen_to_geo(0.0, 0.0).expect("corner");
let dist = ((center.lat - corner.lat).powi(2) + (center.lon - corner.lon).powi(2)).sqrt();
assert!(dist > 0.01, "corner and center should differ");
}
#[test]
fn screen_to_ray_direction_is_normalized() {
let cam = Camera::default();
let (_, dir) = cam.screen_to_ray(400.0, 300.0);
assert!(
(dir.length() - 1.0).abs() < 1e-6,
"direction should be unit length"
);
}
#[test]
fn screen_to_ray_degenerate_viewport() {
let mut cam = Camera::default();
cam.set_viewport(0, 0);
let (origin, dir) = cam.screen_to_ray(0.0, 0.0);
assert!(origin.x.is_finite());
assert!(dir.z.is_finite());
}
#[test]
fn screen_to_geo_horizon_returns_none() {
let mut cam = Camera::default();
cam.set_pitch(std::f64::consts::FRAC_PI_2 - 0.02);
cam.set_distance(10_000.0);
cam.set_viewport(800, 600);
let result = cam.screen_to_geo(400.0, 0.0);
if let Some(geo) = result {
assert!(geo.lat.is_finite());
assert!(geo.lon.is_finite());
}
}
#[test]
fn meters_per_pixel_positive() {
let cam = Camera::default();
assert!(cam.meters_per_pixel() > 0.0);
}
#[test]
fn meters_per_pixel_decreases_with_zoom() {
let mut cam = Camera::default();
let mpp_far = cam.meters_per_pixel();
cam.set_distance(1_000.0);
let mpp_close = cam.meters_per_pixel();
assert!(mpp_close < mpp_far);
}
#[test]
fn meters_per_pixel_ortho_vs_perspective() {
let mut cam = Camera::default();
cam.set_mode(CameraMode::Perspective);
let mpp_persp = cam.meters_per_pixel();
cam.set_mode(CameraMode::Orthographic);
let mpp_ortho = cam.meters_per_pixel();
assert!(mpp_persp > 0.0 && mpp_persp.is_finite());
assert!(mpp_ortho > 0.0 && mpp_ortho.is_finite());
}
#[test]
fn set_mode_preserves_meters_per_pixel() {
let mut cam = Camera::default();
cam.set_distance(100_000.0);
cam.set_viewport(1280, 720);
cam.set_mode(CameraMode::Perspective);
let mpp_before = cam.meters_per_pixel();
cam.set_mode(CameraMode::Orthographic);
let mpp_after = cam.meters_per_pixel();
assert!(
(mpp_before - mpp_after).abs() / mpp_before < 1e-10,
"meters_per_pixel should be preserved: perspective={mpp_before}, orthographic={mpp_after}"
);
cam.set_mode(CameraMode::Perspective);
let mpp_roundtrip = cam.meters_per_pixel();
assert!(
(mpp_before - mpp_roundtrip).abs() / mpp_before < 1e-10,
"meters_per_pixel should survive round-trip: original={mpp_before}, roundtrip={mpp_roundtrip}"
);
}
#[test]
fn target_world_uses_selected_projection() {
let mut cam = Camera::default();
cam.set_target(GeoCoord::from_lat_lon(45.0, 10.0));
let merc = cam.target_world();
cam.set_projection(CameraProjection::Equirectangular);
let eq = cam.target_world();
assert!((merc.x - eq.x).abs() < 1e-6);
assert!((merc.y - eq.y).abs() > 1_000.0);
}
#[test]
fn screen_to_geo_center_respects_equirectangular_projection() {
let mut cam = Camera::default();
cam.set_projection(CameraProjection::Equirectangular);
cam.set_target(GeoCoord::from_lat_lon(30.0, 20.0));
cam.set_distance(100_000.0);
cam.set_viewport(800, 600);
let geo = cam.screen_to_geo(400.0, 300.0).expect("center hit");
assert!((geo.lat - 30.0).abs() < 0.1);
assert!((geo.lon - 20.0).abs() < 0.1);
}
#[test]
fn screen_to_geo_center_respects_globe_projection() {
let mut cam = Camera::default();
cam.set_projection(CameraProjection::Globe);
cam.set_target(GeoCoord::from_lat_lon(30.0, 20.0));
cam.set_distance(3_000_000.0);
cam.set_viewport(800, 600);
let geo = cam.screen_to_geo(400.0, 300.0).expect("center hit");
assert!((geo.lat - 30.0).abs() < 0.1);
assert!((geo.lon - 20.0).abs() < 0.1);
}
#[test]
fn screen_to_geo_center_respects_vertical_perspective_projection() {
let mut cam = Camera::default();
cam.set_target(GeoCoord::from_lat_lon(30.0, 20.0));
cam.set_distance(3_000_000.0);
cam.set_projection(CameraProjection::vertical_perspective(
*cam.target(),
cam.distance(),
));
cam.set_viewport(800, 600);
let geo = cam.screen_to_geo(400.0, 300.0).expect("center hit");
assert!((geo.lat - 30.0).abs() < 0.1);
assert!((geo.lon - 20.0).abs() < 0.1);
}
#[test]
fn pan_moves_target_under_globe_projection() {
let mut cam = Camera::default();
cam.set_projection(CameraProjection::Globe);
cam.set_target(GeoCoord::from_lat_lon(10.0, 10.0));
cam.set_distance(3_000_000.0);
cam.set_viewport(800, 600);
let before = *cam.target();
CameraController::pan(&mut cam, 100.0, 0.0, None, None);
let after = *cam.target();
assert!((after.lon - before.lon).abs() > 0.0 || (after.lat - before.lat).abs() > 0.0);
}
}