Skip to main content

volren_core/interaction/
trackball.rs

1//! Trackball interaction style for 3D volume exploration.
2//!
3//! Implements an arcball-style camera:
4//! - **Left drag**   → orbit (arcball rotation)
5//! - **Right drag**  → dolly (zoom in/out)
6//! - **Middle drag** → pan
7//! - **Scroll**      → dolly
8//! - **`r` key**     → reset to initial camera (requires consumer to set)
9//!
10//! # VTK Equivalent
11//! `vtkInteractorStyleTrackballCamera`
12
13use super::{
14    events::{InteractionContext, InteractionResult, MouseEventKind},
15    InteractionStyle, KeyEvent, MouseButton, MouseEvent,
16};
17use crate::camera::Camera;
18
19/// State of active drag.
20#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
21enum DragState {
22    #[default]
23    None,
24    Orbiting,
25    Dollying,
26    Panning,
27}
28
29/// Trackball (arcball) camera interaction style.
30///
31/// **Sensitivity** values control the mapping from pixel deltas to angles/units:
32/// - `orbit_sensitivity` — radians per pixel (default 0.005)
33/// - `pan_sensitivity`   — world units per pixel (default 0.001 × distance)
34/// - `zoom_sensitivity`  — factor per scroll tick (default 0.1)
35#[derive(Debug)]
36pub struct TrackballStyle {
37    drag: DragState,
38    last_pos: (f64, f64),
39    /// Radians per pixel for orbit.
40    pub orbit_sensitivity: f64,
41    /// Scroll zoom: factor per scroll unit (subtracted from 1.0).
42    pub zoom_sensitivity: f64,
43}
44
45impl TrackballStyle {
46    /// Create with default sensitivities.
47    #[must_use]
48    pub fn new() -> Self {
49        Self {
50            drag: DragState::None,
51            last_pos: (0.0, 0.0),
52            orbit_sensitivity: 0.005,
53            zoom_sensitivity: 0.1,
54        }
55    }
56}
57
58impl Default for TrackballStyle {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl InteractionStyle for TrackballStyle {
65    fn on_mouse_event(
66        &mut self,
67        event: &MouseEvent,
68        _ctx: &InteractionContext,
69        camera: &mut Camera,
70    ) -> InteractionResult {
71        match event.kind {
72            MouseEventKind::Press(button) => {
73                self.last_pos = event.position;
74                self.drag = match button {
75                    MouseButton::Left => DragState::Orbiting,
76                    MouseButton::Right => DragState::Dollying,
77                    MouseButton::Middle => DragState::Panning,
78                };
79                InteractionResult::nothing()
80            }
81
82            MouseEventKind::Release(_) => {
83                self.drag = DragState::None;
84                InteractionResult::nothing()
85            }
86
87            MouseEventKind::Move => {
88                let dx = event.position.0 - self.last_pos.0;
89                let dy = event.position.1 - self.last_pos.1;
90                self.last_pos = event.position;
91
92                if dx == 0.0 && dy == 0.0 {
93                    return InteractionResult::nothing();
94                }
95
96                match self.drag {
97                    DragState::None => return InteractionResult::nothing(),
98
99                    DragState::Orbiting => {
100                        let angle_h = dx * self.orbit_sensitivity;
101                        let angle_v = dy * self.orbit_sensitivity;
102                        camera.orbit(angle_h, angle_v);
103                    }
104
105                    DragState::Dollying => {
106                        let delta = dy * camera.distance() * 0.01;
107                        camera.dolly(delta);
108                    }
109
110                    DragState::Panning => {
111                        let scale = camera.distance() * 0.001;
112                        let right = camera.right();
113                        let up = camera.view_up_ortho();
114                        camera.pan(-right * dx * scale + up * dy * scale);
115                    }
116                }
117
118                InteractionResult::camera_only()
119            }
120
121            MouseEventKind::Scroll(delta) => {
122                let factor = if delta > 0.0 {
123                    1.0 - self.zoom_sensitivity * delta.abs()
124                } else {
125                    1.0 + self.zoom_sensitivity * delta.abs()
126                };
127                camera.zoom(factor.max(0.01));
128                InteractionResult::camera_only()
129            }
130        }
131    }
132
133    fn on_key_event(
134        &mut self,
135        _event: &KeyEvent,
136        _ctx: &InteractionContext,
137        _camera: &mut Camera,
138    ) -> InteractionResult {
139        InteractionResult::nothing()
140    }
141}
142
143// ── Tests ─────────────────────────────────────────────────────────────────────
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::interaction::Modifiers;
149    use approx::assert_abs_diff_eq;
150    use glam::DVec3;
151
152    fn default_camera() -> Camera {
153        Camera::new(DVec3::new(0.0, 0.0, 10.0), DVec3::ZERO, DVec3::Y)
154    }
155
156    fn ctx() -> InteractionContext {
157        InteractionContext {
158            viewport_width: 800.0,
159            viewport_height: 600.0,
160            volume_bounds: None,
161        }
162    }
163
164    fn mouse(pos: (f64, f64), kind: MouseEventKind) -> MouseEvent {
165        MouseEvent {
166            position: pos,
167            kind,
168            modifiers: Modifiers::default(),
169        }
170    }
171
172    #[test]
173    fn press_and_move_orbits() {
174        let mut style = TrackballStyle::new();
175        let mut cam = default_camera();
176        let d0 = cam.distance();
177
178        let r1 = style.on_mouse_event(
179            &mouse((0.0, 0.0), MouseEventKind::Press(MouseButton::Left)),
180            &ctx(),
181            &mut cam,
182        );
183        assert!(!r1.camera_changed);
184
185        let r2 = style.on_mouse_event(&mouse((50.0, 0.0), MouseEventKind::Move), &ctx(), &mut cam);
186        assert!(r2.camera_changed);
187        // Distance preserved after orbit
188        assert_abs_diff_eq!(cam.distance(), d0, epsilon = 1e-6);
189    }
190
191    #[test]
192    fn scroll_zooms_camera() {
193        let mut style = TrackballStyle::new();
194        let mut cam = default_camera();
195        let d0 = cam.distance();
196
197        style.on_mouse_event(
198            &mouse((400.0, 300.0), MouseEventKind::Scroll(1.0)),
199            &ctx(),
200            &mut cam,
201        );
202        assert!(cam.distance() < d0, "scroll should zoom in");
203    }
204
205    #[test]
206    fn no_drag_move_does_nothing() {
207        let mut style = TrackballStyle::new();
208        let mut cam = default_camera();
209        let pos0 = cam.position();
210
211        style.on_mouse_event(
212            &mouse((100.0, 100.0), MouseEventKind::Move),
213            &ctx(),
214            &mut cam,
215        );
216        assert_abs_diff_eq!(cam.position().x, pos0.x, epsilon = 1e-10);
217    }
218}