diff --git a/crates/bone-render/src/camera3.rs b/crates/bone-render/src/camera3.rs index 3fe829f..cec3feb 100644 --- a/crates/bone-render/src/camera3.rs +++ b/crates/bone-render/src/camera3.rs @@ -4,8 +4,8 @@ use uom::si::f64::Length; use uom::si::length::millimeter; use bone_types::{ - Aabb3, AxisAngle, Camera3, Plane3, Point3, Projection, ProjectionKind, Result, StandardView, - Tolerance, TypesError, UnitVec3, Vec3, ZoomFactor, + Aabb3, Angle, AxisAngle, Camera3, Plane3, Point3, Projection, ProjectionKind, Result, + StandardView, Tolerance, TypesError, UnitVec3, Vec3, ZoomFactor, }; use crate::camera::ViewportExtent; @@ -13,6 +13,7 @@ use crate::camera::ViewportExtent; const ORTHO_DEPTH_FACTOR: f64 = 8.0; const RAY_TOLERANCE: Tolerance = Tolerance::new(1.0e-12); const FOCAL_DENOM_FLOOR: f64 = 1.0e-9; +const ARCBALL_MIN_AXIS: f64 = 1.0e-9; const FRAME_MARGIN: f64 = 1.2; const SILHOUETTE_COLLAPSE_FRACTION: f64 = 1.0e-6; @@ -125,6 +126,52 @@ pub fn orbit_about_point(camera: Camera3, pivot: Point3, rotation: AxisAngle) -> ) } +pub fn arcball_rotation( + camera: Camera3, + extent: ViewportExtent, + from: ViewportPoint, + to: ViewportPoint, +) -> Result { + let grabbed = arcball_vector(camera, extent, from)?; + let dragged = arcball_vector(camera, extent, to)?; + let axis = grabbed.cross(&dragged); + let axis_norm = axis.norm(); + if axis_norm < ARCBALL_MIN_AXIS { + return Ok(AxisAngle::new( + UnitVec3::z_axis(), + Angle::new::(0.0), + )); + } + let angle = grabbed.dot(&dragged).clamp(-1.0, 1.0).acos(); + let unit = axis / axis_norm; + Ok(AxisAngle::new( + UnitVec3::new_unchecked(unit.x, unit.y, unit.z), + Angle::new::(angle), + )) +} + +pub fn roll_about_view( + camera: Camera3, + extent: ViewportExtent, + from: ViewportPoint, + to: ViewportPoint, +) -> Result { + let forward = view_direction(camera)?; + let cx = f64::from(extent.width().value()) * 0.5; + let cy = f64::from(extent.height().value()) * 0.5; + let (ax, ay) = (from.x() - cx, from.y() - cy); + let (bx, by) = (to.x() - cx, to.y() - cy); + let angle = (ax * by - ay * bx).atan2(ax * bx + ay * by); + Camera3::new( + camera.eye(), + camera.target(), + camera + .up() + .rotated(AxisAngle::new(forward, Angle::new::(angle))), + camera.projection(), + ) +} + pub fn frame_isometric(aabb: Aabb3, extent: ViewportExtent) -> Result { let direction = UnitVec3::try_from_components(1.0, 1.0, 1.0, RAY_TOLERANCE)?; frame_along(aabb, extent, direction, UnitVec3::z_axis()) @@ -285,6 +332,29 @@ fn view_direction(camera: Camera3) -> Result { (camera.target() - camera.eye()).try_normalize(RAY_TOLERANCE) } +fn arcball_vector( + camera: Camera3, + extent: ViewportExtent, + pixel: ViewportPoint, +) -> Result> { + let forward = view_direction(camera)?; + let (fx, fy, fz) = forward.components(); + let f = NVec3::new(fx, fy, fz); + let (ux, uy, uz) = camera.up().components(); + let right = f.cross(&NVec3::new(ux, uy, uz)).normalize(); + let up = right.cross(&f); + let (ndc_x, ndc_y) = ndc_of(pixel, extent); + let sphere_x = ndc_x * aspect_ratio(extent); + let radius_sq = sphere_x * sphere_x + ndc_y * ndc_y; + let depth = if radius_sq <= 1.0 { + (1.0 - radius_sq).sqrt() + } else { + 0.0 + }; + let local = NVec3::new(sphere_x, ndc_y, depth).normalize(); + Ok(right * local.x + up * local.y - f * local.z) +} + fn translate(camera: Camera3, delta: Vec3) -> Result { Camera3::new( camera.eye() + delta, @@ -574,6 +644,78 @@ mod tests { }); } + #[test] + fn arcball_rotation_is_identity_when_the_cursor_holds_still() { + let pixel = vp(70.0, 90.0); + let Ok(rotation) = arcball_rotation(ortho_camera(), extent(), pixel, pixel) else { + panic!("a zero-length drag still yields a rotation"); + }; + assert!( + rotation.angle().get::().abs() < 1e-12, + "holding the cursor still produces no rotation" + ); + } + + #[test] + fn arcball_speed_is_isotropic_across_aspect() { + let wide = ViewportExtent::new( + crate::camera::ViewportPx::new(512), + crate::camera::ViewportPx::new(256), + ); + let (cx, cy) = (256.0, 128.0); + let span = 60.0; + let Ok(horizontal) = arcball_rotation(ortho_camera(), wide, vp(cx, cy), vp(cx + span, cy)) + else { + panic!("a horizontal drag rotates"); + }; + let Ok(vertical) = arcball_rotation(ortho_camera(), wide, vp(cx, cy), vp(cx, cy + span)) + else { + panic!("a vertical drag rotates"); + }; + let h = horizontal.angle().get::(); + let v = vertical.angle().get::(); + assert!( + (h - v).abs() < 1e-9, + "equal pixel drags must rotate equally on a non-square viewport: h={h} v={v}" + ); + } + + #[test] + fn antipodal_single_step_drag_collapses_to_no_rotation() { + let Ok(rotation) = + arcball_rotation(ortho_camera(), extent(), vp(256.0, 128.0), vp(0.0, 128.0)) + else { + panic!("the arcball still yields a rotation at the sphere edge"); + }; + assert!( + rotation.angle().get::().abs() < 1e-9, + "a single step between antipodal sphere points is intentionally a no-op, not a pi flip" + ); + } + + #[test] + fn roll_sweep_from_right_to_below_rolls_up_toward_positive_x() { + let Ok(projection) = Projection::orthographic(Length::new::(2.0)) else { + panic!("half height is positive"); + }; + let Ok(front) = Camera3::new( + Point3::from_mm(0.0, -10.0, 0.0), + Point3::origin(), + UnitVec3::z_axis(), + projection, + ) else { + panic!("camera is non-degenerate"); + }; + let Ok(rolled) = roll_about_view(front, extent(), vp(200.0, 128.0), vp(128.0, 200.0)) else { + panic!("a roll drag transforms the camera"); + }; + let (ux, uy, uz) = rolled.up().components(); + assert!( + (ux - 1.0).abs() < 1e-9 && uy.abs() < 1e-9 && uz.abs() < 1e-9, + "a right-then-below screen sweep rolls up from +z to +x: ({ux}, {uy}, {uz})" + ); + } + #[test] fn isometric_frames_unit_cube_centered() { let cube = Aabb3::from_corners( diff --git a/crates/bone-render/src/lib.rs b/crates/bone-render/src/lib.rs index f3faac3..46a1c34 100644 --- a/crates/bone-render/src/lib.rs +++ b/crates/bone-render/src/lib.rs @@ -2,6 +2,7 @@ pub mod camera; pub mod camera3; pub mod diff; pub mod gpu; +pub mod navigate; pub mod pick; pub mod pipelines; pub mod preview; @@ -12,12 +13,13 @@ pub mod tween; pub use camera::{Camera2, GridSpacing, PixelsPerMm, ViewportExtent, ViewportPx}; pub use camera3::{ - ViewportPoint, clip_from_world, frame_isometric, frame_standard_view, orbit_about_pixel, - orbit_about_point, pan_pixels, world_from_clip, world_on_focal_plane, world_ray, - zoom_about_pixel, + ViewportPoint, arcball_rotation, clip_from_world, frame_isometric, frame_standard_view, + orbit_about_pixel, orbit_about_point, pan_pixels, roll_about_view, world_from_clip, + world_on_focal_plane, world_ray, zoom_about_pixel, }; pub use diff::{PixelDiff, PixelDiffError, PixelDiffReport, PixelDiffThreshold, PixelMismatch}; pub use gpu::{BackendTag, Capabilities, Gpu, OffscreenContext}; +pub use navigate::{DragModifiers, NavGesture, ViewportNavigator}; pub use pick::{ EntityKindTag, PickAperture, PickId, PickIdError, PickIndex, PickQuery, PickedItem, Picker, }; diff --git a/crates/bone-render/src/navigate.rs b/crates/bone-render/src/navigate.rs new file mode 100644 index 0000000..fb20e99 --- /dev/null +++ b/crates/bone-render/src/navigate.rs @@ -0,0 +1,297 @@ +use bone_types::{AxisAngle, Camera3, OrbitState, Result}; + +use crate::camera::ViewportExtent; +use crate::camera3::{ + ViewportPoint, arcball_rotation, orbit_about_point, pan_pixels, roll_about_view, +}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct DragModifiers { + shift: bool, + alt: bool, +} + +impl DragModifiers { + pub const NONE: Self = Self { + shift: false, + alt: false, + }; + + #[must_use] + pub const fn with_shift(self) -> Self { + Self { + shift: true, + alt: self.alt, + } + } + + #[must_use] + pub const fn with_alt(self) -> Self { + Self { + shift: self.shift, + alt: true, + } + } + + #[must_use] + pub const fn gesture(self) -> NavGesture { + if self.shift { + NavGesture::Pan + } else if self.alt { + NavGesture::Roll + } else { + NavGesture::Orbit + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum NavGesture { + Orbit, + Pan, + Roll, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +struct Drag { + gesture: NavGesture, + last: ViewportPoint, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct ViewportNavigator { + drag: Option, + orbit: OrbitState, +} + +impl ViewportNavigator { + #[must_use] + pub fn new() -> Self { + Self { + drag: None, + orbit: OrbitState::identity(), + } + } + + #[must_use] + pub fn orbit_rotation(&self) -> AxisAngle { + self.orbit.rotation() + } + + #[must_use] + pub fn is_dragging(&self) -> bool { + self.drag.is_some() + } + + pub fn begin_drag(&mut self, gesture: NavGesture, cursor: ViewportPoint) { + self.drag = Some(Drag { + gesture, + last: cursor, + }); + } + + pub fn end_drag(&mut self) { + self.drag = None; + } + + pub fn drag_to( + &mut self, + cursor: ViewportPoint, + camera: Camera3, + extent: ViewportExtent, + ) -> Result { + let Some(drag) = self.drag else { + return Ok(camera); + }; + let next = match drag.gesture { + NavGesture::Orbit => { + let delta = arcball_rotation(camera, extent, drag.last, cursor)?; + let oriented = orbit_about_point(camera, camera.target(), delta)?; + self.orbit = self.orbit.rotated(delta); + oriented + } + NavGesture::Pan => pan_pixels(camera, extent, drag.last, cursor)?, + NavGesture::Roll => roll_about_view(camera, extent, drag.last, cursor)?, + }; + self.drag = Some(Drag { + gesture: drag.gesture, + last: cursor, + }); + Ok(next) + } +} + +impl Default for ViewportNavigator { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::camera::ViewportPx; + use bone_types::{Point3, Projection, UnitVec3}; + use uom::si::f64::Length; + use uom::si::length::millimeter; + + fn extent() -> ViewportExtent { + ViewportExtent::square(ViewportPx::new(256)) + } + + fn camera() -> Camera3 { + let Ok(projection) = Projection::orthographic(Length::new::(2.0)) else { + panic!("half height is positive"); + }; + let Ok(camera) = Camera3::new( + Point3::from_mm(0.0, -10.0, 0.0), + Point3::origin(), + UnitVec3::z_axis(), + projection, + ) else { + panic!("camera is non-degenerate"); + }; + camera + } + + fn vp(x: f64, y: f64) -> ViewportPoint { + let Ok(point) = ViewportPoint::new(x, y) else { + panic!("pixel coordinates are finite"); + }; + point + } + + fn close(a: Point3, b: Point3, tol: f64) -> bool { + let (ax, ay, az) = a.coords_mm(); + let (bx, by, bz) = b.coords_mm(); + (ax - bx).abs() < tol && (ay - by).abs() < tol && (az - bz).abs() < tol + } + + #[test] + fn modifiers_select_the_gesture() { + assert_eq!(DragModifiers::NONE.gesture(), NavGesture::Orbit); + assert_eq!(DragModifiers::NONE.with_shift().gesture(), NavGesture::Pan); + assert_eq!(DragModifiers::NONE.with_alt().gesture(), NavGesture::Roll); + } + + #[test] + fn shift_takes_precedence_over_alt() { + assert_eq!( + DragModifiers::NONE.with_shift().with_alt().gesture(), + NavGesture::Pan, + "holding both shift and alt resolves to pan, never roll" + ); + assert_eq!( + DragModifiers::NONE.with_alt().with_shift().gesture(), + NavGesture::Pan, + "the precedence is independent of the order the modifiers were set" + ); + } + + #[test] + fn drag_without_a_begin_is_a_no_op() { + let mut nav = ViewportNavigator::new(); + assert!(!nav.is_dragging()); + let Ok(after) = nav.drag_to(vp(10.0, 10.0), camera(), extent()) else { + panic!("an idle navigator passes the camera through"); + }; + assert_eq!(after, camera()); + } + + #[test] + fn orbit_drag_holds_the_target_and_accumulates_rotation() { + let mut nav = ViewportNavigator::new(); + nav.begin_drag(NavGesture::Orbit, vp(128.0, 128.0)); + assert!(nav.is_dragging()); + let Ok(orbited) = nav.drag_to(vp(190.0, 128.0), camera(), extent()) else { + panic!("an orbit drag transforms the camera"); + }; + assert!( + close(orbited.target(), camera().target(), 1e-9), + "the orbit pivots on the target, so it cannot move" + ); + assert_ne!( + orbited.eye(), + camera().eye(), + "a horizontal orbit drag must move the eye" + ); + let rotated = nav + .orbit_rotation() + .angle() + .get::(); + assert!( + rotated.abs() > 1e-3, + "the orbit state accumulates the drag rotation: {rotated}" + ); + } + + #[test] + fn orbit_drag_right_swings_the_eye_toward_positive_x() { + let mut nav = ViewportNavigator::new(); + nav.begin_drag(NavGesture::Orbit, vp(128.0, 128.0)); + let Ok(orbited) = nav.drag_to(vp(190.0, 128.0), camera(), extent()) else { + panic!("an orbit drag transforms the camera"); + }; + let (ex, _, _) = orbited.eye().coords_mm(); + assert!( + ex > 0.0, + "dragging right orbits the eye to the +x side of the model: {ex}" + ); + } + + #[test] + fn pan_drag_keeps_the_grabbed_point_under_the_cursor() { + let mut nav = ViewportNavigator::new(); + let from = vp(100.0, 110.0); + let to = vp(160.0, 140.0); + let Ok(grabbed) = crate::camera3::world_on_focal_plane(camera(), extent(), from) else { + panic!("the grabbed pixel has a focal point"); + }; + nav.begin_drag(NavGesture::Pan, from); + let Ok(panned) = nav.drag_to(to, camera(), extent()) else { + panic!("a pan drag transforms the camera"); + }; + let Ok(landed) = crate::camera3::world_on_focal_plane(panned, extent(), to) else { + panic!("the released pixel has a focal point"); + }; + assert!( + close(grabbed, landed, 1e-6), + "the grabbed world point should sit under the release cursor" + ); + } + + #[test] + fn roll_drag_spins_up_about_the_view_without_moving_the_eye() { + let mut nav = ViewportNavigator::new(); + nav.begin_drag(NavGesture::Roll, vp(200.0, 128.0)); + let Ok(rolled) = nav.drag_to(vp(128.0, 200.0), camera(), extent()) else { + panic!("a roll drag transforms the camera"); + }; + assert!( + close(rolled.eye(), camera().eye(), 1e-9) + && close(rolled.target(), camera().target(), 1e-9), + "a roll keeps the eye and target fixed" + ); + assert_ne!(rolled.up(), camera().up(), "a roll reorients the up vector"); + } + + #[test] + fn end_drag_clears_the_active_gesture() { + let mut nav = ViewportNavigator::new(); + nav.begin_drag(NavGesture::Orbit, vp(40.0, 40.0)); + assert!(nav.is_dragging()); + nav.end_drag(); + assert!( + !nav.is_dragging(), + "losing focus or releasing the button drops the in-flight drag" + ); + let Ok(after) = nav.drag_to(vp(80.0, 80.0), camera(), extent()) else { + panic!("a cleared navigator passes the camera through"); + }; + assert_eq!( + after, + camera(), + "a move after the drag clears must not orbit" + ); + } +}