Another project
1use bone_types::{AxisAngle, Camera3, OrbitState, Result, ZoomFactor};
2
3use crate::camera::ViewportExtent;
4use crate::camera3::{
5 ViewportPoint, arcball_rotation, orbit_about_point, pan_pixels, roll_about_view,
6 zoom_about_pixel,
7};
8
9const ZOOM_DRAG_PER_PIXEL: f64 = 1.0075;
10
11#[derive(Copy, Clone, Debug, PartialEq, Eq)]
12pub struct DragModifiers {
13 ctrl: bool,
14 shift: bool,
15 alt: bool,
16}
17
18impl DragModifiers {
19 pub const NONE: Self = Self {
20 ctrl: false,
21 shift: false,
22 alt: false,
23 };
24
25 #[must_use]
26 pub const fn with_ctrl(self) -> Self {
27 Self {
28 ctrl: true,
29 shift: self.shift,
30 alt: self.alt,
31 }
32 }
33
34 #[must_use]
35 pub const fn with_shift(self) -> Self {
36 Self {
37 ctrl: self.ctrl,
38 shift: true,
39 alt: self.alt,
40 }
41 }
42
43 #[must_use]
44 pub const fn with_alt(self) -> Self {
45 Self {
46 ctrl: self.ctrl,
47 shift: self.shift,
48 alt: true,
49 }
50 }
51
52 #[must_use]
53 pub const fn gesture(self) -> NavGesture {
54 if self.ctrl {
55 NavGesture::Pan
56 } else if self.shift {
57 NavGesture::Zoom
58 } else if self.alt {
59 NavGesture::Roll
60 } else {
61 NavGesture::Orbit
62 }
63 }
64}
65
66#[derive(Copy, Clone, Debug, PartialEq, Eq)]
67pub enum NavGesture {
68 Orbit,
69 Pan,
70 Roll,
71 Zoom,
72}
73
74#[derive(Copy, Clone, Debug, PartialEq)]
75struct Drag {
76 gesture: NavGesture,
77 last: ViewportPoint,
78}
79
80#[derive(Copy, Clone, Debug, PartialEq)]
81pub struct ViewportNavigator {
82 drag: Option<Drag>,
83 orbit: OrbitState,
84}
85
86impl ViewportNavigator {
87 #[must_use]
88 pub fn new() -> Self {
89 Self {
90 drag: None,
91 orbit: OrbitState::identity(),
92 }
93 }
94
95 #[must_use]
96 pub fn orbit_rotation(&self) -> AxisAngle {
97 self.orbit.rotation()
98 }
99
100 #[must_use]
101 pub fn is_dragging(&self) -> bool {
102 self.drag.is_some()
103 }
104
105 pub fn begin_drag(&mut self, gesture: NavGesture, cursor: ViewportPoint) {
106 self.drag = Some(Drag {
107 gesture,
108 last: cursor,
109 });
110 }
111
112 pub fn end_drag(&mut self) {
113 self.drag = None;
114 }
115
116 pub fn drag_to(
117 &mut self,
118 cursor: ViewportPoint,
119 camera: Camera3,
120 extent: ViewportExtent,
121 ) -> Result<Camera3> {
122 let Some(drag) = self.drag else {
123 return Ok(camera);
124 };
125 let next = match drag.gesture {
126 NavGesture::Orbit => self.orbit_step(camera, extent, cursor, drag.last)?,
127 NavGesture::Pan => pan_pixels(camera, extent, drag.last, cursor)?,
128 NavGesture::Roll => roll_about_view(camera, extent, cursor, drag.last)?,
129 NavGesture::Zoom => {
130 let factor = ZoomFactor::new(ZOOM_DRAG_PER_PIXEL.powf(drag.last.y() - cursor.y()))?;
131 zoom_about_pixel(camera, extent, cursor, factor)?
132 }
133 };
134 self.drag = Some(Drag {
135 gesture: drag.gesture,
136 last: cursor,
137 });
138 Ok(next)
139 }
140
141 pub fn orbit_pixels(
142 &mut self,
143 camera: Camera3,
144 extent: ViewportExtent,
145 dx: f64,
146 dy: f64,
147 ) -> Result<Camera3> {
148 let cx = f64::from(extent.width().value()) * 0.5;
149 let cy = f64::from(extent.height().value()) * 0.5;
150 let from = ViewportPoint::new(cx, cy)?;
151 let to = ViewportPoint::new(cx + dx, cy + dy)?;
152 self.orbit_step(camera, extent, to, from)
153 }
154
155 fn orbit_step(
156 &mut self,
157 camera: Camera3,
158 extent: ViewportExtent,
159 from: ViewportPoint,
160 to: ViewportPoint,
161 ) -> Result<Camera3> {
162 let delta = arcball_rotation(camera, extent, from, to)?;
163 let oriented = orbit_about_point(camera, camera.target(), delta)?;
164 self.orbit = self.orbit.rotated(delta);
165 Ok(oriented)
166 }
167}
168
169impl Default for ViewportNavigator {
170 fn default() -> Self {
171 Self::new()
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::camera::ViewportPx;
179 use bone_types::{Point3, Projection, UnitVec3};
180 use uom::si::f64::Length;
181 use uom::si::length::millimeter;
182
183 fn extent() -> ViewportExtent {
184 ViewportExtent::square(ViewportPx::new(256))
185 }
186
187 fn camera() -> Camera3 {
188 let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(2.0)) else {
189 panic!("half height is positive");
190 };
191 let Ok(camera) = Camera3::new(
192 Point3::from_mm(0.0, -10.0, 0.0),
193 Point3::origin(),
194 UnitVec3::z_axis(),
195 projection,
196 ) else {
197 panic!("camera is non-degenerate");
198 };
199 camera
200 }
201
202 fn vp(x: f64, y: f64) -> ViewportPoint {
203 let Ok(point) = ViewportPoint::new(x, y) else {
204 panic!("pixel coordinates are finite");
205 };
206 point
207 }
208
209 fn close(a: Point3, b: Point3, tol: f64) -> bool {
210 let (ax, ay, az) = a.coords_mm();
211 let (bx, by, bz) = b.coords_mm();
212 (ax - bx).abs() < tol && (ay - by).abs() < tol && (az - bz).abs() < tol
213 }
214
215 #[test]
216 fn modifiers_select_the_gesture() {
217 assert_eq!(DragModifiers::NONE.gesture(), NavGesture::Orbit);
218 assert_eq!(DragModifiers::NONE.with_ctrl().gesture(), NavGesture::Pan);
219 assert_eq!(DragModifiers::NONE.with_shift().gesture(), NavGesture::Zoom);
220 assert_eq!(DragModifiers::NONE.with_alt().gesture(), NavGesture::Roll);
221 }
222
223 #[test]
224 fn ctrl_outranks_shift_outranks_alt() {
225 assert_eq!(
226 DragModifiers::NONE.with_ctrl().with_shift().gesture(),
227 NavGesture::Pan,
228 "ctrl pans even when shift is also held"
229 );
230 assert_eq!(
231 DragModifiers::NONE.with_shift().with_alt().gesture(),
232 NavGesture::Zoom,
233 "shift zooms even when alt is also held"
234 );
235 assert_eq!(
236 DragModifiers::NONE.with_alt().with_ctrl().gesture(),
237 NavGesture::Pan,
238 "the precedence is independent of the order the modifiers were set"
239 );
240 }
241
242 #[test]
243 fn drag_without_a_begin_is_a_no_op() {
244 let mut nav = ViewportNavigator::new();
245 assert!(!nav.is_dragging());
246 let Ok(after) = nav.drag_to(vp(10.0, 10.0), camera(), extent()) else {
247 panic!("an idle navigator passes the camera through");
248 };
249 assert_eq!(after, camera());
250 }
251
252 #[test]
253 fn orbit_drag_holds_the_target_and_accumulates_rotation() {
254 let mut nav = ViewportNavigator::new();
255 nav.begin_drag(NavGesture::Orbit, vp(128.0, 128.0));
256 assert!(nav.is_dragging());
257 let Ok(orbited) = nav.drag_to(vp(190.0, 128.0), camera(), extent()) else {
258 panic!("an orbit drag transforms the camera");
259 };
260 assert!(
261 close(orbited.target(), camera().target(), 1e-9),
262 "the orbit pivots on the target, so it cannot move"
263 );
264 assert_ne!(
265 orbited.eye(),
266 camera().eye(),
267 "a horizontal orbit drag must move the eye"
268 );
269 let rotated = nav.orbit_rotation().angle().get::<uom::si::angle::radian>();
270 assert!(
271 rotated.abs() > 1e-3,
272 "the orbit state accumulates the drag rotation: {rotated}"
273 );
274 }
275
276 #[test]
277 fn orbit_drag_right_pulls_the_grabbed_model_with_the_cursor() {
278 let mut nav = ViewportNavigator::new();
279 nav.begin_drag(NavGesture::Orbit, vp(128.0, 128.0));
280 let Ok(orbited) = nav.drag_to(vp(190.0, 128.0), camera(), extent()) else {
281 panic!("an orbit drag transforms the camera");
282 };
283 let (ex, _, _) = orbited.eye().coords_mm();
284 assert!(
285 ex < 0.0,
286 "dragging right swings the eye to -x so the grabbed face follows the cursor like pan: {ex}"
287 );
288 }
289
290 #[test]
291 fn pan_drag_keeps_the_grabbed_point_under_the_cursor() {
292 let mut nav = ViewportNavigator::new();
293 let from = vp(100.0, 110.0);
294 let to = vp(160.0, 140.0);
295 let Ok(grabbed) = crate::camera3::world_on_focal_plane(camera(), extent(), from) else {
296 panic!("the grabbed pixel has a focal point");
297 };
298 nav.begin_drag(NavGesture::Pan, from);
299 let Ok(panned) = nav.drag_to(to, camera(), extent()) else {
300 panic!("a pan drag transforms the camera");
301 };
302 let Ok(landed) = crate::camera3::world_on_focal_plane(panned, extent(), to) else {
303 panic!("the released pixel has a focal point");
304 };
305 assert!(
306 close(grabbed, landed, 1e-6),
307 "the grabbed world point should sit under the release cursor"
308 );
309 }
310
311 #[test]
312 fn roll_drag_spins_up_about_the_view_without_moving_the_eye() {
313 let mut nav = ViewportNavigator::new();
314 nav.begin_drag(NavGesture::Roll, vp(200.0, 128.0));
315 let Ok(rolled) = nav.drag_to(vp(128.0, 200.0), camera(), extent()) else {
316 panic!("a roll drag transforms the camera");
317 };
318 assert!(
319 close(rolled.eye(), camera().eye(), 1e-9)
320 && close(rolled.target(), camera().target(), 1e-9),
321 "a roll keeps the eye and target fixed"
322 );
323 assert_ne!(rolled.up(), camera().up(), "a roll reorients the up vector");
324 }
325
326 #[test]
327 fn scroll_orbit_holds_the_target_and_accumulates_rotation() {
328 let mut nav = ViewportNavigator::new();
329 let Ok(orbited) = nav.orbit_pixels(camera(), extent(), 60.0, 0.0) else {
330 panic!("a scroll delta orbits the camera");
331 };
332 assert!(
333 close(orbited.target(), camera().target(), 1e-9),
334 "a scroll orbit pivots on the target"
335 );
336 assert_ne!(
337 orbited.eye(),
338 camera().eye(),
339 "a horizontal scroll orbit moves the eye"
340 );
341 let rotated = nav.orbit_rotation().angle().get::<uom::si::angle::radian>();
342 assert!(
343 rotated.abs() > 1e-3,
344 "a scroll orbit accumulates rotation like a drag: {rotated}"
345 );
346 }
347
348 #[test]
349 fn zoom_drag_down_enlarges_the_orthographic_view() {
350 use bone_types::ProjectionKind;
351 let mut nav = ViewportNavigator::new();
352 nav.begin_drag(NavGesture::Zoom, vp(128.0, 100.0));
353 let Ok(zoomed) = nav.drag_to(vp(128.0, 160.0), camera(), extent()) else {
354 panic!("a zoom drag transforms the camera");
355 };
356 let ProjectionKind::Orthographic { half_height } = zoomed.projection().kind() else {
357 panic!("the seed camera is orthographic");
358 };
359 assert!(
360 half_height.get::<uom::si::length::millimeter>() > 2.0,
361 "dragging the zoom gesture downward zooms out, growing the half height"
362 );
363 }
364
365 #[test]
366 fn end_drag_clears_the_active_gesture() {
367 let mut nav = ViewportNavigator::new();
368 nav.begin_drag(NavGesture::Orbit, vp(40.0, 40.0));
369 assert!(nav.is_dragging());
370 nav.end_drag();
371 assert!(
372 !nav.is_dragging(),
373 "losing focus or releasing the button drops the in-flight drag"
374 );
375 let Ok(after) = nav.drag_to(vp(80.0, 80.0), camera(), extent()) else {
376 panic!("a cleared navigator passes the camera through");
377 };
378 assert_eq!(
379 after,
380 camera(),
381 "a move after the drag clears must not orbit"
382 );
383 }
384}