diff --git a/README.md b/README.md index b5c0c8fc..f24a498d 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,10 @@ page in Preferences has an on/off switch and a speed control for the 3D mouse. T it too, through the browser; Firefox needs a 3DxWare setting first (see [Getting started › 3D mouse in the browser](docs/book/src/guide/getting-started.md#3d-mouse-in-the-browser)). +With 3DxWare on Windows, SolidCraft follows 3DxWare's profile for it when there is one, and +3DxWare's object mode (tilt to roll, twist to spin) until there is. The mouse-wheel turns that +3DxWare makes from the cap's motion are ignored, so the view doesn't zoom in steps as the cap tilts. + On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the device. A udev rule that adds the `uaccess` tag does that, for example `/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id: diff --git a/apps/solidcraft-web/src/space_mouse.rs b/apps/solidcraft-web/src/space_mouse.rs index 9707b89d..07394de5 100644 --- a/apps/solidcraft-web/src/space_mouse.rs +++ b/apps/solidcraft-web/src/space_mouse.rs @@ -9,10 +9,6 @@ use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE}; -/// 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. -const WHEEL_QUIET_S: f64 = 0.15; - /// Logitech's vendor id, which older 3Dconnexion devices carry, and those devices' product ids /// (usb.ids). Logitech's own gamepads and wheels carry the same vendor id. const LOGITECH: u16 = 0x046d; @@ -69,29 +65,6 @@ pub fn choose(found: impl IntoIterator) -> Option { found.into_iter().min_by_key(|(source, _)| *source).map(|(_, axes)| axes) } -/// 3DxWare turns the cap's motion into mouse-wheel turns in a browser (its zoom emulation). The -/// app reads the cap itself, 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(_))); - } - } -} - #[cfg(target_arch = "wasm32")] pub use browser::SpaceMouse; @@ -105,7 +78,9 @@ mod browser { use wasm_bindgen::JsCast as _; use wasm_bindgen::closure::Closure; - use super::{WheelGuard, axes, choose, source}; + use solidcraft_ui_egui::space_mouse::WheelGuard; + + use super::{axes, choose, source}; /// How often the page looks for the cap starting to move while nothing else redraws. const WAKE_MS: i32 = 50; @@ -200,19 +175,6 @@ mod browser { mod tests { use super::*; - 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 recognises_3d_mice_in_each_browsers_ids() { // As Edge and Firefox list them (Windows 11, SpaceMouse Pro, 3DxWare 10). @@ -320,32 +282,4 @@ mod tests { assert_eq!(choose([(Source::Device, moving), (Source::Device, other)]), Some(moving)); assert_eq!(choose([]), None); } - - #[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); - } } diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index bc55e866..9d2b8c89 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -77,6 +77,9 @@ impl eframe::App for App { } } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + // Before the app's own hook, which adds the control channel's events. + #[cfg(any(target_os = "windows", target_os = "linux"))] + self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw); self.solve.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { diff --git a/apps/solidcraft/src/space_mouse.rs b/apps/solidcraft/src/space_mouse.rs index d7873476..da872320 100644 --- a/apps/solidcraft/src/space_mouse.rs +++ b/apps/solidcraft/src/space_mouse.rs @@ -16,17 +16,17 @@ use hidapi::{DeviceInfo, HidApi}; use quick_xml::Reader; use quick_xml::events::Event; use solidcraft_ui_egui::SolveApp; -use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, navigate_frame}; +use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame}; const STALE_AFTER: Duration = Duration::from_millis(150); const RETRY_AFTER: Duration = Duration::from_secs(2); const MAX_PROFILE_BYTES: u64 = 1024 * 1024; -/// The mapping used when no 3DxWare profile is found. On Linux there is no 3DxWare, so it is what -/// the driver applies by default in object mode (#1172). Elsewhere the driver writes a profile, -/// and the raw axes are used until it does. +/// The mapping used when no 3DxWare profile is found: what the driver applies by default in +/// object mode. On Linux there is no 3DxWare (#1172); on Windows 3DxWare has no profile for +/// SolidCraft until one is made, and the raw axes swap roll and spin (#1380). fn without_profile() -> DriverMapping { - if cfg!(target_os = "linux") { DriverMapping::object_mode() } else { DriverMapping::default() } + DriverMapping::object_mode() } #[derive(Clone, Copy, Debug, Default)] @@ -320,6 +320,9 @@ pub struct SpaceMouse { last_frame: Instant, driver_mapping: DriverMapping, mapping_checked_at: Instant, + /// Drops 3DxWare's emulated wheel while the cap moves (#1380). + wheel: WheelGuard, + started: Instant, } impl SpaceMouse { @@ -331,7 +334,25 @@ impl SpaceMouse { if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) { log::warn!("3D mouse: reader could not start: {error}"); } - Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_else(without_profile), mapping_checked_at: now } + Self { + shared, + last_frame: now, + driver_mapping: load_driver_mapping().unwrap_or_else(without_profile), + mapping_checked_at: now, + wheel: WheelGuard::default(), + started: now, + } + } + + /// Before the app's own hook (which adds the control channel's events): with navigation on, + /// the wheel events 3DxWare makes from the cap's motion are dropped. + pub fn raw_input_hook(&mut self, enabled: bool, raw: &mut egui::RawInput) { + if !enabled { + return; + } + let now = Instant::now(); + let moving = self.shared.lock().map(|reading| reading.current(now).moving()).unwrap_or(false); + self.wheel.filter(moving, now.duration_since(self.started).as_secs_f64(), &mut raw.events); } pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) { @@ -428,7 +449,7 @@ mod tests { } #[test] - fn without_a_profile_linux_uses_the_drivers_object_mode_axes() { + fn without_a_profile_the_drivers_object_mode_axes_are_used() { // 3DxWare's object-mode mapping, as a profile (#1172: confirmed on a SpaceMouse Compact). let object_mode = br#" @@ -441,14 +462,11 @@ mod tests { "#; let profile = parse_driver_mapping(object_mode).expect("valid 3DxWare profile"); - if cfg!(target_os = "linux") { - assert_eq!(without_profile(), profile); - // Tilting the cap rolls the view and twisting it spins it, as with the driver. - assert_eq!(rotation(without_profile(), [0, 350, 0]), [0.0, 0.0, -1.0]); - assert_eq!(rotation(without_profile(), [0, 0, 350]), [0.0, -1.0, 0.0]); - } else { - assert_eq!(without_profile(), DriverMapping::default()); - } + // On every platform (#1172 on Linux, #1380 on Windows). + assert_eq!(without_profile(), profile); + // Tilting the cap rolls the view and twisting it spins it, as with the driver. + assert_eq!(rotation(without_profile(), [0, 350, 0]), [0.0, 0.0, -1.0]); + assert_eq!(rotation(without_profile(), [0, 0, 350]), [0.0, -1.0, 0.0]); } #[test] @@ -476,6 +494,8 @@ mod tests { last_frame: now - Duration::from_millis(20), driver_mapping: DriverMapping::object_mode(), mapping_checked_at: now, + wheel: WheelGuard::default(), + started: now, }; let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default()); app.home.open = false; @@ -484,4 +504,64 @@ mod tests { assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] }); assert!(seconds > 0.0); } + + fn wheel() -> egui::Event { + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + } + } + + fn mouse(reading: Reading) -> SpaceMouse { + let now = Instant::now(); + SpaceMouse { + shared: Arc::new(Mutex::new(reading)), + last_frame: now, + driver_mapping: DriverMapping::object_mode(), + mapping_checked_at: now, + wheel: WheelGuard::default(), + started: now, + } + } + + #[test] + fn the_wheel_3dxware_makes_from_the_cap_is_dropped() { + // The cap tilted forward: 3DxWare also sends wheel turns (#1380). They are dropped. + let now = Instant::now(); + let mut tilted = Reading::new(now); + tilted.axes.rotation = [-200, 0, 0]; + let mut moving = mouse(tilted); + let mut raw = egui::RawInput { events: vec![wheel(), egui::Event::Zoom(1.1), egui::Event::Text("a".into())], ..Default::default() }; + moving.raw_input_hook(true, &mut raw); + assert_eq!(raw.events, [egui::Event::Text("a".into())]); + // With navigation off, the wheel is the cap's only way to move the view: kept. + let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() }; + moving.raw_input_hook(false, &mut raw); + assert_eq!(raw.events.len(), 1); + // The cap at rest: the wheel is the user's. + let mut resting = mouse(Reading::new(now)); + let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() }; + resting.raw_input_hook(true, &mut raw); + assert_eq!(raw.events.len(), 1); + } + + #[test] + fn the_wheel_is_the_users_again_once_the_cap_has_been_still_a_moment() { + let mut tilted = Reading::new(Instant::now()); + tilted.axes.rotation = [-200, 0, 0]; + let mut cap = mouse(tilted); + let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() }; + cap.raw_input_hook(true, &mut raw); + assert!(raw.events.is_empty(), "dropped while the cap moves"); + // Let go, then wait out the quiet time. + if let Ok(mut reading) = cap.shared.lock() { + *reading = Reading::new(Instant::now()); + } + std::thread::sleep(Duration::from_secs_f64(solidcraft_ui_egui::space_mouse::WHEEL_QUIET_S) + Duration::from_millis(60)); + let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() }; + cap.raw_input_hook(true, &mut raw); + assert_eq!(raw.events.len(), 1, "kept once the cap has been still"); + } } diff --git a/crates/ui-egui/src/space_mouse.rs b/crates/ui-egui/src/space_mouse.rs index ff373fa3..bb9d76e0 100644 --- a/crates/ui-egui/src/space_mouse.rs +++ b/crates/ui-egui/src/space_mouse.rs @@ -26,6 +26,34 @@ impl Axes { } } +/// 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 { @@ -156,6 +184,19 @@ mod tests { 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(); @@ -348,4 +389,32 @@ mod tests { 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); + } }