//! 3D mouse input, shared by the desktop and web apps. Each app reads the device its own way (the //! desktop through HID, the web app through the browser's Gamepad API) and hands its six raw axes //! to [`navigate_frame`] once per frame. This module maps them, applies the dead zone and drops //! 3DxWare's emulated wheel; the page showing the camera moves it (`navigation::device`), so the //! cap does what that page's camera may do. use std::time::Duration; use crate::SolveApp; /// What a 3Dconnexion device reports at full deflection, in raw units, on every axis. pub const FULL_SCALE: i16 = 350; /// One reading of the six axes in the device's raw units: X right, Y towards the user, Z down, /// and the rotations about those axes (right-handed). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Axes { pub translation: [i16; 3], pub rotation: [i16; 3], } impl Axes { /// Some axis is past the dead zone: the cap is being moved. pub fn moving(self) -> bool { self.translation.iter().chain(&self.rotation).any(|&v| normalized(v) != 0.0) } } /// Seconds after the cap last moved that wheel events are still taken for 3DxWare's: one can be /// queued just before the cap stops and only reach the next frame. pub const WHEEL_QUIET_S: f64 = 0.15; /// 3DxWare turns the cap's motion into mouse-wheel turns for programs it has no profile for (its /// keyboard and mouse emulation), browsers included (#1336, #1380). Both apps read the cap /// themselves, so those are dropped: wheel events while the cap is moving and for /// [`WHEEL_QUIET_S`] after. 3DxWare sends them only well past the dead zone (from about a third /// of full deflection), never at rest, so a wheel turn with the cap at rest is the user's. #[derive(Debug, Default)] pub struct WheelGuard { moved_at: Option, } impl WheelGuard { /// Takes this frame's reading (`now` in seconds) and drops the frame's wheel events if they /// are 3DxWare's. pub fn filter(&mut self, moving: bool, now: f64, events: &mut Vec) { if moving { self.moved_at = Some(now); } let recent = self.moved_at.is_some_and(|at| (0.0..=WHEEL_QUIET_S).contains(&(now - at))); if recent { events.retain(|e| !matches!(e, egui::Event::MouseWheel { .. } | egui::Event::Zoom(_))); } } } /// Where one physical axis goes: its logical axis, and how. #[derive(Clone, Copy, Debug, PartialEq)] pub struct AxisRule { pub output: usize, pub enabled: bool, pub reversed: bool, pub scale: f64, } /// The physical-to-logical axis mapping 3DxWare applies (the desktop reads it from the driver's /// profile; direct reads bypass the driver). #[derive(Clone, Copy, Debug, PartialEq)] pub struct DriverMapping { pub axes: [AxisRule; 6], pub overall_scale: f64, } impl Default for DriverMapping { fn default() -> Self { Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 } } } impl DriverMapping { /// What 3DxWare applies by default in object mode (#1172): the physical Ry and Rz swapped and /// reversed, so a tilt left/right rolls and a twist spins. pub fn object_mode() -> Self { let mut mapping = Self::default(); mapping.axes[4] = AxisRule { output: 5, enabled: true, reversed: true, scale: 1.0 }; mapping.axes[5] = AxisRule { output: 4, enabled: true, reversed: true, scale: 1.0 }; mapping } /// The mapping with the user's per-axis choices folded in: `reverse` flips and `speed` scales /// each physical axis of the cap. A speed that is not a finite number counts as 1. pub fn with_user_axes(mut self, reverse: [bool; 6], speed: [f32; 6]) -> Self { for ((rule, reverse), speed) in self.axes.iter_mut().zip(reverse).zip(speed) { rule.reversed ^= reverse; rule.scale *= if speed.is_finite() { f64::from(speed).clamp(0.0, 3.0) } else { 1.0 }; } self } pub fn apply(self, axes: Axes) -> Motion { let raw = [ normalized(axes.translation[0]), normalized(axes.translation[1]), normalized(axes.translation[2]), normalized(axes.rotation[0]), normalized(axes.rotation[1]), normalized(axes.rotation[2]), ]; let mut logical = [0.0; 6]; for (input, rule) in self.axes.iter().enumerate() { if rule.enabled { let sign = if rule.reversed { -1.0 } else { 1.0 }; if let Some(slot) = logical.get_mut(rule.output) { *slot = raw[input] * sign * rule.scale * self.overall_scale; } } } Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] } } } /// The logical axes after the mapping, each in -1..1. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Motion { pub translation: [f64; 3], pub rotation: [f64; 3], } /// One frame of 3D mouse motion, waiting for the page that shows the camera: the logical axes, /// and how many seconds of motion they stand for (the frame's time, scaled by the speed). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct DeviceMotion { pub motion: Motion, pub seconds: f64, } /// Hands one frame of 3D mouse input, `seconds` after the previous frame, to the page showing /// the camera. Nothing happens with navigation switched off, on the start page or while a file /// picker is open. Returns whether there was motion to hand over; it then asks for the next frame /// itself. pub fn navigate_frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMapping, axes: Axes, seconds: f64) -> bool { if !app.preferences.space_mouse_enabled || app.home.open || app.file_picker_busy() || axes == Axes::default() { return false; } let speed = f64::from(app.preferences.space_mouse_speed); if !speed.is_finite() { return false; } app.begin_external_navigation(); // A long gap (the first frame after a pause) moves as far as a 50 ms one. let step = if seconds.is_finite() { seconds.min(0.05) } else { 0.0 }; app.viewport.device_motion = Some(DeviceMotion { motion: mapping.with_user_axes(app.preferences.space_mouse_reverse, app.preferences.space_mouse_axis_speed).apply(axes), seconds: step * speed.clamp(0.1, 5.0), }); ctx.request_repaint_after(Duration::from_millis(16)); true } /// A small dead zone avoids drift at rest. A full cap deflection takes roughly one viewport /// per second to pan and about two radians per second to turn, independent of frame rate. fn normalized(value: i16) -> f64 { let v = f64::from(value); if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) } } #[cfg(test)] mod tests { use super::*; use crate::Services; use solidcraft_engine::Session; use solidcraft_engine::render::Camera; /// The camera math the page applies the motion with (`navigation::device`): `sketch` keeps /// the orientation, as editing a sketch does. fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) { crate::navigation::device_camera(cam, motion, seconds, height, !sketch, reverse_zoom); } /// One frame: the 3D mouse hands its motion over, and the modelling viewport applies it. fn frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMapping, axes: Axes, seconds: f64) -> bool { let handed = navigate_frame(app, ctx, mapping, axes, seconds); let page = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); crate::navigation::device(app, page, crate::navigation::NavCaps::MODEL); handed } fn raw(axes: Axes) -> Motion { DriverMapping::default().apply(axes) } fn app() -> SolveApp { let mut app = SolveApp::new(Session::default(), Services::default()); app.home.open = false; app } #[test] fn per_axis_choices_flip_and_scale_one_physical_axis() { let mapping = DriverMapping::default().with_user_axes([false, true, false, false, false, false], [1.0, 1.0, 1.0, 0.5, f32::NAN, 1.0]); let still = Motion::default(); assert_eq!(mapping.apply(Axes { translation: [0, 350, 0], rotation: [0; 3] }).translation[1], -raw(PUSH).translation[1]); assert_eq!( mapping.apply(Axes { translation: [350, 0, 0], rotation: [0; 3] }).translation[0], raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }).translation[0] ); assert_eq!(mapping.apply(Axes { translation: [0; 3], rotation: [350, 0, 0] }).rotation[0], 0.5); assert_eq!(mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }).rotation[1], 1.0); assert_eq!(mapping.apply(Axes::default()), still); let off = DriverMapping::default().with_user_axes([false; 6], [0.0; 6]); assert_eq!(off.apply(PUSH), still); } const PUSH: Axes = Axes { translation: [0, 350, 0], rotation: [0; 3] }; fn wheel(dy: f32) -> egui::Event { egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, dy), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, } } fn other() -> egui::Event { egui::Event::PointerMoved(egui::pos2(1.0, 2.0)) } #[test] fn six_axes_move_the_camera_and_sketches_keep_their_orientation() { let mut cam = Camera::default(); let before = cam; navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false); assert_ne!(cam.target, before.target); assert!(cam.distance < before.distance); assert_ne!(cam.yaw, before.yaw); assert_ne!(cam.pitch, before.pitch); assert_ne!(cam.roll, before.roll); let orientation = (cam.yaw, cam.pitch, cam.roll); navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false); assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation); assert!(cam.is_valid()); } #[test] fn individual_axes_have_physical_spacemouse_meaning() { let before = Camera::default(); let mut slide = before; navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false); assert_ne!(slide.target, before.target); assert_eq!(slide.distance, before.distance); let mut push = before; navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false); assert_eq!(push.target, before.target); assert!(push.distance < before.distance); let mut lift = before; navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false); assert_ne!(lift.target, before.target); assert_eq!(lift.distance, before.distance); let mut tilt = before; navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false); assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll)); assert_ne!(tilt.pitch, before.pitch); let mut roll = before; navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false); assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll)); assert_ne!(roll.yaw, before.yaw); let mut twist = before; navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false); assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch)); assert_ne!(twist.roll, before.roll); } #[test] fn reverse_zoom_setting_applies_to_spacemouse() { let before = Camera::default(); let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }); let mut normal = before; navigate(&mut normal, motion, 0.02, 600.0, false, false); let mut reversed = before; navigate(&mut reversed, motion, 0.02, 600.0, false, true); assert!(normal.distance < before.distance); assert!(reversed.distance > before.distance); } #[test] fn object_mode_tilts_roll_and_twists_spin() { let before = Camera::default(); let mut tilt = before; navigate(&mut tilt, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false); assert_eq!((tilt.yaw, tilt.pitch), (before.yaw, before.pitch)); assert_ne!(tilt.roll, before.roll); let mut twist = before; navigate(&mut twist, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false); assert_ne!(twist.yaw, before.yaw); assert_eq!((twist.pitch, twist.roll), (before.pitch, before.roll)); // The physical tilt right (Ry−) rolls one way and the clockwise twist (Rz+) spins the // other, as 3DxWare's profile has them (#1172). let tilt_right = DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, -350, 0] }); assert_eq!(tilt_right, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, 1.0] }); let twist_clockwise = DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }); assert_eq!(twist_clockwise, Motion { translation: [0.0; 3], rotation: [0.0, -1.0, 0.0] }); // Translations and the forward tilt are the raw axes. let all = Axes { translation: [100, -200, 300], rotation: [-150, 0, 0] }; assert_eq!(DriverMapping::object_mode().apply(all), DriverMapping::default().apply(all)); } #[test] fn the_dead_zone_is_rest() { assert!(!Axes::default().moving()); assert!(!Axes { translation: [20, -20, 0], rotation: [0, 20, -20] }.moving()); for i in 0..6 { let mut values = [0_i16; 6]; values[i] = 21; let axes = Axes { translation: [values[0], values[1], values[2]], rotation: [values[3], values[4], values[5]] }; assert!(axes.moving(), "axis {i}"); values[i] = -21; let axes = Axes { translation: [values[0], values[1], values[2]], rotation: [values[3], values[4], values[5]] }; assert!(axes.moving(), "axis {i} reversed"); } assert!(Axes { translation: [i16::MIN, 0, 0], rotation: [0, 0, i16::MAX] }.moving()); } #[test] fn a_mapping_never_reaches_past_the_six_axes() { let mut mapping = DriverMapping::default(); mapping.axes[0].output = 6; mapping.axes[1].output = usize::MAX; let motion = mapping.apply(Axes { translation: [350, 350, 350], rotation: [350; 3] }); assert_eq!(motion.translation, [0.0, 0.0, 1.0]); assert_eq!(motion.rotation, [1.0; 3]); } #[test] fn a_frame_moves_the_view_and_keeps_the_next_frame_coming() { let ctx = egui::Context::default(); let mut app = app(); let before = app.cam; assert!(frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02)); assert!(app.cam.distance < before.distance); // At rest, or with no time passed, nothing moves. let still = app.cam; assert!(!frame(&mut app, &ctx, DriverMapping::default(), Axes::default(), 0.02)); assert!(frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.0)); assert_eq!(app.cam.distance, still.distance); } #[test] fn an_open_file_picker_holds_the_view_until_it_closes() { let ctx = egui::Context::default(); let mut app = app(); let slot: crate::picker_test_support::Slot = Default::default(); crate::picker_test_support::open(&mut app, &slot); app.pick_open(|_, _| {}); assert!(app.file_picker_busy()); let before = app.cam; assert!(!frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02)); assert_eq!(app.cam.distance, before.distance); // Cancelled: the 3D mouse moves the view again. *slot.borrow_mut() = Some(None); crate::file_picker::tick(&mut app, &ctx); assert!(!app.file_picker_busy()); assert!(frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02)); assert!(app.cam.distance < before.distance); } #[test] fn a_long_gap_between_frames_moves_as_far_as_a_short_one() { let ctx = egui::Context::default(); let (mut short, mut long) = (app(), app()); frame(&mut short, &ctx, DriverMapping::default(), PUSH, 0.05); frame(&mut long, &ctx, DriverMapping::default(), PUSH, 30.0); assert_eq!(short.cam.distance, long.cam.distance); for seconds in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] { let mut app = app(); let before = app.cam; frame(&mut app, &ctx, DriverMapping::default(), PUSH, seconds); assert_eq!(app.cam.distance, before.distance, "{seconds}"); } } #[test] fn navigation_off_the_start_page_and_a_hostile_speed_leave_the_view_alone() { let ctx = egui::Context::default(); let mut off = app(); off.preferences.space_mouse_enabled = false; let mut home = app(); home.home.open = true; let mut nan = app(); nan.preferences.space_mouse_speed = f32::NAN; for mut app in [off, home, nan] { let before = app.cam; assert!(!frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02)); assert_eq!(app.cam.distance, before.distance); } // Speed follows the preference, within 0.1 to 5 times. let speeds = [0.5, 2.0, 5.0, f32::MAX, 0.25, 0.0, -3.0]; let distances: Vec = speeds .iter() .map(|&speed| { let mut app = app(); app.preferences.space_mouse_speed = speed; frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02); assert!(app.cam.is_valid(), "{speed}"); app.cam.distance }) .collect(); assert!(distances[1] < distances[0]); assert_eq!(distances[3], distances[2], "past 5 times is 5 times"); // Under 0.1 times is 0.1 times (the f32 preference can't hold 0.1 exactly): still a // motion, less than at 0.25. assert_eq!(distances[6], distances[5], "under 0.1 times is 0.1 times"); assert!(distances[4] < distances[5] && distances[5] < app().cam.distance); } #[test] fn wheel_turns_are_dropped_while_the_cap_moves_and_kept_at_rest() { let mut guard = WheelGuard::default(); // At rest, the wheel is the user's. let mut events = vec![wheel(100.0), other()]; guard.filter(false, 10.0, &mut events); assert_eq!(events.len(), 2); // Moving: 3DxWare's wheel (and a pinch zoom) goes; everything else stays. let mut events = vec![wheel(-100.0), other(), egui::Event::Zoom(1.1), wheel(100.0)]; guard.filter(true, 11.0, &mut events); assert_eq!(events, vec![other()]); // Just after the cap stops, a queued one still goes. let mut events = vec![wheel(100.0)]; guard.filter(false, 11.0 + WHEEL_QUIET_S - 0.01, &mut events); assert!(events.is_empty()); // Later, the wheel is the user's again. let mut events = vec![wheel(100.0)]; guard.filter(false, 11.0 + WHEEL_QUIET_S + 0.01, &mut events); assert_eq!(events.len(), 1); // A clock that goes back never swallows the user's wheel. let mut events = vec![wheel(100.0)]; guard.filter(false, 5.0, &mut events); assert_eq!(events.len(), 1); let mut events = vec![wheel(100.0)]; guard.filter(false, f64::NAN, &mut events); assert_eq!(events.len(), 1); } }