Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(render): arcball orbit, pan, roll drag navigator

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 7, 2026, 11:30 AM +0300) commit 4aa6001e parent d7aef0be change-id mrsptoqx
+446 -5
+144 -2
crates/bone-render/src/camera3.rs
··· 4 4 use uom::si::length::millimeter; 5 5 6 6 use bone_types::{ 7 - Aabb3, AxisAngle, Camera3, Plane3, Point3, Projection, ProjectionKind, Result, StandardView, 8 - Tolerance, TypesError, UnitVec3, Vec3, ZoomFactor, 7 + Aabb3, Angle, AxisAngle, Camera3, Plane3, Point3, Projection, ProjectionKind, Result, 8 + StandardView, Tolerance, TypesError, UnitVec3, Vec3, ZoomFactor, 9 9 }; 10 10 11 11 use crate::camera::ViewportExtent; ··· 13 13 const ORTHO_DEPTH_FACTOR: f64 = 8.0; 14 14 const RAY_TOLERANCE: Tolerance = Tolerance::new(1.0e-12); 15 15 const FOCAL_DENOM_FLOOR: f64 = 1.0e-9; 16 + const ARCBALL_MIN_AXIS: f64 = 1.0e-9; 16 17 const FRAME_MARGIN: f64 = 1.2; 17 18 const SILHOUETTE_COLLAPSE_FRACTION: f64 = 1.0e-6; 18 19 ··· 125 126 ) 126 127 } 127 128 129 + pub fn arcball_rotation( 130 + camera: Camera3, 131 + extent: ViewportExtent, 132 + from: ViewportPoint, 133 + to: ViewportPoint, 134 + ) -> Result<AxisAngle> { 135 + let grabbed = arcball_vector(camera, extent, from)?; 136 + let dragged = arcball_vector(camera, extent, to)?; 137 + let axis = grabbed.cross(&dragged); 138 + let axis_norm = axis.norm(); 139 + if axis_norm < ARCBALL_MIN_AXIS { 140 + return Ok(AxisAngle::new( 141 + UnitVec3::z_axis(), 142 + Angle::new::<radian>(0.0), 143 + )); 144 + } 145 + let angle = grabbed.dot(&dragged).clamp(-1.0, 1.0).acos(); 146 + let unit = axis / axis_norm; 147 + Ok(AxisAngle::new( 148 + UnitVec3::new_unchecked(unit.x, unit.y, unit.z), 149 + Angle::new::<radian>(angle), 150 + )) 151 + } 152 + 153 + pub fn roll_about_view( 154 + camera: Camera3, 155 + extent: ViewportExtent, 156 + from: ViewportPoint, 157 + to: ViewportPoint, 158 + ) -> Result<Camera3> { 159 + let forward = view_direction(camera)?; 160 + let cx = f64::from(extent.width().value()) * 0.5; 161 + let cy = f64::from(extent.height().value()) * 0.5; 162 + let (ax, ay) = (from.x() - cx, from.y() - cy); 163 + let (bx, by) = (to.x() - cx, to.y() - cy); 164 + let angle = (ax * by - ay * bx).atan2(ax * bx + ay * by); 165 + Camera3::new( 166 + camera.eye(), 167 + camera.target(), 168 + camera 169 + .up() 170 + .rotated(AxisAngle::new(forward, Angle::new::<radian>(angle))), 171 + camera.projection(), 172 + ) 173 + } 174 + 128 175 pub fn frame_isometric(aabb: Aabb3, extent: ViewportExtent) -> Result<Camera3> { 129 176 let direction = UnitVec3::try_from_components(1.0, 1.0, 1.0, RAY_TOLERANCE)?; 130 177 frame_along(aabb, extent, direction, UnitVec3::z_axis()) ··· 285 332 (camera.target() - camera.eye()).try_normalize(RAY_TOLERANCE) 286 333 } 287 334 335 + fn arcball_vector( 336 + camera: Camera3, 337 + extent: ViewportExtent, 338 + pixel: ViewportPoint, 339 + ) -> Result<NVec3<f64>> { 340 + let forward = view_direction(camera)?; 341 + let (fx, fy, fz) = forward.components(); 342 + let f = NVec3::new(fx, fy, fz); 343 + let (ux, uy, uz) = camera.up().components(); 344 + let right = f.cross(&NVec3::new(ux, uy, uz)).normalize(); 345 + let up = right.cross(&f); 346 + let (ndc_x, ndc_y) = ndc_of(pixel, extent); 347 + let sphere_x = ndc_x * aspect_ratio(extent); 348 + let radius_sq = sphere_x * sphere_x + ndc_y * ndc_y; 349 + let depth = if radius_sq <= 1.0 { 350 + (1.0 - radius_sq).sqrt() 351 + } else { 352 + 0.0 353 + }; 354 + let local = NVec3::new(sphere_x, ndc_y, depth).normalize(); 355 + Ok(right * local.x + up * local.y - f * local.z) 356 + } 357 + 288 358 fn translate(camera: Camera3, delta: Vec3) -> Result<Camera3> { 289 359 Camera3::new( 290 360 camera.eye() + delta, ··· 572 642 "world point under the cursor should survive an orbit" 573 643 ); 574 644 }); 645 + } 646 + 647 + #[test] 648 + fn arcball_rotation_is_identity_when_the_cursor_holds_still() { 649 + let pixel = vp(70.0, 90.0); 650 + let Ok(rotation) = arcball_rotation(ortho_camera(), extent(), pixel, pixel) else { 651 + panic!("a zero-length drag still yields a rotation"); 652 + }; 653 + assert!( 654 + rotation.angle().get::<radian>().abs() < 1e-12, 655 + "holding the cursor still produces no rotation" 656 + ); 657 + } 658 + 659 + #[test] 660 + fn arcball_speed_is_isotropic_across_aspect() { 661 + let wide = ViewportExtent::new( 662 + crate::camera::ViewportPx::new(512), 663 + crate::camera::ViewportPx::new(256), 664 + ); 665 + let (cx, cy) = (256.0, 128.0); 666 + let span = 60.0; 667 + let Ok(horizontal) = arcball_rotation(ortho_camera(), wide, vp(cx, cy), vp(cx + span, cy)) 668 + else { 669 + panic!("a horizontal drag rotates"); 670 + }; 671 + let Ok(vertical) = arcball_rotation(ortho_camera(), wide, vp(cx, cy), vp(cx, cy + span)) 672 + else { 673 + panic!("a vertical drag rotates"); 674 + }; 675 + let h = horizontal.angle().get::<radian>(); 676 + let v = vertical.angle().get::<radian>(); 677 + assert!( 678 + (h - v).abs() < 1e-9, 679 + "equal pixel drags must rotate equally on a non-square viewport: h={h} v={v}" 680 + ); 681 + } 682 + 683 + #[test] 684 + fn antipodal_single_step_drag_collapses_to_no_rotation() { 685 + let Ok(rotation) = 686 + arcball_rotation(ortho_camera(), extent(), vp(256.0, 128.0), vp(0.0, 128.0)) 687 + else { 688 + panic!("the arcball still yields a rotation at the sphere edge"); 689 + }; 690 + assert!( 691 + rotation.angle().get::<radian>().abs() < 1e-9, 692 + "a single step between antipodal sphere points is intentionally a no-op, not a pi flip" 693 + ); 694 + } 695 + 696 + #[test] 697 + fn roll_sweep_from_right_to_below_rolls_up_toward_positive_x() { 698 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(2.0)) else { 699 + panic!("half height is positive"); 700 + }; 701 + let Ok(front) = Camera3::new( 702 + Point3::from_mm(0.0, -10.0, 0.0), 703 + Point3::origin(), 704 + UnitVec3::z_axis(), 705 + projection, 706 + ) else { 707 + panic!("camera is non-degenerate"); 708 + }; 709 + let Ok(rolled) = roll_about_view(front, extent(), vp(200.0, 128.0), vp(128.0, 200.0)) else { 710 + panic!("a roll drag transforms the camera"); 711 + }; 712 + let (ux, uy, uz) = rolled.up().components(); 713 + assert!( 714 + (ux - 1.0).abs() < 1e-9 && uy.abs() < 1e-9 && uz.abs() < 1e-9, 715 + "a right-then-below screen sweep rolls up from +z to +x: ({ux}, {uy}, {uz})" 716 + ); 575 717 } 576 718 577 719 #[test]
+5 -3
crates/bone-render/src/lib.rs
··· 2 2 pub mod camera3; 3 3 pub mod diff; 4 4 pub mod gpu; 5 + pub mod navigate; 5 6 pub mod pick; 6 7 pub mod pipelines; 7 8 pub mod preview; ··· 12 13 13 14 pub use camera::{Camera2, GridSpacing, PixelsPerMm, ViewportExtent, ViewportPx}; 14 15 pub use camera3::{ 15 - ViewportPoint, clip_from_world, frame_isometric, frame_standard_view, orbit_about_pixel, 16 - orbit_about_point, pan_pixels, world_from_clip, world_on_focal_plane, world_ray, 17 - zoom_about_pixel, 16 + ViewportPoint, arcball_rotation, clip_from_world, frame_isometric, frame_standard_view, 17 + orbit_about_pixel, orbit_about_point, pan_pixels, roll_about_view, world_from_clip, 18 + world_on_focal_plane, world_ray, zoom_about_pixel, 18 19 }; 19 20 pub use diff::{PixelDiff, PixelDiffError, PixelDiffReport, PixelDiffThreshold, PixelMismatch}; 20 21 pub use gpu::{BackendTag, Capabilities, Gpu, OffscreenContext}; 22 + pub use navigate::{DragModifiers, NavGesture, ViewportNavigator}; 21 23 pub use pick::{ 22 24 EntityKindTag, PickAperture, PickId, PickIdError, PickIndex, PickQuery, PickedItem, Picker, 23 25 };
+297
crates/bone-render/src/navigate.rs
··· 1 + use bone_types::{AxisAngle, Camera3, OrbitState, Result}; 2 + 3 + use crate::camera::ViewportExtent; 4 + use crate::camera3::{ 5 + ViewportPoint, arcball_rotation, orbit_about_point, pan_pixels, roll_about_view, 6 + }; 7 + 8 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 9 + pub struct DragModifiers { 10 + shift: bool, 11 + alt: bool, 12 + } 13 + 14 + impl DragModifiers { 15 + pub const NONE: Self = Self { 16 + shift: false, 17 + alt: false, 18 + }; 19 + 20 + #[must_use] 21 + pub const fn with_shift(self) -> Self { 22 + Self { 23 + shift: true, 24 + alt: self.alt, 25 + } 26 + } 27 + 28 + #[must_use] 29 + pub const fn with_alt(self) -> Self { 30 + Self { 31 + shift: self.shift, 32 + alt: true, 33 + } 34 + } 35 + 36 + #[must_use] 37 + pub const fn gesture(self) -> NavGesture { 38 + if self.shift { 39 + NavGesture::Pan 40 + } else if self.alt { 41 + NavGesture::Roll 42 + } else { 43 + NavGesture::Orbit 44 + } 45 + } 46 + } 47 + 48 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 49 + pub enum NavGesture { 50 + Orbit, 51 + Pan, 52 + Roll, 53 + } 54 + 55 + #[derive(Copy, Clone, Debug, PartialEq)] 56 + struct Drag { 57 + gesture: NavGesture, 58 + last: ViewportPoint, 59 + } 60 + 61 + #[derive(Copy, Clone, Debug, PartialEq)] 62 + pub struct ViewportNavigator { 63 + drag: Option<Drag>, 64 + orbit: OrbitState, 65 + } 66 + 67 + impl ViewportNavigator { 68 + #[must_use] 69 + pub fn new() -> Self { 70 + Self { 71 + drag: None, 72 + orbit: OrbitState::identity(), 73 + } 74 + } 75 + 76 + #[must_use] 77 + pub fn orbit_rotation(&self) -> AxisAngle { 78 + self.orbit.rotation() 79 + } 80 + 81 + #[must_use] 82 + pub fn is_dragging(&self) -> bool { 83 + self.drag.is_some() 84 + } 85 + 86 + pub fn begin_drag(&mut self, gesture: NavGesture, cursor: ViewportPoint) { 87 + self.drag = Some(Drag { 88 + gesture, 89 + last: cursor, 90 + }); 91 + } 92 + 93 + pub fn end_drag(&mut self) { 94 + self.drag = None; 95 + } 96 + 97 + pub fn drag_to( 98 + &mut self, 99 + cursor: ViewportPoint, 100 + camera: Camera3, 101 + extent: ViewportExtent, 102 + ) -> Result<Camera3> { 103 + let Some(drag) = self.drag else { 104 + return Ok(camera); 105 + }; 106 + let next = match drag.gesture { 107 + NavGesture::Orbit => { 108 + let delta = arcball_rotation(camera, extent, drag.last, cursor)?; 109 + let oriented = orbit_about_point(camera, camera.target(), delta)?; 110 + self.orbit = self.orbit.rotated(delta); 111 + oriented 112 + } 113 + NavGesture::Pan => pan_pixels(camera, extent, drag.last, cursor)?, 114 + NavGesture::Roll => roll_about_view(camera, extent, drag.last, cursor)?, 115 + }; 116 + self.drag = Some(Drag { 117 + gesture: drag.gesture, 118 + last: cursor, 119 + }); 120 + Ok(next) 121 + } 122 + } 123 + 124 + impl Default for ViewportNavigator { 125 + fn default() -> Self { 126 + Self::new() 127 + } 128 + } 129 + 130 + #[cfg(test)] 131 + mod tests { 132 + use super::*; 133 + use crate::camera::ViewportPx; 134 + use bone_types::{Point3, Projection, UnitVec3}; 135 + use uom::si::f64::Length; 136 + use uom::si::length::millimeter; 137 + 138 + fn extent() -> ViewportExtent { 139 + ViewportExtent::square(ViewportPx::new(256)) 140 + } 141 + 142 + fn camera() -> Camera3 { 143 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(2.0)) else { 144 + panic!("half height is positive"); 145 + }; 146 + let Ok(camera) = Camera3::new( 147 + Point3::from_mm(0.0, -10.0, 0.0), 148 + Point3::origin(), 149 + UnitVec3::z_axis(), 150 + projection, 151 + ) else { 152 + panic!("camera is non-degenerate"); 153 + }; 154 + camera 155 + } 156 + 157 + fn vp(x: f64, y: f64) -> ViewportPoint { 158 + let Ok(point) = ViewportPoint::new(x, y) else { 159 + panic!("pixel coordinates are finite"); 160 + }; 161 + point 162 + } 163 + 164 + fn close(a: Point3, b: Point3, tol: f64) -> bool { 165 + let (ax, ay, az) = a.coords_mm(); 166 + let (bx, by, bz) = b.coords_mm(); 167 + (ax - bx).abs() < tol && (ay - by).abs() < tol && (az - bz).abs() < tol 168 + } 169 + 170 + #[test] 171 + fn modifiers_select_the_gesture() { 172 + assert_eq!(DragModifiers::NONE.gesture(), NavGesture::Orbit); 173 + assert_eq!(DragModifiers::NONE.with_shift().gesture(), NavGesture::Pan); 174 + assert_eq!(DragModifiers::NONE.with_alt().gesture(), NavGesture::Roll); 175 + } 176 + 177 + #[test] 178 + fn shift_takes_precedence_over_alt() { 179 + assert_eq!( 180 + DragModifiers::NONE.with_shift().with_alt().gesture(), 181 + NavGesture::Pan, 182 + "holding both shift and alt resolves to pan, never roll" 183 + ); 184 + assert_eq!( 185 + DragModifiers::NONE.with_alt().with_shift().gesture(), 186 + NavGesture::Pan, 187 + "the precedence is independent of the order the modifiers were set" 188 + ); 189 + } 190 + 191 + #[test] 192 + fn drag_without_a_begin_is_a_no_op() { 193 + let mut nav = ViewportNavigator::new(); 194 + assert!(!nav.is_dragging()); 195 + let Ok(after) = nav.drag_to(vp(10.0, 10.0), camera(), extent()) else { 196 + panic!("an idle navigator passes the camera through"); 197 + }; 198 + assert_eq!(after, camera()); 199 + } 200 + 201 + #[test] 202 + fn orbit_drag_holds_the_target_and_accumulates_rotation() { 203 + let mut nav = ViewportNavigator::new(); 204 + nav.begin_drag(NavGesture::Orbit, vp(128.0, 128.0)); 205 + assert!(nav.is_dragging()); 206 + let Ok(orbited) = nav.drag_to(vp(190.0, 128.0), camera(), extent()) else { 207 + panic!("an orbit drag transforms the camera"); 208 + }; 209 + assert!( 210 + close(orbited.target(), camera().target(), 1e-9), 211 + "the orbit pivots on the target, so it cannot move" 212 + ); 213 + assert_ne!( 214 + orbited.eye(), 215 + camera().eye(), 216 + "a horizontal orbit drag must move the eye" 217 + ); 218 + let rotated = nav 219 + .orbit_rotation() 220 + .angle() 221 + .get::<uom::si::angle::radian>(); 222 + assert!( 223 + rotated.abs() > 1e-3, 224 + "the orbit state accumulates the drag rotation: {rotated}" 225 + ); 226 + } 227 + 228 + #[test] 229 + fn orbit_drag_right_swings_the_eye_toward_positive_x() { 230 + let mut nav = ViewportNavigator::new(); 231 + nav.begin_drag(NavGesture::Orbit, vp(128.0, 128.0)); 232 + let Ok(orbited) = nav.drag_to(vp(190.0, 128.0), camera(), extent()) else { 233 + panic!("an orbit drag transforms the camera"); 234 + }; 235 + let (ex, _, _) = orbited.eye().coords_mm(); 236 + assert!( 237 + ex > 0.0, 238 + "dragging right orbits the eye to the +x side of the model: {ex}" 239 + ); 240 + } 241 + 242 + #[test] 243 + fn pan_drag_keeps_the_grabbed_point_under_the_cursor() { 244 + let mut nav = ViewportNavigator::new(); 245 + let from = vp(100.0, 110.0); 246 + let to = vp(160.0, 140.0); 247 + let Ok(grabbed) = crate::camera3::world_on_focal_plane(camera(), extent(), from) else { 248 + panic!("the grabbed pixel has a focal point"); 249 + }; 250 + nav.begin_drag(NavGesture::Pan, from); 251 + let Ok(panned) = nav.drag_to(to, camera(), extent()) else { 252 + panic!("a pan drag transforms the camera"); 253 + }; 254 + let Ok(landed) = crate::camera3::world_on_focal_plane(panned, extent(), to) else { 255 + panic!("the released pixel has a focal point"); 256 + }; 257 + assert!( 258 + close(grabbed, landed, 1e-6), 259 + "the grabbed world point should sit under the release cursor" 260 + ); 261 + } 262 + 263 + #[test] 264 + fn roll_drag_spins_up_about_the_view_without_moving_the_eye() { 265 + let mut nav = ViewportNavigator::new(); 266 + nav.begin_drag(NavGesture::Roll, vp(200.0, 128.0)); 267 + let Ok(rolled) = nav.drag_to(vp(128.0, 200.0), camera(), extent()) else { 268 + panic!("a roll drag transforms the camera"); 269 + }; 270 + assert!( 271 + close(rolled.eye(), camera().eye(), 1e-9) 272 + && close(rolled.target(), camera().target(), 1e-9), 273 + "a roll keeps the eye and target fixed" 274 + ); 275 + assert_ne!(rolled.up(), camera().up(), "a roll reorients the up vector"); 276 + } 277 + 278 + #[test] 279 + fn end_drag_clears_the_active_gesture() { 280 + let mut nav = ViewportNavigator::new(); 281 + nav.begin_drag(NavGesture::Orbit, vp(40.0, 40.0)); 282 + assert!(nav.is_dragging()); 283 + nav.end_drag(); 284 + assert!( 285 + !nav.is_dragging(), 286 + "losing focus or releasing the button drops the in-flight drag" 287 + ); 288 + let Ok(after) = nav.drag_to(vp(80.0, 80.0), camera(), extent()) else { 289 + panic!("a cleared navigator passes the camera through"); 290 + }; 291 + assert_eq!( 292 + after, 293 + camera(), 294 + "a move after the drag clears must not orbit" 295 + ); 296 + } 297 + }