#[derive(Clone, Copy, Debug, PartialEq)]
pub struct InputFeel {
pub orbit_per_px: f32,
pub pan_per_px: f32,
pub dolly_per_scroll: f32,
pub damping: f32,
pub natural_scroll: bool,
}
impl Default for InputFeel {
fn default() -> Self {
Self {
orbit_per_px: 0.008,
pan_per_px: 0.0022,
dolly_per_scroll: 0.0015,
damping: 16.0,
natural_scroll: false,
}
}
}
impl InputFeel {
pub fn macos() -> Self {
Self {
orbit_per_px: 0.009,
pan_per_px: 0.0024,
dolly_per_scroll: 0.0020,
damping: 13.0,
natural_scroll: true,
}
}
pub fn windows() -> Self {
Self::default()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct V3 {
pub x: f32,
pub y: f32,
pub z: f32,
}
impl V3 {
pub const fn new(x: f32, y: f32, z: f32) -> Self {
Self { x, y, z }
}
pub fn add(self, o: V3) -> V3 {
V3::new(self.x + o.x, self.y + o.y, self.z + o.z)
}
pub fn sub(self, o: V3) -> V3 {
V3::new(self.x - o.x, self.y - o.y, self.z - o.z)
}
pub fn scale(self, s: f32) -> V3 {
V3::new(self.x * s, self.y * s, self.z * s)
}
pub fn dot(self, o: V3) -> f32 {
self.x * o.x + self.y * o.y + self.z * o.z
}
pub fn cross(self, o: V3) -> V3 {
V3::new(
self.y * o.z - self.z * o.y,
self.z * o.x - self.x * o.z,
self.x * o.y - self.y * o.x,
)
}
pub fn len(self) -> f32 {
self.dot(self).sqrt()
}
pub fn normalized(self) -> V3 {
let l = self.len().max(1e-9);
self.scale(1.0 / l)
}
}
#[derive(Clone, Copy, Debug)]
pub struct Projected {
pub x: f32,
pub y: f32,
pub depth: f32,
pub visible: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pos {
pub x: f32,
pub y: f32,
}
impl Pos {
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
}
#[derive(Clone, Copy, Debug)]
pub struct Camera {
pub pan_x: f32,
pub pan_y: f32,
pub zoom: f32,
pub target: V3,
pub azimuth: f32,
pub elevation: f32,
pub distance: f32,
pub fov_y: f32,
pub feel: InputFeel,
}
impl Default for Camera {
fn default() -> Self {
Self {
pan_x: 0.0,
pan_y: 0.0,
zoom: 1.0,
target: V3::new(0.0, 0.0, 0.0),
azimuth: 0.0,
elevation: 0.0,
distance: 3.2,
fov_y: 50f32.to_radians(),
feel: InputFeel::default(),
}
}
}
impl Camera {
pub const NEAR_PLANE: f32 = 0.02;
#[inline]
pub fn project2d(&self, p: Pos) -> (f32, f32) {
(p.x * self.zoom + self.pan_x, p.y * self.zoom + self.pan_y)
}
pub fn far_plane(&self) -> f32 {
const FAR_PAD: f32 = 4.0;
self.distance + FAR_PAD
}
pub fn eye(&self) -> V3 {
let (se, ce) = self.elevation.sin_cos();
let (sa, ca) = self.azimuth.sin_cos();
let offset = V3::new(ce * sa, se, ce * ca).scale(self.distance);
self.target.add(offset)
}
pub fn basis(&self) -> (V3, V3, V3) {
let fwd = self.target.sub(self.eye()).normalized();
let world_up = V3::new(0.0, 1.0, 0.0);
let right = fwd.cross(world_up).normalized();
let up = right.cross(fwd).normalized();
(fwd, right, up)
}
pub fn view_space(&self, p: V3) -> V3 {
let (fwd, right, up) = self.basis();
let rel = p.sub(self.eye());
V3::new(rel.dot(right), rel.dot(up), rel.dot(fwd))
}
pub fn project_view(&self, c: V3, center: (f32, f32), half_h: f32) -> Projected {
if c.z <= Self::NEAR_PLANE || c.z >= self.far_plane() {
return Projected { x: center.0, y: center.1, depth: c.z, visible: false };
}
let focal = half_h / (self.fov_y * 0.5).tan();
let sx = center.0 + (c.x / c.z) * focal;
let sy = center.1 - (c.y / c.z) * focal;
Projected { x: sx, y: sy, depth: c.z, visible: true }
}
pub fn project_view_world(&self, p: V3, center: (f32, f32), half_h: f32) -> Projected {
self.project_view(self.view_space(p), center, half_h)
}
pub fn view_proj(&self, aspect: f32) -> [f32; 16] {
let (fwd, right, up) = self.basis();
let eye = self.eye();
let tx = -right.dot(eye);
let ty = -up.dot(eye);
let tz = -fwd.dot(eye);
let near = (self.distance - 2.0).max(0.05);
let far = self.distance + 4.0;
let f = 1.0 / (self.fov_y * 0.5).tan();
let sx = f / aspect.max(1e-6);
let sy = f;
let a = far / (far - near);
let b = -far * near / (far - near);
let mut m = [0.0f32; 16];
let set = |m: &mut [f32; 16], row: usize, col: usize, v: f32| m[col * 4 + row] = v;
set(&mut m, 0, 0, sx * right.x);
set(&mut m, 0, 1, sx * right.y);
set(&mut m, 0, 2, sx * right.z);
set(&mut m, 0, 3, sx * tx);
set(&mut m, 1, 0, sy * up.x);
set(&mut m, 1, 1, sy * up.y);
set(&mut m, 1, 2, sy * up.z);
set(&mut m, 1, 3, sy * ty);
set(&mut m, 2, 0, a * fwd.x);
set(&mut m, 2, 1, a * fwd.y);
set(&mut m, 2, 2, a * fwd.z);
set(&mut m, 2, 3, a * tz + b);
set(&mut m, 3, 0, fwd.x);
set(&mut m, 3, 1, fwd.y);
set(&mut m, 3, 2, fwd.z);
set(&mut m, 3, 3, tz);
m
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_feel_presets_keep_their_constants() {
let w = InputFeel::windows();
assert_eq!(w, InputFeel::default());
assert!(!w.natural_scroll);
let m = InputFeel::macos();
assert!(m.natural_scroll, "macOS trackpad pans natural");
assert!(m.damping < w.damping, "macOS floatier");
assert!((m.orbit_per_px - 0.009).abs() < 1e-9);
assert!((w.orbit_per_px - 0.008).abs() < 1e-9);
}
#[test]
fn camera_seam_2d_matches_graphview_affine() {
let cam = Camera { pan_x: 30.0, pan_y: -12.0, zoom: 2.5, ..Camera::default() };
let p = Pos::new(10.0, 4.0);
let (sx, sy) = cam.project2d(p);
let rx = p.x * 2.5 + 30.0;
let ry = p.y * 2.5 - 12.0;
assert!((sx - rx).abs() < 1e-6 && (sy - ry).abs() < 1e-6, "2D affine matches graphview");
}
#[test]
fn camera_seam_3d_matches_orbit_camera_math() {
let cam = Camera {
azimuth: 0.0,
elevation: 0.0,
distance: 5.0,
target: V3::new(0.0, 0.0, 0.0),
fov_y: 50f32.to_radians(),
..Camera::default()
};
let r = cam.eye().sub(cam.target).len();
assert!((r - 5.0).abs() < 1e-4, "eye radius == distance");
let center = (400.0, 300.0);
let mid = cam.project_view_world(V3::new(0.0, 0.0, 0.0), center, 300.0);
assert!(mid.visible);
assert!((mid.x - center.0).abs() < 1.0 && (mid.y - center.1).abs() < 1.0, "target → centre");
let near_pt = cam.project_view_world(V3::new(0.5, 0.0, 2.0), center, 300.0);
let far_pt = cam.project_view_world(V3::new(0.5, 0.0, -2.0), center, 300.0);
assert!(
(near_pt.x - center.0).abs() > (far_pt.x - center.0).abs(),
"nearer projects wider (perspective, matching OrbitCamera)"
);
let behind = cam.project_view_world(cam.eye().add(cam.basis().0.scale(-1.0)), center, 300.0);
assert!(!behind.visible, "behind-eye culled");
assert_eq!(Camera::NEAR_PLANE, 0.02);
let m = cam.view_proj(800.0 / 600.0);
let apply = |p: V3| {
let v = [p.x, p.y, p.z, 1.0];
let mut o = [0.0f32; 4];
for row in 0..4 {
let mut s = 0.0;
for col in 0..4 {
s += m[col * 4 + row] * v[col];
}
o[row] = s;
}
o
};
let nr = apply(V3::new(0.0, 0.0, 2.0));
let fr = apply(V3::new(0.0, 0.0, -2.0));
assert!(nr[3] > 0.0 && fr[3] > 0.0, "both in front");
let zn = nr[2] / nr[3];
let zf = fr[2] / fr[3];
assert!(zn < zf, "nearer smaller depth");
assert!((0.0..=1.0).contains(&zn) && (0.0..=1.0).contains(&zf), "depths in [0,1]");
}
}