diff --git a/README.md b/README.md index e99eef6d..54759d7a 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,9 @@ Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p s On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view. While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation -page in Preferences has an on/off switch and a speed control for the 3D mouse. +page in Preferences has an on/off switch and a speed control for the 3D mouse. The web app reads +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)). 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 diff --git a/apps/solidcraft-web/Cargo.toml b/apps/solidcraft-web/Cargo.toml index db27ddaf..4c615fb4 100644 --- a/apps/solidcraft-web/Cargo.toml +++ b/apps/solidcraft-web/Cargo.toml @@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [ "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager", "FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream", "WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", - "BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", + "BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad", ] } diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index b9ff0d74..555fe38c 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -10,6 +10,8 @@ mod files; #[cfg(target_arch = "wasm32")] mod install; +#[cfg(any(target_arch = "wasm32", test))] +mod space_mouse; #[cfg(target_arch = "wasm32")] mod web { @@ -21,6 +23,7 @@ mod web { use wasm_bindgen::JsCast as _; use crate::files::{self, Files, Intent}; + use crate::space_mouse::SpaceMouse; const CANVAS_ID: &str = "solidcraft_canvas"; const LOADING_ID: &str = "solidcraft_loading"; @@ -67,6 +70,7 @@ mod web { /// On-demand CJK font: fetched only when a CJK language is selected. font: solidcraft_ui_egui::fonts::CjkFontLoader, font_result: FontLoadResult, + space_mouse: SpaceMouse, } impl Shell { @@ -382,11 +386,14 @@ mod web { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.tick_font(ctx); self.app.logic(ctx); + self.space_mouse.logic(&mut self.app, ctx); self.files_tick(ctx); UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app))); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { files::take_drops(&self.files, raw); + // Before the app's own hook, which adds the control channel's events. + self.space_mouse.raw_input_hook(ctx, self.app.preferences.space_mouse_enabled, raw); self.app.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { @@ -516,6 +523,7 @@ mod web { recents_synced: false, font: Default::default(), font_result: Rc::new(std::cell::RefCell::new(None)), + space_mouse: SpaceMouse::default(), })) }), ) diff --git a/apps/solidcraft-web/src/space_mouse.rs b/apps/solidcraft-web/src/space_mouse.rs new file mode 100644 index 00000000..9707b89d --- /dev/null +++ b/apps/solidcraft-web/src/space_mouse.rs @@ -0,0 +1,351 @@ +//! The 3D mouse in the browser (#1336), through the Gamepad API. +//! +//! Chromium browsers list a 3Dconnexion device itself, with its six axes scaled to -1..1 from the +//! device's ±350. Firefox lists only joysticks and gamepads (a 3D mouse is a HID multi-axis +//! controller), but 3DxWare's virtual joystick carries the same six axes once 3DxWare's profile +//! for Firefox sends them there. Both come in the device's own order and signs, so the shared +//! navigation takes them with 3DxWare's object-mode mapping, as the Linux app does without a +//! profile. + +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; +const LOGITECH_3D_MICE: [u16; 11] = [0xc603, 0xc605, 0xc606, 0xc621, 0xc623, 0xc625, 0xc626, 0xc627, 0xc628, 0xc629, 0xc62b]; +/// 3Dconnexion's own vendor id. The browser lists only its 3D mice as gamepads: its mice, and the +/// mouse side of its receivers, are not gamepads. +const THREEDCONNEXION: u16 = 0x256f; +/// 3DxWare's virtual joystick. +const VIRTUAL_JOYSTICK: (u16, u16) = (0xbeef, 0x046d); + +/// What a gamepad the browser lists is, when it is a 3D mouse. A device is read before the +/// joystick: in a Chromium browser both are listed, and the joystick stays at rest. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Source { + Device, + VirtualJoystick, +} + +/// The vendor and product ids in a gamepad's id: `Name (Vendor: 046d Product: c62b)` in Chromium +/// browsers, `046d-c62b-Name` in Firefox. +fn vendor_product(id: &str) -> Option<(u16, u16)> { + fn hex(digits: &str) -> Option { + if (1..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_hexdigit()) { u16::from_str_radix(digits, 16).ok() } else { None } + } + if let Some((_, ids)) = id.rsplit_once("Vendor: ") { + let (vendor, rest) = ids.split_once(" Product: ")?; + let product = rest.split(|c: char| !c.is_ascii_hexdigit()).next()?; + return Some((hex(vendor)?, hex(product)?)); + } + let mut parts = id.splitn(3, '-'); + let (vendor, product, _name) = (parts.next()?, parts.next()?, parts.next()?); + Some((hex(vendor)?, hex(product)?)) +} + +/// Whether a gamepad is a 3D mouse, from the id the browser gives it. +pub fn source(id: &str) -> Option { + match vendor_product(id)? { + VIRTUAL_JOYSTICK => Some(Source::VirtualJoystick), + (THREEDCONNEXION, _) => Some(Source::Device), + (LOGITECH, product) if LOGITECH_3D_MICE.contains(&product) => Some(Source::Device), + _ => None, + } +} + +/// The six axes in the device's raw units, from a gamepad's axes (-1..1). Fewer than six: not a +/// 3D mouse. A value that isn't a number reads as rest. +pub fn axes(values: &[f64]) -> Option { + let raw = |i: usize| values.get(i).map(|&v| if v.is_finite() { (v.clamp(-1.0, 1.0) * f64::from(FULL_SCALE)).round() as i16 } else { 0 }); + Some(Axes { translation: [raw(0)?, raw(1)?, raw(2)?], rotation: [raw(3)?, raw(4)?, raw(5)?] }) +} + +/// The 3D mouse to read among the ones listed: the first device, else the first joystick. +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; + +#[cfg(target_arch = "wasm32")] +mod browser { + use std::cell::Cell; + use std::rc::Rc; + + use solidcraft_ui_egui::SolveApp; + use solidcraft_ui_egui::space_mouse::{Axes, DriverMapping, navigate_frame}; + use wasm_bindgen::JsCast as _; + use wasm_bindgen::closure::Closure; + + use super::{WheelGuard, axes, choose, source}; + + /// How often the page looks for the cap starting to move while nothing else redraws. + const WAKE_MS: i32 = 50; + + /// The 3D mouse the browser lists, if any. + fn read() -> Option { + let pads = web_sys::window()?.navigator().get_gamepads().ok()?; + choose(pads.iter().filter_map(|pad| { + let pad = pad.dyn_into::().ok().filter(web_sys::Gamepad::connected)?; + let source = source(&pad.id())?; + let values: Vec = pad.axes().iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect(); + Some((source, axes(&values)?)) + })) + } + + /// Reads the 3D mouse once per frame and moves the view with it. + #[derive(Default)] + pub struct SpaceMouse { + axes: Axes, + /// The previous frame's time and the step since it (seconds). + last: Option, + seconds: f64, + wheel: WheelGuard, + /// Listening for gamepads (from the first frame with navigation on). + listening: bool, + /// The timer that wakes the app when the cap starts moving runs (from when a 3D mouse + /// is first listed). + waking: bool, + /// Navigation is on (the waker reads it). + enabled: Rc>, + } + + impl SpaceMouse { + /// At the start of a frame: reads the cap and drops 3DxWare's emulated wheel from the + /// frame's input, before the app sees it. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, enabled: bool, raw: &mut egui::RawInput) { + let now = raw.time.unwrap_or_else(|| js_sys::Date::now() / 1000.0); + self.seconds = self.last.map_or(0.0, |last| now - last); + self.last = Some(now); + self.enabled.set(enabled); + self.axes = Axes::default(); + if !enabled { + return; + } + if !self.listening { + self.listening = true; + listen(ctx); + } + let reading = read(); + if reading.is_some() && !self.waking { + self.waking = wake(ctx, &self.enabled); + } + // Inside the dead zone is rest: a cap resting just off zero doesn't keep redrawing. + self.axes = reading.filter(|axes| axes.moving()).unwrap_or_default(); + self.wheel.filter(self.axes.moving(), now, &mut raw.events); + } + + pub fn logic(&self, app: &mut SolveApp, ctx: &egui::Context) { + navigate_frame(app, ctx, DriverMapping::object_mode(), self.axes, self.seconds); + } + } + + /// A 3D mouse is listed only once it has been moved on the page: redraw then, so the frame + /// finds it without waiting for the pointer. + fn listen(ctx: &egui::Context) { + let Some(window) = web_sys::window() else { return }; + let ctx = ctx.clone(); + let on_connect = Closure::::new(move || ctx.request_repaint()); + let _ = window.add_event_listener_with_callback("gamepadconnected", on_connect.as_ref().unchecked_ref()); + // The listener lives as long as the page. + on_connect.forget(); + } + + /// The browser sends no event when a gamepad moves, so a timer looks while the app is idle and + /// redraws when the cap starts moving; the frames that follow keep going on their own. + fn wake(ctx: &egui::Context, enabled: &Rc>) -> bool { + let Some(window) = web_sys::window() else { return false }; + let (ctx, enabled) = (ctx.clone(), Rc::clone(enabled)); + let tick = Closure::::new(move || { + if enabled.get() && read().is_some_and(Axes::moving) { + ctx.request_repaint(); + } + }); + let started = window.set_interval_with_callback_and_timeout_and_arguments_0(tick.as_ref().unchecked_ref(), WAKE_MS).is_ok(); + // The timer lives as long as the page. + tick.forget(); + started + } +} + +#[cfg(test)] +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). + assert_eq!(source("SpaceMouse Pro (Vendor: 046d Product: c62b)"), Some(Source::Device)); + assert_eq!(source("Unknown Gamepad (Vendor: beef Product: 046d)"), Some(Source::VirtualJoystick)); + assert_eq!(source("beef-046d-Unknown Gamepad"), Some(Source::VirtualJoystick)); + // 3Dconnexion's own vendor id, any product; the older devices under Logitech's. + assert_eq!(source("SpaceMouse Compact (Vendor: 256f Product: c635)"), Some(Source::Device)); + assert_eq!(source("256f-c652-3Dconnexion Universal Receiver"), Some(Source::Device)); + assert_eq!(source("46d-c626-SpaceNavigator"), Some(Source::Device)); + assert_eq!(source("SpaceNavigator (STANDARD GAMEPAD Vendor: 046d Product: c626)"), Some(Source::Device)); + for product in LOGITECH_3D_MICE { + assert_eq!(source(&format!("x (Vendor: 046d Product: {product:04x})")), Some(Source::Device)); + assert_eq!(source(&format!("046d-{product:04X}-x")), Some(Source::Device)); + } + } + + #[test] + fn gamepads_and_other_logitech_devices_are_not_3d_mice() { + for id in [ + "Logitech Dual Action (STANDARD GAMEPAD Vendor: 046d Product: c216)", + "046d-c21d-Logitech Gamepad F310", + "G29 Driving Force Racing Wheel (Vendor: 046d Product: c24f)", + "046d-c215-Logitech Extreme 3D", + "NuLOOQ navigator (Vendor: 046d Product: c640)", + "Xbox 360 Controller (XInput STANDARD GAMEPAD)", + "45e-28e-Xbox 360 Wired Controller", + "beef-046e-Unknown Gamepad", + "beee-046d-Unknown Gamepad", + "", + "-", + "--", + "256f-", + "256f-c635", + "Vendor: 256f", + "Vendor: 256f Product: ", + "x (Vendor: 256fff Product: c635)", + "x (Vendor: zz6f Product: c635)", + "12345-c635-x", + "256f--x", + "SpaceMouse Pro", + ] { + assert_eq!(source(id), None, "{id:?}"); + } + } + + #[test] + fn ids_never_panic() { + // Hostile and odd ids: multi-byte text around the markers, markers repeated or cut short. + for id in [ + "Vendor: é Product: ü", + "Vendor: Vendor: 256f Product: c635)", + "é-ü-ö", + "Ⅰ (Vendor: 256f Product: c6Ⅰ)", + "\u{0}-\u{0}-\u{0}", + "Vendor: 256f Product: c635 Vendor: ", + ] { + let _ = source(id); + } + let mut seed = 0x2545_f491_4f6c_dd1d_u64; + let alphabet: Vec = "0123456789abcdefABCDEF- ():VendorProductéⅠ\u{0}".chars().collect(); + for _ in 0..20_000 { + let len = (seed % 40) as usize; + let id: String = (0..len) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + alphabet[(seed % alphabet.len() as u64) as usize] + }) + .collect(); + if let Some(Source::Device) = source(&id) { + let (vendor, product) = vendor_product(&id).expect("recognised ids have both"); + assert!(vendor == THREEDCONNEXION || (vendor == LOGITECH && LOGITECH_3D_MICE.contains(&product)), "{id:?}"); + } + } + } + + #[test] + fn gamepad_axes_are_the_devices_raw_units() { + // Full deflection is the desktop's 350, and the browser's scale keeps the device's steps. + let axes = axes(&[1.0, -1.0, 0.5, -0.5, 2.0 / 700.0, 0.0]).expect("six axes"); + assert_eq!(axes, Axes { translation: [350, -350, 175], rotation: [-175, 1, 0] }); + for raw in -350_i16..=350 { + let value = f64::from(raw) / 350.0; + assert_eq!(super::axes(&[value; 6]).map(|a| a.translation[0]), Some(raw)); + } + // Past full deflection is full deflection; anything that isn't a number is rest. + let hostile = super::axes(&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300, f64::MIN_POSITIVE]).expect("six axes"); + assert_eq!(hostile, Axes { translation: [0, 0, 0], rotation: [350, -350, 0] }); + // A gamepad with fewer than six axes is not a 3D mouse; extra axes are ignored. + assert_eq!(super::axes(&[1.0; 5]), None); + assert_eq!(super::axes(&[]), None); + assert_eq!(super::axes(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]), Some(Axes::default())); + } + + #[test] + fn a_device_is_read_before_the_virtual_joystick() { + let moving = Axes { translation: [100, 0, 0], rotation: [0; 3] }; + let other = Axes { translation: [0, 200, 0], rotation: [0; 3] }; + assert_eq!(choose([(Source::VirtualJoystick, other), (Source::Device, moving)]), Some(moving)); + assert_eq!(choose([(Source::Device, moving), (Source::VirtualJoystick, other)]), Some(moving)); + assert_eq!(choose([(Source::VirtualJoystick, other)]), Some(other)); + // Two devices: the first listed. + 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/space_mouse.rs b/apps/solidcraft/src/space_mouse.rs index 62734b6e..d7873476 100644 --- a/apps/solidcraft/src/space_mouse.rs +++ b/apps/solidcraft/src/space_mouse.rs @@ -15,79 +15,18 @@ use hid_descriptor::Layout; use hidapi::{DeviceInfo, HidApi}; use quick_xml::Reader; use quick_xml::events::Event; -use solidcraft_engine::render::Camera; use solidcraft_ui_egui::SolveApp; +use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, 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; -#[derive(Clone, Copy, Debug, Default, PartialEq)] -struct Axes { - translation: [i16; 3], - rotation: [i16; 3], -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct AxisRule { - output: usize, - enabled: bool, - reversed: bool, - scale: f64, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct DriverMapping { - axes: [AxisRule; 6], - 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 { - /// 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): the physical Ry and Rz swapped - /// and reversed, so a tilt left/right rolls and a twist spins. Elsewhere the driver writes a - /// profile, and the raw axes are used until it does. - fn without_profile() -> Self { - let mut mapping = Self::default(); - if cfg!(target_os = "linux") { - 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 - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq)] -struct Motion { - translation: [f64; 3], - rotation: [f64; 3], -} - -impl DriverMapping { - 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 }; - logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale; - } - } - Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] } - } +/// 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. +fn without_profile() -> DriverMapping { + if cfg!(target_os = "linux") { DriverMapping::object_mode() } else { DriverMapping::default() } } #[derive(Clone, Copy, Debug, Default)] @@ -392,87 +331,38 @@ 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(DriverMapping::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 } } pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) { let now = Instant::now(); - let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05); + let seconds = now.duration_since(self.last_frame).as_secs_f64(); self.last_frame = now; if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER { - let updated = load_driver_mapping().unwrap_or_else(DriverMapping::without_profile); + let updated = load_driver_mapping().unwrap_or_else(without_profile); if updated != self.driver_mapping { log::info!("3D mouse: applying updated 3DxWare axis profile"); self.driver_mapping = updated; } self.mapping_checked_at = now; } - if !app.preferences.space_mouse_enabled || app.home.open { - return; - } let axes = match self.shared.lock() { Ok(reading) => reading.current(now), Err(_) => return, }; - if axes == Axes::default() { - return; - } - let speed = f64::from(app.preferences.space_mouse_speed); - if !speed.is_finite() { - return; - } - app.begin_external_navigation(); - let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0))); - navigate( - &mut app.cam, - self.driver_mapping.apply(axes), - seconds * speed.clamp(0.1, 5.0), - height, - app.session.active_sketch.is_some(), - app.preferences.zoom_reverse, - ); - ctx.request_repaint_after(Duration::from_millis(16)); - } -} - -/// 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) } -} - -fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) { - if !(seconds > 0.0) || !seconds.is_finite() { - return; - } - let [x, y, z] = motion.translation; - let [rx, ry, rz] = motion.rotation; - // Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's - // profile above can swap the physical forward/back and up/down axes before they reach here. - cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height); - let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 }; - cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None); - if !sketch { - // These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt, - // Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected - // navigation mode, so swapping them again here would make Roll behave like Spin. - cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds); - cam.roll_by(rz * 2.0 * seconds); + navigate_frame(app, ctx, self.driver_mapping, axes, seconds); } } #[cfg(test)] mod tests { use super::*; + use solidcraft_ui_egui::space_mouse::{DeviceMotion, Motion}; - fn raw(axes: Axes) -> Motion { - DriverMapping::default().apply(axes) + /// The logical axes a reading reaches. Logical Rz rolls the view and logical Ry turns it about + /// its vertical (`navigation::device_camera` in solidcraft-ui-egui, tested there). + fn rotation(mapping: DriverMapping, rotation: [i16; 3]) -> [f64; 3] { + mapping.apply(Axes { translation: [0; 3], rotation }).rotation } #[test] @@ -519,69 +409,6 @@ mod tests { assert_eq!(reading.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] }); } - #[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 applies_3dxware_axis_swaps_and_reversals() { let profile = br#" @@ -615,38 +442,17 @@ mod tests { "#; let profile = parse_driver_mapping(object_mode).expect("valid 3DxWare profile"); if cfg!(target_os = "linux") { - assert_eq!(DriverMapping::without_profile(), profile); + assert_eq!(without_profile(), profile); // Tilting the cap rolls the view and twisting it spins it, as with the driver. - let before = Camera::default(); - let mut tilt = before; - navigate( - &mut tilt, - DriverMapping::without_profile().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::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), - 0.02, - 600.0, - false, - false, - ); - assert_ne!(twist.yaw, before.yaw); - assert_eq!(twist.roll, before.roll); + 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!(DriverMapping::without_profile(), DriverMapping::default()); + assert_eq!(without_profile(), DriverMapping::default()); } } #[test] - fn mapped_roll_and_spin_reach_distinct_camera_rotations() { + fn mapped_roll_and_spin_reach_the_roll_and_turn_axes() { let profile = br#" trueHIDMultiAxis_RyHIDMultiAxis_Rzfalse @@ -654,16 +460,28 @@ mod tests { "#; let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile"); - let before = Camera::default(); + // The physical roll reaches the roll axis only, and the physical spin the turn only. + assert_eq!(rotation(mapping, [0, 350, 0]), [0.0, 0.0, 1.0]); + assert_eq!(rotation(mapping, [0, 0, 350]), [0.0, -1.0, 0.0]); + } - let mut physical_roll = before; - navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false); - assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch)); - assert_ne!(physical_roll.roll, before.roll); - - let mut physical_spin = before; - navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false); - assert_ne!(physical_spin.yaw, before.yaw); - assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll)); + #[test] + fn each_frame_hands_the_view_its_motion_through_the_drivers_mapping() { + // The reader's latest tilt, and a profile that makes tilt roll: the frame's motion rolls. + let now = Instant::now(); + let mut reading = Reading::new(now); + reading.axes.rotation = [0, 350, 0]; + let mut mouse = SpaceMouse { + shared: Arc::new(Mutex::new(reading)), + last_frame: now - Duration::from_millis(20), + driver_mapping: DriverMapping::object_mode(), + mapping_checked_at: now, + }; + let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default()); + app.home.open = false; + mouse.tick(&mut app, &Context::default()); + let DeviceMotion { motion, seconds } = app.viewport.device_motion.expect("the frame hands the page its motion"); + assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] }); + assert!(seconds > 0.0); } } diff --git a/assets/locales/en.json b/assets/locales/en.json index 2b8345bc..12317a2c 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -344,7 +344,7 @@ "sketch.make_selection": "Convert the selection to {linetype}", "ui.1_degree_of_freedom": "1 degree of freedom", "ui.3d_mouse_speed": "3D mouse speed", - "ui.3d_mouse_windows": "3D mouse (Windows)", + "ui.3d_mouse_windows": "3D mouse", "ui.3d_sketch": "3D Sketch", "ui.about": "About", "ui.activate": "Activate", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 0b185008..6aabcff4 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -344,7 +344,7 @@ "sketch.make_selection": "Convertir la sélection en {linetype}", "ui.1_degree_of_freedom": "1 degré de liberté", "ui.3d_mouse_speed": "Vitesse de la souris 3D", - "ui.3d_mouse_windows": "Souris 3D (Windows)", + "ui.3d_mouse_windows": "Souris 3D", "ui.3d_sketch": "Esquisse 3D", "ui.about": "À propos", "ui.activate": "Activer", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 5f718d41..b88765b2 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -344,7 +344,7 @@ "sketch.make_selection": "選択を{linetype}にする", "ui.1_degree_of_freedom": "自由度 1", "ui.3d_mouse_speed": "3D マウス速度", - "ui.3d_mouse_windows": "3D マウス (Windows)", + "ui.3d_mouse_windows": "3D マウス", "ui.3d_sketch": "3D スケッチ", "ui.about": "SolidCraft について", "ui.activate": "アクティブ化", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 15477724..1aa22081 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -329,7 +329,7 @@ "sketch.make_selection": "Преобразовать выделение в {linetype}", "ui.1_degree_of_freedom": "1 степень свободы", "ui.3d_mouse_speed": "Скорость 3D-мыши", - "ui.3d_mouse_windows": "3D-мышь (Windows)", + "ui.3d_mouse_windows": "3D-мышь", "ui.3d_sketch": "3D-эскиз", "ui.about": "О программе", "ui.activate": "Активировать", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index a5ea3674..2ffc11cd 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -308,7 +308,7 @@ "sketch.make_selection": "Перетворити виділене на {linetype}", "ui.1_degree_of_freedom": "1 ступінь вільності", "ui.3d_mouse_speed": "Швидкість 3D-миші", - "ui.3d_mouse_windows": "3D-миша (Windows)", + "ui.3d_mouse_windows": "3D-миша", "ui.3d_sketch": "3D-ескіз", "ui.about": "Про програму", "ui.activate": "Активувати", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 2168aa33..8a8432d5 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -344,7 +344,7 @@ "sketch.make_selection": "将选择转换为 {linetype}", "ui.1_degree_of_freedom": "1 个自由度", "ui.3d_mouse_speed": "3D 鼠标速度", - "ui.3d_mouse_windows": "3D 鼠标(Windows)", + "ui.3d_mouse_windows": "3D 鼠标", "ui.3d_sketch": "3D 草图", "ui.about": "关于", "ui.activate": "激活", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3e58cd15..05366d1c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -149,6 +149,7 @@ pub mod sketch_tools; pub mod smart_extrude; #[cfg(test)] mod smart_extrude_ui_tests; +pub mod space_mouse; pub mod stand_up; #[cfg(test)] mod sweep_dialog_tests; diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index 654ed752..866a89c4 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -5,9 +5,10 @@ //! One entry point, [`handle`], reads a frame's pointer, wheel, trackpad and modifier input over a //! view and moves `app.cam` the way Preferences › Navigation says: the navigation preset (Fusion, //! SolidWorks, Blender, Touchpad…), Orbit around, zoom to the cursor, the middle double-click and -//! orbit snapping. The SpaceMouse, view keys and the navigators act on `app.cam` independently of -//! the page. New canvases must call `navigation::handle` rather than reading drags or the wheel -//! themselves; `hand_rolled_camera_input_is_rejected` fails when they do. +//! orbit snapping. The 3D mouse's motion goes through [`device`] too, with the page's +//! [`NavCaps`]. The view keys and the navigators act on `app.cam` independently of the page. New +//! canvases must call `navigation::handle` rather than reading drags or the wheel themselves; +//! `hand_rolled_camera_input_is_rejected` fails when they do. use egui::{Pos2, Rect, pos2, vec2}; use serde_json::json; @@ -16,6 +17,7 @@ use solidcraft_engine::render::Camera; use crate::SolveApp; use crate::prefs::{OrbitAround, pivot_under}; +use crate::space_mouse::Motion; use crate::viewport::{Hit, NavMode, axis_rect, nearest_quarter_turn, pick, pick_cached, projection}; /// What a page's camera may do. @@ -244,6 +246,8 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e // Ctrl or Alt held: drawing and dragging don't snap. crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command); middle_double_click(app, ui, inside); + // The 3D mouse's motion this frame, with what this page's camera may do. + device(app, rect, caps); // ---- navigation ---- let trackpad_pan = app.preferences.trackpad_pans(); @@ -464,6 +468,40 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e CameraInput { hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, delta, inside, axes, pinch, pan, primary_nav, mode } } +/// The 3D mouse's motion this frame (#1336), on a page `rect` whose camera may do `caps`: the cap +/// pans and zooms every page, and turns the view only where the page may orbit and no sketch is +/// being edited (a sketch keeps its view square to it). [`handle`] calls it for its page; the 3D +/// mouse only reads the device and queues the motion ([`crate::space_mouse::navigate_frame`]). +pub(crate) fn device(app: &mut SolveApp, rect: Rect, caps: NavCaps) { + let Some(queued) = app.viewport.device_motion.take() else { return }; + let turn = caps.orbit && app.session.active_sketch.is_none(); + let height = f64::from(rect.height().max(1.0)); + device_camera(&mut app.cam, queued.motion, queued.seconds, height, turn, app.preferences.zoom_reverse); +} + +/// `seconds` of 3D mouse `motion` on `cam`, in a view `height` points tall. A full deflection +/// pans about 0.6 of the view's height a second and turns about two radians a second, whatever +/// the frame rate. With `turn` off the view keeps its orientation. +pub(crate) fn device_camera(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, turn: bool, reverse_zoom: bool) { + if !(seconds > 0.0) || !seconds.is_finite() { + return; + } + let [x, y, z] = motion.translation; + let [rx, ry, rz] = motion.rotation; + // Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's + // profile can swap the physical forward/back and up/down axes before they reach here. + cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height); + let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 }; + cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None); + if turn { + // These are logical 3DxWare coordinates after the profile mapping: Rx is Tilt, Ry is + // Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected navigation + // mode, so swapping them again here would make Roll behave like Spin. + cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds); + cam.roll_by(rz * 2.0 * seconds); + } +} + /// Six principal views, twelve edge diagonals and eight isometric corner directions. pub(crate) fn snap_directions() -> impl Iterator { [-1.0, 0.0, 1.0].into_iter().flat_map(|x| { @@ -637,15 +675,15 @@ pub(crate) fn orbit_about(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64 mod tests { /// Every page with a camera navigates through [`super::handle`] (#1454). A page that reads /// the wheel or moves the view camera itself would drift from the user's navigation settings, - /// so outside this module (and the SpaceMouse driver, a device rather than a page) the camera - /// may only be moved through it, the view commands or the navigators. + /// so outside this module the camera may only be moved through it, the view commands or the + /// navigators. The 3D mouse in both apps hands its motion to [`super::device`]. #[test] fn hand_rolled_camera_input_is_rejected() { const PATTERNS: [&str; 7] = ["smooth_scroll_delta", "raw_scroll_delta", "zoom_delta()", "cam.orbit(", "cam.pan(", "cam.zoom_at(", "cam.roll_by("]; let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let dirs = [root.join("src"), root.join("../../apps/solidcraft/src")]; - let allowed = ["navigation.rs", "space_mouse.rs"]; + let dirs = [root.join("src"), root.join("../../apps/solidcraft/src"), root.join("../../apps/solidcraft-web/src")]; + let allowed = ["navigation.rs"]; let mut offenders = Vec::new(); let mut scanned = 0; let mut stack: Vec = dirs.iter().filter(|d| d.is_dir()).cloned().collect(); @@ -661,7 +699,8 @@ mod tests { continue; } scanned += 1; - let text = std::fs::read_to_string(&path).unwrap(); + // A Windows checkout's CRLF lines, read as written, so the split below finds the tests. + let text = std::fs::read_to_string(&path).unwrap().replace("\r\n", "\n"); // In-file unit tests set cameras up directly; only the code above them counts. let code = text.split("\n#[cfg(test)]\nmod ").next().unwrap_or_default(); for (n, line) in code.lines().enumerate() { @@ -739,4 +778,60 @@ mod tests { assert_ne!(app.cam.distance, before.distance, "{preset}: the wheel zooms"); } } + + /// The 3D mouse pans and zooms a flat page with the cap and never turns it, as the mouse + /// does there; pages that orbit turn with it (#1336). + #[test] + fn the_3d_mouse_pans_and_zooms_a_flat_page_but_never_turns_it() { + use crate::space_mouse::{DeviceMotion, Motion}; + let every_axis = DeviceMotion { motion: Motion { translation: [1.0, 1.0, 1.0], rotation: [1.0, 1.0, 1.0] }, seconds: 0.05 }; + let flat = super::NavCaps { orbit: false, pick: false }; + for (caps, turns) in [(flat, false), (super::NavCaps::MODEL, true), (super::NavCaps::RENDER, true)] { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &serde_json::json!({"length":40,"width":30,"height":20})).unwrap(); + let mut app = crate::SolveApp::new(s, crate::Services::default()); + let ctx = egui::Context::default(); + let before = app.cam; + app.viewport.device_motion = Some(every_axis); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), + time: Some(0.0), + focused: true, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + let rect = ui.max_rect(); + app.viewport.rect = Some(rect); + let resp = ui.interact(rect, ui.id().with("page"), egui::Sense::click_and_drag()); + super::handle(&mut app, ui, rect, &resp, caps); + }); + out.textures_delta.clear(); + assert!(app.viewport.device_motion.is_none(), "{caps:?}: the page takes the motion"); + assert_ne!(app.cam.target, before.target, "{caps:?}: the cap pans"); + assert!(app.cam.distance < before.distance, "{caps:?}: the cap zooms"); + let turned = (app.cam.yaw, app.cam.pitch, app.cam.roll) != (before.yaw, before.pitch, before.roll); + assert_eq!(turned, turns, "{caps:?}: the cap turns the view only where the page orbits"); + } + } + + /// While a sketch is edited the cap pans and zooms, and the view stays square to the sketch. + #[test] + fn the_3d_mouse_never_turns_a_sketch_being_edited() { + use crate::space_mouse::{DeviceMotion, Motion}; + let mut s = solidcraft_engine::Session::default(); + s.execute("sketch.create", &serde_json::json!({"plane": "XY"})).unwrap(); + let mut app = crate::SolveApp::new(s, crate::Services::default()); + assert!(app.session.active_sketch.is_some()); + let before = app.cam; + app.viewport.device_motion = Some(DeviceMotion { motion: Motion { translation: [1.0; 3], rotation: [1.0; 3] }, seconds: 0.05 }); + let page = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + super::device(&mut app, page, super::NavCaps::MODEL); + assert_eq!((app.cam.yaw, app.cam.pitch, app.cam.roll), (before.yaw, before.pitch, before.roll)); + assert_ne!(app.cam.target, before.target); + assert!(app.cam.distance < before.distance); + // Nothing queued, nothing moves. + let still = app.cam; + super::device(&mut app, page, super::NavCaps::MODEL); + assert_eq!(app.cam, still); + } } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index b519acad..6498e549 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1188,7 +1188,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { egui::Slider::new(&mut app.preferences.look_at_speed, 0.5..=3.0).step_by(0.25).custom_formatter(|v, _| format!("{v:.2}×")), ); ui.end_row(); - ui.label(crate::i18n::tr_label("3D mouse (Windows)")); + ui.label(crate::i18n::tr_label("3D mouse")); ui.checkbox(&mut app.preferences.space_mouse_enabled, crate::i18n::tr_label("Navigate with SpaceNavigator / SpaceMouse")); ui.end_row(); ui.label(crate::i18n::tr_label("3D mouse speed")); diff --git a/crates/ui-egui/src/space_mouse.rs b/crates/ui-egui/src/space_mouse.rs new file mode 100644 index 00000000..ff373fa3 --- /dev/null +++ b/crates/ui-egui/src/space_mouse.rs @@ -0,0 +1,351 @@ +//! 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) + } +} + +/// 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 + } + + 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.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 + } + + const PUSH: Axes = Axes { translation: [0, 350, 0], rotation: [0; 3] }; + + #[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); + } +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 12313a5a..71af58ad 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -91,6 +91,9 @@ pub struct ViewportState { pub orbit_pivot: Option, /// Continuous orbit: the view turns about the vertical until the next click or key. pub spin: bool, + /// The 3D mouse's motion this frame, for the page showing the camera to apply with what its + /// camera may do (`navigation::device`). + pub device_motion: Option, /// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends. pub(crate) wheel_zoom_raw_until_idle: bool, /// While trackpad scrolling runs, ignore egui's smoothing tail for zoom so diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index badd89ec..7aff90d5 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -110,6 +110,56 @@ cd apps/solidcraft-web && trunk serve --release --no-autoreload Offline use needs `--no-autoreload`: Trunk's live reload changes the page as it serves it, so it no longer matches the build the browser would keep. Debug builds (`trunk serve`) never keep a copy. +### 3D mouse in the browser + +The web app reads a 3Dconnexion SpaceMouse or SpaceNavigator through the browser's Gamepad API, +with the same switch and speed in Preferences › Navigation as the desktop app. Edge and Chrome +read the device as it is. A browser shows a gamepad to a page only once it has been moved there, +so if nothing happens at first, push the cap firmly once. 3DxWare also turns the cap into +mouse-wheel zoom in browsers; while the cap moves, the web app ignores the wheel and reads the +cap itself. Before the browser shows the cap to the page, that zoom still gets through, so the +first push after opening the page may zoom a little. + +Firefox doesn't list 3D mice ([Mozilla bug 1226575](https://bugzilla.mozilla.org/show_bug.cgi?id=1226575)). +On Windows it does list 3DxWare's virtual joystick, which can carry the cap instead. Save this +as `%APPDATA%\3Dconnexion\3DxWare\Cfg\Firefox.xml`, then restart Firefox (and 3DxWare, if the cap +still only zooms). It changes Firefox only: there 3DxWare sends the cap's six axes to the +joystick instead of the mouse wheel. Delete the file to undo it. + +```xml + + + + + STR_FIREFOX + FIREFOX.EXE + KMJ + + + + + ID_STR_FIREFOX + STR_FIREFOX + ID_STR_FIREFOX + + + + ID_Standard_3D_Mouse + + STR_DEFAULT_BANK + Default + trueHIDMultiAxis_X-5125110HIDJoystick_X + trueHIDMultiAxis_Y-5125110HIDJoystick_Y + trueHIDMultiAxis_Z-5125110HIDJoystick_Z + trueHIDMultiAxis_Rx-5125110HIDJoystick_Rx + trueHIDMultiAxis_Ry-5125110HIDJoystick_Ry + trueHIDMultiAxis_Rz-5125110HIDJoystick_Rz + + + + +``` + ## CLI `solidcraft-cli` runs the same commands without a window: diff --git a/docs/i18n_catalog_ja.md b/docs/i18n_catalog_ja.md index c2b5d192..002d3286 100644 --- a/docs/i18n_catalog_ja.md +++ b/docs/i18n_catalog_ja.md @@ -19,7 +19,7 @@ | 3-Point Rectangle | 3点指定長方形 | `cmd.sketch_rectangle_three_point` | | 3-Tangent Circle | 3接線指定の円 | `cmd.sketch_circle_three_tangent` | | 3D Line | 3D 線分 | `cmd.sketch_line3d` | -| 3D mouse (Windows) | 3D マウス (Windows) | `ui.3d_mouse_windows` | +| 3D mouse | 3D マウス | `ui.3d_mouse_windows` | | 3D mouse speed | 3D マウス速度 | `ui.3d_mouse_speed` | | 3D Point | 3D 点 | `cmd.sketch_point3d` | | 3D Sketch | 3D スケッチ | `ui.3d_sketch` |