use crate::camera::{Aabb, Camera};
pub fn norm3(v: [f64; 3]) -> [f64; 3] {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if len <= 0.0 {
return [0.0, 0.0, 1.0];
}
[v[0] / len, v[1] / len, v[2] / len]
}
pub fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
pub fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
pub fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
pub fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
pub fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
pub fn len3(a: [f64; 3]) -> f64 {
dot3(a, a).sqrt()
}
pub fn rotate3(v: [f64; 3], axis: [f64; 3], angle: f64) -> [f64; 3] {
let (sin, cos) = angle.sin_cos();
let cross = cross3(axis, v);
let dot = dot3(axis, v);
[
v[0] * cos + cross[0] * sin + axis[0] * dot * (1.0 - cos),
v[1] * cos + cross[1] * sin + axis[1] * dot * (1.0 - cos),
v[2] * cos + cross[2] * sin + axis[2] * dot * (1.0 - cos),
]
}
pub fn invert4_columns(m: &[f64; 16]) -> Option<[f64; 16]> {
let a00 = m[0]; let a01 = m[1]; let a02 = m[2]; let a03 = m[3];
let a10 = m[4]; let a11 = m[5]; let a12 = m[6]; let a13 = m[7];
let a20 = m[8]; let a21 = m[9]; let a22 = m[10]; let a23 = m[11];
let a30 = m[12]; let a31 = m[13]; let a32 = m[14]; let a33 = m[15];
let b00 = a00 * a11 - a01 * a10;
let b01 = a00 * a12 - a02 * a10;
let b02 = a00 * a13 - a03 * a10;
let b03 = a01 * a12 - a02 * a11;
let b04 = a01 * a13 - a03 * a11;
let b05 = a02 * a13 - a03 * a12;
let b06 = a20 * a31 - a21 * a30;
let b07 = a20 * a32 - a22 * a30;
let b08 = a20 * a33 - a23 * a30;
let b09 = a21 * a32 - a22 * a31;
let b10 = a21 * a33 - a23 * a31;
let b11 = a22 * a33 - a23 * a32;
let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
if det.abs() < 1e-300 {
return None;
}
let inv = 1.0 / det;
Some([
(a11 * b11 - a12 * b10 + a13 * b09) * inv,
(a02 * b10 - a01 * b11 - a03 * b09) * inv,
(a31 * b05 - a32 * b04 + a33 * b03) * inv,
(a22 * b04 - a21 * b05 - a23 * b03) * inv,
(a12 * b08 - a10 * b11 - a13 * b07) * inv,
(a00 * b11 - a02 * b08 + a03 * b07) * inv,
(a32 * b02 - a30 * b05 - a33 * b01) * inv,
(a20 * b05 - a22 * b02 + a23 * b01) * inv,
(a10 * b10 - a11 * b08 + a13 * b06) * inv,
(a01 * b08 - a00 * b10 - a03 * b06) * inv,
(a30 * b04 - a31 * b02 + a33 * b00) * inv,
(a21 * b02 - a20 * b04 - a23 * b00) * inv,
(a11 * b07 - a10 * b09 - a12 * b06) * inv,
(a00 * b09 - a01 * b07 + a02 * b06) * inv,
(a31 * b01 - a30 * b03 - a32 * b00) * inv,
(a20 * b03 - a21 * b01 + a22 * b00) * inv,
])
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Projection {
Orthographic { half_height: f64 },
Perspective { fov_y_deg: f64 },
}
#[derive(Debug, Clone, Copy)]
pub struct Ray {
pub origin: [f64; 3],
pub dir: [f64; 3],
}
#[derive(Debug, Clone)]
pub struct ViewCamera {
pub eye: [f64; 3],
pub target: [f64; 3],
pub up: [f64; 3],
pub projection: Projection,
pub width: f64,
pub height: f64,
pub near: f64,
pub far: f64,
}
impl Default for ViewCamera {
fn default() -> Self {
Self {
eye: [15.0, 12.0, 15.0],
target: [0.0, 0.0, 0.0],
up: [0.0, 1.0, 0.0],
projection: Projection::Orthographic { half_height: 10.0 },
width: 800.0,
height: 600.0,
near: -100000.0,
far: 100000.0,
}
}
}
impl ViewCamera {
pub fn aspect(&self) -> f64 {
(self.width / self.height.max(1.0)).max(1e-6)
}
pub fn basis(&self) -> ([f64; 3], [f64; 3], [f64; 3]) {
let forward = norm3(sub3(self.target, self.eye));
let right = norm3(cross3(forward, self.up));
let up = cross3(right, forward);
(right, up, forward)
}
pub fn distance(&self) -> f64 {
len3(sub3(self.eye, self.target)).max(1e-9)
}
pub fn world_per_pixel(&self) -> f64 {
match self.projection {
Projection::Orthographic { half_height } => 2.0 * half_height / self.height.max(1.0),
Projection::Perspective { fov_y_deg } => {
let fov = fov_y_deg.to_radians();
2.0 * (fov * 0.5).tan() * self.distance() / self.height.max(1.0)
}
}
}
pub fn view_proj_cols(&self) -> [[f64; 4]; 4] {
let (right, up, forward) = self.basis();
let half_h = match self.projection {
Projection::Orthographic { half_height } => half_height,
Projection::Perspective { fov_y_deg } => (fov_y_deg.to_radians() * 0.5).tan(),
};
let half_w = half_h * self.aspect();
let ex = -dot3(right, self.eye);
let ey = -dot3(up, self.eye);
let ez = dot3(forward, self.eye);
let view = [
[right[0], up[0], -forward[0], 0.0],
[right[1], up[1], -forward[1], 0.0],
[right[2], up[2], -forward[2], 0.0],
[ex, ey, ez, 1.0],
];
let proj = match self.projection {
Projection::Orthographic { .. } => {
let sx = 1.0 / half_w;
let sy = 1.0 / half_h;
let sz = -1.0 / (self.far - self.near);
[
[sx, 0.0, 0.0, 0.0],
[0.0, sy, 0.0, 0.0],
[0.0, 0.0, sz, 0.0],
[0.0, 0.0, -self.near / (self.far - self.near), 1.0],
]
}
Projection::Perspective { .. } => {
let near = self.near.max(1e-6);
let far = self.far.max(near * 1.0001);
let f = 1.0 / half_h;
[
[f / self.aspect(), 0.0, 0.0, 0.0],
[0.0, f, 0.0, 0.0],
[0.0, 0.0, far / (near - far), -1.0],
[0.0, 0.0, near * far / (near - far), 0.0],
]
}
};
let mut view_proj = [[0.0f64; 4]; 4];
for col in 0..4 {
for row in 0..4 {
let mut sum = 0.0;
for k in 0..4 {
sum += proj[k][row] * view[col][k];
}
view_proj[col][row] = sum;
}
}
view_proj
}
pub fn resolve(&self) -> Camera {
let cols = self.view_proj_cols();
let mut view_proj = [[0.0f32; 4]; 4];
for col in 0..4 {
for row in 0..4 {
view_proj[col][row] = cols[col][row] as f32;
}
}
let fwd = norm3(sub3(self.target, self.eye));
Camera {
view_proj,
forward: [fwd[0] as f32, fwd[1] as f32, fwd[2] as f32],
}
}
pub fn view_proj_flat(&self) -> [f64; 16] {
let cols = self.view_proj_cols();
let mut out = [0.0f64; 16];
for col in 0..4 {
for row in 0..4 {
out[col * 4 + row] = cols[col][row];
}
}
out
}
pub fn view_proj_inverse_flat(&self) -> [f64; 16] {
invert4_columns(&self.view_proj_flat())
.unwrap_or([1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0])
}
pub fn project(&self, world: [f64; 3]) -> (f64, f64, f64) {
let (right, up, forward) = self.basis();
let rel = sub3(world, self.eye);
let vx = dot3(rel, right);
let vy = dot3(rel, up);
let depth = dot3(rel, forward);
match self.projection {
Projection::Orthographic { half_height } => {
let half_w = half_height * self.aspect();
let sx = (vx / half_w * 0.5 + 0.5) * self.width;
let sy = (0.5 - vy / half_height * 0.5) * self.height;
(sx, sy, depth)
}
Projection::Perspective { fov_y_deg } => {
let half_h = (fov_y_deg.to_radians() * 0.5).tan();
let half_w = half_h * self.aspect();
let d = depth.max(1e-9);
let sx = (vx / (half_w * d) * 0.5 + 0.5) * self.width;
let sy = (0.5 - vy / (half_h * d) * 0.5) * self.height;
(sx, sy, depth)
}
}
}
pub fn pick_ray(&self, x: f64, y: f64) -> Ray {
let (right, up, forward) = self.basis();
let ndc_x = (x / self.width.max(1.0)) * 2.0 - 1.0;
let ndc_y = -((y / self.height.max(1.0)) * 2.0 - 1.0);
match self.projection {
Projection::Orthographic { half_height } => {
let half_w = half_height * self.aspect();
let span = self.far.abs().max(self.near.abs()).max(half_height * 40.0).max(1.0);
let on_plane = add3(
self.eye,
add3(scale3(right, ndc_x * half_w), scale3(up, ndc_y * half_height)),
);
Ray {
origin: sub3(on_plane, scale3(forward, span)),
dir: forward,
}
}
Projection::Perspective { fov_y_deg } => {
let half_h = (fov_y_deg.to_radians() * 0.5).tan();
let half_w = half_h * self.aspect();
let dir = norm3(add3(
forward,
add3(scale3(right, ndc_x * half_w), scale3(up, ndc_y * half_h)),
));
Ray {
origin: self.eye,
dir,
}
}
}
}
pub fn fit_depth_range(&mut self, bbox: &Aabb) {
if bbox.is_empty() {
return;
}
let (_, _, forward) = self.basis();
let mut min_d = f64::INFINITY;
let mut max_d = f64::NEG_INFINITY;
for i in 0..8 {
let corner = [
if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
];
let d = dot3(sub3(corner, self.eye), forward);
min_d = min_d.min(d);
max_d = max_d.max(d);
}
let diag = len3(sub3(bbox.max, bbox.min));
let pad = ((max_d - min_d) * 0.1).max(diag * 0.1).max(0.5);
match self.projection {
Projection::Orthographic { .. } => {
self.near = min_d - pad;
self.far = max_d + pad;
}
Projection::Perspective { .. } => {
let far = (max_d + pad).max(1.0);
self.near = (far * 0.001).clamp(1e-4, 1.0).min((min_d - pad).max(1e-4));
self.far = far;
}
}
}
pub fn zoom_to_fit(&mut self, bbox: &Aabb, margin: f64) {
if bbox.is_empty() {
return;
}
let margin = margin.max(1.0);
let (right, up, forward) = self.basis();
let center = bbox.center();
let mut half_w = 0.0f64;
let mut half_h = 0.0f64;
for i in 0..8 {
let corner = [
if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
];
let rel = sub3(corner, center);
half_w = half_w.max(dot3(rel, right).abs());
half_h = half_h.max(dot3(rel, up).abs());
}
half_w = (half_w * margin).max(1e-6);
half_h = (half_h * margin).max(1e-6);
let dist = self.distance();
let aspect = self.aspect();
self.target = center;
match self.projection {
Projection::Orthographic { ref mut half_height } => {
*half_height = half_h.max(half_w / aspect);
self.eye = sub3(center, scale3(forward, dist));
}
Projection::Perspective { fov_y_deg } => {
let fov = fov_y_deg.to_radians();
let dist_h = half_h / (fov * 0.5).tan().max(1e-6);
let tan_half_h_fov = (fov * 0.5).tan() * aspect;
let dist_w = half_w / tan_half_h_fov.max(1e-6);
let target_dist = dist_h.max(dist_w).max(1e-3);
self.eye = sub3(center, scale3(forward, target_dist));
}
}
self.fit_depth_range(bbox);
}
pub fn toggle_projection(&mut self) -> &'static str {
const FOV: f64 = 50.0;
let forward = norm3(sub3(self.target, self.eye));
match self.projection {
Projection::Orthographic { half_height } => {
let denom = (FOV.to_radians() * 0.5).tan();
let mut distance = half_height / denom.max(1e-9);
if !distance.is_finite() || distance < 1e-4 {
distance = 10.0;
}
self.eye = sub3(self.target, scale3(forward, distance));
self.projection = Projection::Perspective { fov_y_deg: FOV };
"perspective"
}
Projection::Perspective { fov_y_deg } => {
let dist = self.distance();
let half_height = ((fov_y_deg.to_radians() * 0.5).tan() * dist).max(1e-6);
self.projection = Projection::Orthographic { half_height };
"orthographic"
}
}
}
pub fn standard_view(&mut self, name: &str) -> bool {
let dist = self.distance();
let iso = norm3([1.0, 1.0, 1.0]);
let (dir, up): ([f64; 3], [f64; 3]) = match name.to_ascii_uppercase().as_str() {
"FRONT" => ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
"BACK" => ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
"RIGHT" => ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
"LEFT" => ([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
"TOP" => ([0.0, 1.0, 0.0], [0.0, 0.0, -1.0]),
"BOTTOM" => ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]),
"ISO" => (iso, [0.0, 1.0, 0.0]),
_ => return false,
};
self.eye = add3(self.target, scale3(dir, dist));
self.up = up;
true
}
pub fn state_json(&self) -> String {
let (kind, scale) = match self.projection {
Projection::Orthographic { half_height } => ("orthographic", half_height),
Projection::Perspective { fov_y_deg } => ("perspective", fov_y_deg),
};
serde_json::json!({
"kind": kind,
"eye": self.eye,
"target": self.target,
"up": self.up,
"scale": scale,
"near": self.near,
"far": self.far,
"width": self.width,
"height": self.height,
"worldPerPixel": self.world_per_pixel(),
})
.to_string()
}
pub fn apply_state_json(&mut self, json: &str) -> Result<(), String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|error| format!("camera state parse: {error}"))?;
let vec3 = |key: &str| -> Option<[f64; 3]> {
let arr = value.get(key)?.as_array()?;
Some([arr.first()?.as_f64()?, arr.get(1)?.as_f64()?, arr.get(2)?.as_f64()?])
};
if let Some(eye) = vec3("eye") {
self.eye = eye;
}
if let Some(target) = vec3("target") {
self.target = target;
}
if let Some(up) = vec3("up") {
self.up = up;
}
let scale = value.get("scale").and_then(|v| v.as_f64());
match value.get("kind").and_then(|v| v.as_str()) {
Some("perspective") => {
self.projection = Projection::Perspective {
fov_y_deg: scale.unwrap_or(50.0),
}
}
Some("orthographic") => {
self.projection = Projection::Orthographic {
half_height: scale.unwrap_or(10.0).max(1e-9),
}
}
_ => {}
}
if let Some(near) = value.get("near").and_then(|v| v.as_f64()) {
self.near = near;
}
if let Some(far) = value.get("far").and_then(|v| v.as_f64()) {
self.far = far;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unit_bbox() -> Aabb {
Aabb {
min: [-5.0, -5.0, -5.0],
max: [5.0, 5.0, 5.0],
}
}
#[test]
fn camera_state_roundtrip() {
let mut camera = ViewCamera::default();
camera.eye = [3.0, 4.0, 5.0];
camera.target = [1.0, 1.0, 1.0];
camera.projection = Projection::Orthographic { half_height: 7.25 };
let json = camera.state_json();
let mut restored = ViewCamera::default();
restored.apply_state_json(&json).unwrap();
assert_eq!(restored.eye, camera.eye);
assert_eq!(restored.target, camera.target);
assert_eq!(restored.projection, camera.projection);
}
#[test]
fn projection_toggle_preserves_apparent_size() {
let mut camera = ViewCamera {
width: 800.0,
height: 600.0,
..ViewCamera::default()
};
camera.zoom_to_fit(&unit_bbox(), 1.1);
let wpp_ortho = camera.world_per_pixel();
assert_eq!(camera.toggle_projection(), "perspective");
let wpp_persp = camera.world_per_pixel();
assert!(
(wpp_ortho - wpp_persp).abs() < wpp_ortho * 1e-9,
"wpp {wpp_ortho} vs {wpp_persp}"
);
assert_eq!(camera.toggle_projection(), "orthographic");
let wpp_back = camera.world_per_pixel();
assert!((wpp_ortho - wpp_back).abs() < wpp_ortho * 1e-9);
}
#[test]
fn zoom_to_fit_centers_and_contains_bbox() {
let bbox = Aabb {
min: [10.0, -2.0, 3.0],
max: [16.0, 6.0, 9.0],
};
let mut camera = ViewCamera {
width: 640.0,
height: 480.0,
..ViewCamera::default()
};
camera.zoom_to_fit(&bbox, 1.1);
let center = bbox.center();
let (sx, sy, depth) = camera.project(center);
assert!((sx - 320.0).abs() < 1e-6, "sx {sx}");
assert!((sy - 240.0).abs() < 1e-6, "sy {sy}");
assert!(depth > 0.0);
for i in 0..8 {
let corner = [
if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
];
let (sx, sy, _) = camera.project(corner);
assert!((-1.0..=641.0).contains(&sx), "corner sx {sx}");
assert!((-1.0..=481.0).contains(&sy), "corner sy {sy}");
}
}
#[test]
fn project_and_pick_ray_are_consistent() {
let mut camera = ViewCamera::default();
camera.zoom_to_fit(&unit_bbox(), 1.1);
let world = [1.25, -0.5, 2.0];
let (sx, sy, _) = camera.project(world);
let ray = camera.pick_ray(sx, sy);
let rel = sub3(world, ray.origin);
let along = dot3(rel, ray.dir);
let closest = add3(ray.origin, scale3(ray.dir, along));
assert!(len3(sub3(world, closest)) < 1e-9);
}
#[test]
fn depth_range_contains_scene() {
let mut camera = ViewCamera::default();
let bbox = unit_bbox();
camera.fit_depth_range(&bbox);
let (_, _, forward) = camera.basis();
for i in 0..8 {
let corner = [
if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
];
let d = dot3(sub3(corner, camera.eye), forward);
assert!(d >= camera.near && d <= camera.far);
}
}
#[test]
fn standard_views_look_at_target() {
let mut camera = ViewCamera::default();
camera.target = [2.0, 3.0, 4.0];
let dist = camera.distance();
for name in ["FRONT", "BACK", "LEFT", "RIGHT", "TOP", "BOTTOM", "ISO"] {
assert!(camera.standard_view(name), "{name}");
assert!((camera.distance() - dist).abs() < 1e-9);
}
assert!(!camera.standard_view("DIAGONAL"));
}
fn apply4(m: &[f64; 16], x: f64, y: f64, z: f64) -> [f64; 3] {
let w = 1.0 / (m[3] * x + m[7] * y + m[11] * z + m[15]);
[
(m[0] * x + m[4] * y + m[8] * z + m[12]) * w,
(m[1] * x + m[5] * y + m[9] * z + m[13]) * w,
(m[2] * x + m[6] * y + m[10] * z + m[14]) * w,
]
}
#[test]
fn view_proj_flat_matches_project() {
for persp in [false, true] {
let mut camera = ViewCamera { width: 800.0, height: 600.0, ..ViewCamera::default() };
camera.zoom_to_fit(&unit_bbox(), 1.1);
if persp {
camera.toggle_projection();
}
let vp = camera.view_proj_flat();
for world in [[1.25, -0.5, 2.0], [-3.0, 4.0, -1.5], [0.0, 0.0, 0.0]] {
let clip = apply4(&vp, world[0], world[1], world[2]);
let sx = (clip[0] * 0.5 + 0.5) * camera.width;
let sy = (0.5 - clip[1] * 0.5) * camera.height;
let (px, py, _) = camera.project(world);
assert!((sx - px).abs() < 1e-6, "persp={persp} sx {sx} vs {px}");
assert!((sy - py).abs() < 1e-6, "persp={persp} sy {sy} vs {py}");
}
}
}
#[test]
fn view_proj_inverse_round_trips_and_rays() {
for persp in [false, true] {
let mut camera = ViewCamera { width: 640.0, height: 480.0, ..ViewCamera::default() };
camera.zoom_to_fit(&unit_bbox(), 1.1);
if persp {
camera.toggle_projection();
}
let vp = camera.view_proj_flat();
let inv = camera.view_proj_inverse_flat();
let world = [1.25, -0.5, 2.0];
let clip = apply4(&vp, world[0], world[1], world[2]);
let back = apply4(&inv, clip[0], clip[1], clip[2]);
for k in 0..3 {
assert!((back[k] - world[k]).abs() < 1e-6, "persp={persp} roundtrip {back:?}");
}
let (sx, sy, _) = camera.project(world);
let ndc_x = (sx / camera.width) * 2.0 - 1.0;
let ndc_y = -((sy / camera.height) * 2.0 - 1.0);
let near = apply4(&inv, ndc_x, ndc_y, 0.0);
let far = apply4(&inv, ndc_x, ndc_y, 1.0);
let dir = norm3(sub3(far, near));
let rel = sub3(world, near);
let along = dot3(rel, dir);
let closest = add3(near, scale3(dir, along));
assert!(len3(sub3(world, closest)) < 1e-6, "persp={persp} ray miss");
}
}
}