1use crate::view::{add3, cross3, dot3, len3, norm3, rotate3, scale3, sub3, Projection, ViewCamera};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Gesture {
18 None,
19 Rotate,
20 Pan,
21}
22
23pub const BUTTON_LEFT: i32 = 0;
25pub const BUTTON_MIDDLE: i32 = 1;
26pub const BUTTON_RIGHT: i32 = 2;
27
28const ZOOM_PER_NOTCH: f64 = 1.08;
49const WHEEL_POINTS_PER_NOTCH: f64 = 70.0;
51const MAX_WHEEL_NOTCHES_PER_EVENT: f64 = 3.0;
56const MIN_ORTHO_HALF_HEIGHT: f64 = 1e-9;
57const MIN_PERSP_DISTANCE: f64 = 1e-6;
58
59#[derive(Debug, Default)]
60pub struct ArcballControls {
61 pub enabled: bool,
62 gesture: GestureState,
63}
64
65#[derive(Debug)]
66struct GestureState {
67 kind: Gesture,
68 last: (f64, f64),
69}
70
71impl Default for GestureState {
72 fn default() -> Self {
73 Self {
74 kind: Gesture::None,
75 last: (0.0, 0.0),
76 }
77 }
78}
79
80impl ArcballControls {
81 pub fn new() -> Self {
82 Self {
83 enabled: true,
84 gesture: GestureState::default(),
85 }
86 }
87
88 pub fn active_gesture(&self) -> Gesture {
89 self.gesture.kind
90 }
91
92 pub fn pointer_down(&mut self, x: f64, y: f64, button: i32) -> bool {
95 if !self.enabled {
96 return false;
97 }
98 self.gesture.kind = match button {
99 BUTTON_LEFT => Gesture::Rotate,
100 BUTTON_MIDDLE | BUTTON_RIGHT => Gesture::Pan,
101 _ => Gesture::None,
102 };
103 self.gesture.last = (x, y);
104 self.gesture.kind != Gesture::None
105 }
106
107 pub fn pointer_move(&mut self, camera: &mut ViewCamera, x: f64, y: f64) -> bool {
110 if !self.enabled || self.gesture.kind == Gesture::None {
111 return false;
112 }
113 let (lx, ly) = self.gesture.last;
114 if (x - lx).abs() < f64::EPSILON && (y - ly).abs() < f64::EPSILON {
115 return false;
116 }
117 match self.gesture.kind {
118 Gesture::Rotate => rotate_arcball(camera, (lx, ly), (x, y)),
119 Gesture::Pan => pan(camera, x - lx, y - ly),
120 Gesture::None => {}
121 }
122 self.gesture.last = (x, y);
123 true
124 }
125
126 pub fn pointer_up(&mut self) -> bool {
128 let was = self.gesture.kind != Gesture::None;
129 self.gesture.kind = Gesture::None;
130 was
131 }
132
133 pub fn wheel(&mut self, camera: &mut ViewCamera, delta_y: f64, cursor: Option<[f64; 2]>) -> bool {
137 if !self.enabled || delta_y == 0.0 {
138 return false;
139 }
140 let notches = (delta_y / WHEEL_POINTS_PER_NOTCH)
144 .clamp(-MAX_WHEEL_NOTCHES_PER_EVENT, MAX_WHEEL_NOTCHES_PER_EVENT);
145 let factor = ZOOM_PER_NOTCH.powf(notches);
146 match cursor {
147 Some([cx, cy]) => zoom_toward(camera, factor, cx, cy),
148 None => zoom(camera, factor),
149 }
150 true
151 }
152}
153
154pub fn zoom_toward(camera: &mut ViewCamera, factor: f64, cx: f64, cy: f64) {
157 let wpp = camera.world_per_pixel();
158 let (right, up, _) = camera.basis();
159 let sx = cx - camera.width * 0.5;
160 let sy = -(cy - camera.height * 0.5); let off = add3(scale3(right, sx * wpp), scale3(up, sy * wpp));
163 match &mut camera.projection {
164 Projection::Orthographic { half_height } => {
165 let old = *half_height;
166 *half_height = (old * factor).max(MIN_ORTHO_HALF_HEIGHT);
167 let f = *half_height / old; let shift = scale3(off, 1.0 - f);
169 camera.target = add3(camera.target, shift);
170 camera.eye = add3(camera.eye, shift);
171 }
172 Projection::Perspective { .. } => {
173 let cursor_world = add3(camera.target, off);
176 let nt = add3(cursor_world, scale3(sub3(camera.target, cursor_world), factor));
177 let ne = add3(cursor_world, scale3(sub3(camera.eye, cursor_world), factor));
178 let dir = sub3(ne, nt);
179 let dist = len3(dir).max(MIN_PERSP_DISTANCE);
180 camera.target = nt;
181 camera.eye = add3(nt, scale3(norm3(dir), dist));
182 }
183 }
184}
185
186pub fn zoom(camera: &mut ViewCamera, factor: f64) {
188 match &mut camera.projection {
189 Projection::Orthographic { half_height } => {
190 *half_height = (*half_height * factor).max(MIN_ORTHO_HALF_HEIGHT);
191 }
192 Projection::Perspective { .. } => {
193 let dir = sub3(camera.eye, camera.target);
194 let dist = (len3(dir) * factor).max(MIN_PERSP_DISTANCE);
195 camera.eye = add3(camera.target, scale3(norm3(dir), dist));
196 }
197 }
198}
199
200pub fn pan(camera: &mut ViewCamera, dx: f64, dy: f64) {
203 let (right, up, _) = camera.basis();
204 let wpp = camera.world_per_pixel();
205 let offset = add3(scale3(right, -dx * wpp), scale3(up, dy * wpp));
206 camera.eye = add3(camera.eye, offset);
207 camera.target = add3(camera.target, offset);
208}
209
210fn trackball_point(camera: &ViewCamera, x: f64, y: f64) -> [f64; 3] {
214 let radius = 0.5 * camera.width.min(camera.height).max(1.0) * 0.75;
215 let cx = camera.width * 0.5;
216 let cy = camera.height * 0.5;
217 let px = x - cx;
218 let py = cy - y; let r2 = radius * radius;
220 let d2 = px * px + py * py;
221 let pz = if d2 <= r2 * 0.5 {
222 (r2 - d2).sqrt()
223 } else {
224 r2 * 0.5 / d2.sqrt()
226 };
227 norm3([px, py, pz])
228}
229
230fn rotate_arcball(camera: &mut ViewCamera, from: (f64, f64), to: (f64, f64)) {
234 let v0 = trackball_point(camera, from.0, from.1);
235 let v1 = trackball_point(camera, to.0, to.1);
236 let axis_cam = cross3(v0, v1);
237 let axis_len = len3(axis_cam);
238 if axis_len < 1e-12 {
239 return;
240 }
241 let angle = dot3(v0, v1).clamp(-1.0, 1.0).acos();
242 if angle.abs() < 1e-12 {
243 return;
244 }
245 let (right, up, forward) = camera.basis();
248 let axis_cam = scale3(axis_cam, 1.0 / axis_len);
249 let axis_world = norm3(add3(
250 add3(scale3(right, axis_cam[0]), scale3(up, axis_cam[1])),
251 scale3(forward, -axis_cam[2]),
252 ));
253 let offset = sub3(camera.eye, camera.target);
254 camera.eye = add3(camera.target, rotate3(offset, axis_world, -angle));
255 camera.up = norm3(rotate3(camera.up, axis_world, -angle));
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 fn camera() -> ViewCamera {
263 ViewCamera {
264 eye: [0.0, 0.0, 20.0],
265 target: [0.0, 0.0, 0.0],
266 up: [0.0, 1.0, 0.0],
267 projection: Projection::Orthographic { half_height: 10.0 },
268 width: 800.0,
269 height: 600.0,
270 near: -1000.0,
271 far: 1000.0,
272 }
273 }
274
275 #[test]
276 fn wheel_zoom_scales_world_per_pixel() {
277 let mut cam = camera();
278 let mut controls = ArcballControls::new();
279 let wpp0 = cam.world_per_pixel();
280 assert!(controls.wheel(&mut cam, WHEEL_POINTS_PER_NOTCH, None));
283 let wpp1 = cam.world_per_pixel();
284 assert!(
285 (wpp1 / wpp0 - ZOOM_PER_NOTCH).abs() < 1e-12,
286 "ratio {}",
287 wpp1 / wpp0
288 );
289 assert!(controls.wheel(&mut cam, -WHEEL_POINTS_PER_NOTCH, None));
292 assert!((cam.world_per_pixel() - wpp0).abs() < 1e-12);
293 }
294
295 #[test]
296 fn wheel_one_notch_is_a_gentle_step() {
297 assert!(
299 (1.05..=1.12).contains(&ZOOM_PER_NOTCH),
300 "ZOOM_PER_NOTCH {} out of the gentle band",
301 ZOOM_PER_NOTCH
302 );
303 for &(notch_points, label) in &[(40.0_f64, "native"), (100.0_f64, "browser")] {
307 let mut cam = camera();
309 let mut controls = ArcballControls::new();
310 let wpp0 = cam.world_per_pixel();
311 assert!(controls.wheel(&mut cam, notch_points, None));
312 let ratio_out = cam.world_per_pixel() / wpp0;
313 assert!(
314 (1.03..=1.13).contains(&ratio_out),
315 "{label} zoom-out ratio {ratio_out} outside the 3–13% gentle band"
316 );
317 let mut cam2 = camera();
319 let mut controls2 = ArcballControls::new();
320 assert!(controls2.wheel(&mut cam2, -notch_points, None));
321 assert!(
322 cam2.world_per_pixel() < wpp0,
323 "{label}: scroll up should zoom in (world-per-pixel should shrink)"
324 );
325 }
326 }
327
328 #[test]
329 fn pan_moves_scene_with_cursor() {
330 let mut cam = camera();
331 let mut controls = ArcballControls::new();
332 assert!(controls.pointer_down(400.0, 300.0, BUTTON_RIGHT));
333 assert!(controls.pointer_move(&mut cam, 500.0, 300.0));
334 assert!(controls.pointer_up());
335 let wpp = cam.world_per_pixel();
338 assert!((cam.eye[0] + 100.0 * wpp).abs() < 1e-9, "eye.x {}", cam.eye[0]);
339 assert!((cam.target[0] + 100.0 * wpp).abs() < 1e-9);
340 assert!((cam.eye[2] - 20.0).abs() < 1e-9);
342 }
343
344 #[test]
345 fn arcball_rotate_preserves_distance_and_orthonormality() {
346 let mut cam = camera();
347 let mut controls = ArcballControls::new();
348 let dist0 = cam.distance();
349 assert!(controls.pointer_down(200.0, 200.0, BUTTON_LEFT));
350 for i in 1..=10 {
352 controls.pointer_move(&mut cam, 200.0 + (i as f64) * 25.0, 200.0 + (i as f64) * 12.0);
353 }
354 controls.pointer_up();
355 assert!((cam.distance() - dist0).abs() < 1e-9);
356 let (right, up, forward) = cam.basis();
358 assert!((len3(up) - 1.0).abs() < 1e-9);
359 assert!(dot3(right, up).abs() < 1e-9);
360 assert!(dot3(up, forward).abs() < 1e-9);
361 assert!(len3(sub3(cam.eye, [0.0, 0.0, 20.0])) > 1.0);
363 }
364
365 #[test]
366 fn horizontal_drag_from_center_orbits_azimuth() {
367 let mut cam = camera();
368 let mut controls = ArcballControls::new();
369 controls.pointer_down(400.0, 300.0, BUTTON_LEFT);
370 controls.pointer_move(&mut cam, 430.0, 300.0);
371 controls.pointer_up();
372 assert!(cam.eye[0] < -1e-3, "eye.x {}", cam.eye[0]);
374 assert!((cam.eye[1]).abs() < 1e-6, "eye.y {}", cam.eye[1]);
375 let mut cam2 = camera();
377 let mut controls2 = ArcballControls::new();
378 controls2.pointer_down(400.0, 300.0, BUTTON_LEFT);
379 controls2.pointer_move(&mut cam2, 430.0, 300.0);
380 controls2.pointer_up();
381 assert_eq!(cam.eye, cam2.eye);
382 assert_eq!(cam.up, cam2.up);
383 }
384
385 #[test]
386 fn disabled_controls_ignore_input() {
387 let mut cam = camera();
388 let mut controls = ArcballControls::new();
389 controls.enabled = false;
390 assert!(!controls.pointer_down(0.0, 0.0, BUTTON_LEFT));
391 assert!(!controls.pointer_move(&mut cam, 50.0, 50.0));
392 assert!(!controls.wheel(&mut cam, 100.0, None));
393 assert_eq!(cam.eye, camera().eye);
394 }
395}