use crate::view::{add3, cross3, dot3, len3, norm3, rotate3, scale3, sub3, Projection, ViewCamera};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gesture {
None,
Rotate,
Pan,
}
pub const BUTTON_LEFT: i32 = 0;
pub const BUTTON_MIDDLE: i32 = 1;
pub const BUTTON_RIGHT: i32 = 2;
const ZOOM_PER_NOTCH: f64 = 1.08;
const WHEEL_POINTS_PER_NOTCH: f64 = 70.0;
const MAX_WHEEL_NOTCHES_PER_EVENT: f64 = 3.0;
const MIN_ORTHO_HALF_HEIGHT: f64 = 1e-9;
const MIN_PERSP_DISTANCE: f64 = 1e-6;
#[derive(Debug, Default)]
pub struct ArcballControls {
pub enabled: bool,
gesture: GestureState,
}
#[derive(Debug)]
struct GestureState {
kind: Gesture,
last: (f64, f64),
}
impl Default for GestureState {
fn default() -> Self {
Self {
kind: Gesture::None,
last: (0.0, 0.0),
}
}
}
impl ArcballControls {
pub fn new() -> Self {
Self {
enabled: true,
gesture: GestureState::default(),
}
}
pub fn active_gesture(&self) -> Gesture {
self.gesture.kind
}
pub fn pointer_down(&mut self, x: f64, y: f64, button: i32) -> bool {
if !self.enabled {
return false;
}
self.gesture.kind = match button {
BUTTON_LEFT => Gesture::Rotate,
BUTTON_MIDDLE | BUTTON_RIGHT => Gesture::Pan,
_ => Gesture::None,
};
self.gesture.last = (x, y);
self.gesture.kind != Gesture::None
}
pub fn pointer_move(&mut self, camera: &mut ViewCamera, x: f64, y: f64) -> bool {
if !self.enabled || self.gesture.kind == Gesture::None {
return false;
}
let (lx, ly) = self.gesture.last;
if (x - lx).abs() < f64::EPSILON && (y - ly).abs() < f64::EPSILON {
return false;
}
match self.gesture.kind {
Gesture::Rotate => rotate_arcball(camera, (lx, ly), (x, y)),
Gesture::Pan => pan(camera, x - lx, y - ly),
Gesture::None => {}
}
self.gesture.last = (x, y);
true
}
pub fn pointer_up(&mut self) -> bool {
let was = self.gesture.kind != Gesture::None;
self.gesture.kind = Gesture::None;
was
}
pub fn wheel(&mut self, camera: &mut ViewCamera, delta_y: f64, cursor: Option<[f64; 2]>) -> bool {
if !self.enabled || delta_y == 0.0 {
return false;
}
let notches = (delta_y / WHEEL_POINTS_PER_NOTCH)
.clamp(-MAX_WHEEL_NOTCHES_PER_EVENT, MAX_WHEEL_NOTCHES_PER_EVENT);
let factor = ZOOM_PER_NOTCH.powf(notches);
match cursor {
Some([cx, cy]) => zoom_toward(camera, factor, cx, cy),
None => zoom(camera, factor),
}
true
}
}
pub fn zoom_toward(camera: &mut ViewCamera, factor: f64, cx: f64, cy: f64) {
let wpp = camera.world_per_pixel();
let (right, up, _) = camera.basis();
let sx = cx - camera.width * 0.5;
let sy = -(cy - camera.height * 0.5); let off = add3(scale3(right, sx * wpp), scale3(up, sy * wpp));
match &mut camera.projection {
Projection::Orthographic { half_height } => {
let old = *half_height;
*half_height = (old * factor).max(MIN_ORTHO_HALF_HEIGHT);
let f = *half_height / old; let shift = scale3(off, 1.0 - f);
camera.target = add3(camera.target, shift);
camera.eye = add3(camera.eye, shift);
}
Projection::Perspective { .. } => {
let cursor_world = add3(camera.target, off);
let nt = add3(cursor_world, scale3(sub3(camera.target, cursor_world), factor));
let ne = add3(cursor_world, scale3(sub3(camera.eye, cursor_world), factor));
let dir = sub3(ne, nt);
let dist = len3(dir).max(MIN_PERSP_DISTANCE);
camera.target = nt;
camera.eye = add3(nt, scale3(norm3(dir), dist));
}
}
}
pub fn zoom(camera: &mut ViewCamera, factor: f64) {
match &mut camera.projection {
Projection::Orthographic { half_height } => {
*half_height = (*half_height * factor).max(MIN_ORTHO_HALF_HEIGHT);
}
Projection::Perspective { .. } => {
let dir = sub3(camera.eye, camera.target);
let dist = (len3(dir) * factor).max(MIN_PERSP_DISTANCE);
camera.eye = add3(camera.target, scale3(norm3(dir), dist));
}
}
}
pub fn pan(camera: &mut ViewCamera, dx: f64, dy: f64) {
let (right, up, _) = camera.basis();
let wpp = camera.world_per_pixel();
let offset = add3(scale3(right, -dx * wpp), scale3(up, dy * wpp));
camera.eye = add3(camera.eye, offset);
camera.target = add3(camera.target, offset);
}
fn trackball_point(camera: &ViewCamera, x: f64, y: f64) -> [f64; 3] {
let radius = 0.5 * camera.width.min(camera.height).max(1.0) * 0.75;
let cx = camera.width * 0.5;
let cy = camera.height * 0.5;
let px = x - cx;
let py = cy - y; let r2 = radius * radius;
let d2 = px * px + py * py;
let pz = if d2 <= r2 * 0.5 {
(r2 - d2).sqrt()
} else {
r2 * 0.5 / d2.sqrt()
};
norm3([px, py, pz])
}
fn rotate_arcball(camera: &mut ViewCamera, from: (f64, f64), to: (f64, f64)) {
let v0 = trackball_point(camera, from.0, from.1);
let v1 = trackball_point(camera, to.0, to.1);
let axis_cam = cross3(v0, v1);
let axis_len = len3(axis_cam);
if axis_len < 1e-12 {
return;
}
let angle = dot3(v0, v1).clamp(-1.0, 1.0).acos();
if angle.abs() < 1e-12 {
return;
}
let (right, up, forward) = camera.basis();
let axis_cam = scale3(axis_cam, 1.0 / axis_len);
let axis_world = norm3(add3(
add3(scale3(right, axis_cam[0]), scale3(up, axis_cam[1])),
scale3(forward, -axis_cam[2]),
));
let offset = sub3(camera.eye, camera.target);
camera.eye = add3(camera.target, rotate3(offset, axis_world, -angle));
camera.up = norm3(rotate3(camera.up, axis_world, -angle));
}
#[cfg(test)]
mod tests {
use super::*;
fn camera() -> ViewCamera {
ViewCamera {
eye: [0.0, 0.0, 20.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: -1000.0,
far: 1000.0,
}
}
#[test]
fn wheel_zoom_scales_world_per_pixel() {
let mut cam = camera();
let mut controls = ArcballControls::new();
let wpp0 = cam.world_per_pixel();
assert!(controls.wheel(&mut cam, WHEEL_POINTS_PER_NOTCH, None));
let wpp1 = cam.world_per_pixel();
assert!(
(wpp1 / wpp0 - ZOOM_PER_NOTCH).abs() < 1e-12,
"ratio {}",
wpp1 / wpp0
);
assert!(controls.wheel(&mut cam, -WHEEL_POINTS_PER_NOTCH, None));
assert!((cam.world_per_pixel() - wpp0).abs() < 1e-12);
}
#[test]
fn wheel_one_notch_is_a_gentle_step() {
assert!(
(1.05..=1.12).contains(&ZOOM_PER_NOTCH),
"ZOOM_PER_NOTCH {} out of the gentle band",
ZOOM_PER_NOTCH
);
for &(notch_points, label) in &[(40.0_f64, "native"), (100.0_f64, "browser")] {
let mut cam = camera();
let mut controls = ArcballControls::new();
let wpp0 = cam.world_per_pixel();
assert!(controls.wheel(&mut cam, notch_points, None));
let ratio_out = cam.world_per_pixel() / wpp0;
assert!(
(1.03..=1.13).contains(&ratio_out),
"{label} zoom-out ratio {ratio_out} outside the 3–13% gentle band"
);
let mut cam2 = camera();
let mut controls2 = ArcballControls::new();
assert!(controls2.wheel(&mut cam2, -notch_points, None));
assert!(
cam2.world_per_pixel() < wpp0,
"{label}: scroll up should zoom in (world-per-pixel should shrink)"
);
}
}
#[test]
fn pan_moves_scene_with_cursor() {
let mut cam = camera();
let mut controls = ArcballControls::new();
assert!(controls.pointer_down(400.0, 300.0, BUTTON_RIGHT));
assert!(controls.pointer_move(&mut cam, 500.0, 300.0));
assert!(controls.pointer_up());
let wpp = cam.world_per_pixel();
assert!((cam.eye[0] + 100.0 * wpp).abs() < 1e-9, "eye.x {}", cam.eye[0]);
assert!((cam.target[0] + 100.0 * wpp).abs() < 1e-9);
assert!((cam.eye[2] - 20.0).abs() < 1e-9);
}
#[test]
fn arcball_rotate_preserves_distance_and_orthonormality() {
let mut cam = camera();
let mut controls = ArcballControls::new();
let dist0 = cam.distance();
assert!(controls.pointer_down(200.0, 200.0, BUTTON_LEFT));
for i in 1..=10 {
controls.pointer_move(&mut cam, 200.0 + (i as f64) * 25.0, 200.0 + (i as f64) * 12.0);
}
controls.pointer_up();
assert!((cam.distance() - dist0).abs() < 1e-9);
let (right, up, forward) = cam.basis();
assert!((len3(up) - 1.0).abs() < 1e-9);
assert!(dot3(right, up).abs() < 1e-9);
assert!(dot3(up, forward).abs() < 1e-9);
assert!(len3(sub3(cam.eye, [0.0, 0.0, 20.0])) > 1.0);
}
#[test]
fn horizontal_drag_from_center_orbits_azimuth() {
let mut cam = camera();
let mut controls = ArcballControls::new();
controls.pointer_down(400.0, 300.0, BUTTON_LEFT);
controls.pointer_move(&mut cam, 430.0, 300.0);
controls.pointer_up();
assert!(cam.eye[0] < -1e-3, "eye.x {}", cam.eye[0]);
assert!((cam.eye[1]).abs() < 1e-6, "eye.y {}", cam.eye[1]);
let mut cam2 = camera();
let mut controls2 = ArcballControls::new();
controls2.pointer_down(400.0, 300.0, BUTTON_LEFT);
controls2.pointer_move(&mut cam2, 430.0, 300.0);
controls2.pointer_up();
assert_eq!(cam.eye, cam2.eye);
assert_eq!(cam.up, cam2.up);
}
#[test]
fn disabled_controls_ignore_input() {
let mut cam = camera();
let mut controls = ArcballControls::new();
controls.enabled = false;
assert!(!controls.pointer_down(0.0, 0.0, BUTTON_LEFT));
assert!(!controls.pointer_move(&mut cam, 50.0, 50.0));
assert!(!controls.wheel(&mut cam, 100.0, None));
assert_eq!(cam.eye, camera().eye);
}
}