From ee1b56cac480de6e2c61622ba94fb62704cc6d19 Mon Sep 17 00:00:00 2001 From: kenos Date: Mon, 12 Oct 2026 05:07:44 +0900 Subject: [PATCH] feat: add Windows SpaceNavigator navigation --- Cargo.lock | 15 +++ README.md | 5 + THIRD-PARTY-LICENSES.txt | 24 +++- apps/solvecraft/Cargo.toml | 3 + apps/solvecraft/src/main.rs | 34 +++-- apps/solvecraft/src/space_mouse.rs | 206 +++++++++++++++++++++++++++++ crates/render/Cargo.toml | 3 + crates/render/src/camera.rs | 55 ++++++-- crates/ui-egui/src/lib.rs | 9 ++ crates/ui-egui/src/prefs.rs | 18 +++ crates/ui-egui/src/viewport.rs | 4 +- 11 files changed, 353 insertions(+), 23 deletions(-) create mode 100644 apps/solvecraft/src/space_mouse.rs diff --git a/Cargo.lock b/Cargo.lock index d75e4e5b..f6a9c5ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1553,6 +1553,19 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hidapi" +version = "2.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818c0e1d27887aaf76fe737042e27a66b796a7b099e6d2e1a72d106c2dff3fa6" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "windows-sys 0.61.2", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -3260,6 +3273,7 @@ version = "0.2.1" dependencies = [ "eframe", "egui", + "hidapi", "log", "rfd", "serde_json", @@ -3357,6 +3371,7 @@ version = "0.2.1" dependencies = [ "image", "serde", + "serde_json", "solvecraft-geom", ] diff --git a/README.md b/README.md index f0f51fa5..f82ba3c5 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,11 @@ cargo xtask ci # fmt, clippy, tests, as Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`. +On Windows, SolveCraft 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. + In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or WebGL2 with `?webgl`; `?sample` opens the sample part). diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index 72a77357..2da8235a 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -1,6 +1,6 @@ Third-party software in SolveCraft -SolveCraft (the desktop app, solvecraft-cli and the web app) is built from the 453 crates below. +SolveCraft (the desktop app, solvecraft-cli and the web app) is built from the 454 crates below. Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files. SolveCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE. @@ -147,6 +147,7 @@ hashbrown 0.16.1 (MIT OR Apache-2.0) hashbrown 0.17.1 (MIT OR Apache-2.0) hermit-abi 0.5.3 (MIT OR Apache-2.0) hex 0.4.3 (MIT OR Apache-2.0) +hidapi 2.6.7 (MIT) iana-time-zone 0.1.65 (MIT OR Apache-2.0) iana-time-zone-haiku 0.1.2 (MIT OR Apache-2.0) icu_collections 2.3.0 (Unicode-3.0) @@ -6019,6 +6020,27 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +hidapi 2.6.7 +================================================================================ + +Copyright 2017 The hidapi-rs Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES +OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ================================================================================ iana-time-zone 0.1.65, iana-time-zone-haiku 0.1.2 ================================================================================ diff --git a/apps/solvecraft/Cargo.toml b/apps/solvecraft/Cargo.toml index 99843582..a16781e4 100644 --- a/apps/solvecraft/Cargo.toml +++ b/apps/solvecraft/Cargo.toml @@ -19,5 +19,8 @@ serde_json = { workspace = true } log = { workspace = true } rfd = { workspace = true } +[target.'cfg(target_os = "windows")'.dependencies] +hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] } + [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] winit = { version = "0.30", default-features = false, features = ["x11"] } diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index 0d9cd71b..7e358a9b 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -13,36 +13,44 @@ #![forbid(unsafe_code)] mod control_server; +#[cfg(target_os = "windows")] +mod space_mouse; use solvecraft_engine::Session; use solvecraft_ui_egui::{Services, SolveApp}; -struct App(SolveApp); +struct App { + solve: SolveApp, + #[cfg(target_os = "windows")] + space_mouse: space_mouse::SpaceMouse, +} impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved // designs first; the window closes once they are answered (`quit_requested`). - if ctx.input(|i| i.viewport().close_requested()) && !self.0.quit_requested { + if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - solvecraft_ui_egui::documents::request_quit(&mut self.0); + solvecraft_ui_egui::documents::request_quit(&mut self.solve); } - self.0.logic(ctx); - if self.0.quit_requested { + self.solve.logic(ctx); + #[cfg(target_os = "windows")] + self.space_mouse.tick(&mut self.solve, ctx); + if self.solve.quit_requested { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + self.solve.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.solve.ui(ui); } fn save(&mut self, storage: &mut dyn eframe::Storage) { - storage.set_string(PREFS_KEY, self.0.prefs()); + storage.set_string(PREFS_KEY, self.solve.prefs()); } fn on_exit(&mut self) { - solvecraft_ui_egui::recovery_ui::close(&mut self.0); + solvecraft_ui_egui::recovery_ui::close(&mut self.solve); } } @@ -205,7 +213,13 @@ fn main() -> eframe::Result { let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 }; solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9))); } - Ok(Box::new(App(app))) + #[cfg(target_os = "windows")] + let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx); + Ok(Box::new(App { + solve: app, + #[cfg(target_os = "windows")] + space_mouse, + })) }), ) } diff --git a/apps/solvecraft/src/space_mouse.rs b/apps/solvecraft/src/space_mouse.rs new file mode 100644 index 00000000..b3effda3 --- /dev/null +++ b/apps/solvecraft/src/space_mouse.rs @@ -0,0 +1,206 @@ +//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI; +//! it publishes the latest axes, and the egui thread applies them once per frame. + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use egui::Context; +use hidapi::{DeviceInfo, HidApi}; +use solvecraft_engine::render::Camera; +use solvecraft_ui_egui::SolveApp; + +const STALE_AFTER: Duration = Duration::from_millis(150); +const RETRY_AFTER: Duration = Duration::from_secs(2); + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Axes { + translation: [i16; 3], + rotation: [i16; 3], +} + +#[derive(Clone, Copy)] +struct Reading { + axes: Axes, + translation_at: Instant, + rotation_at: Instant, +} + +impl Reading { + fn new(now: Instant) -> Self { + Self { axes: Axes::default(), translation_at: now, rotation_at: now } + } + + fn current(self, now: Instant) -> Axes { + Axes { + translation: if now.duration_since(self.translation_at) < STALE_AFTER { self.axes.translation } else { [0; 3] }, + rotation: if now.duration_since(self.rotation_at) < STALE_AFTER { self.axes.rotation } else { [0; 3] }, + } + } +} + +/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own. +/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice. +fn is_3d_mouse(info: &DeviceInfo) -> bool { + matches!(info.vendor_id(), 0x046d | 0x256f) && info.usage_page() == 0x01 && info.usage() == 0x08 +} + +fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> { + let &[x0, x1, y0, y1, z0, z1] = bytes.get(..6)? else { return None }; + Some([i16::from_le_bytes([x0, x1]), i16::from_le_bytes([y0, y1]), i16::from_le_bytes([z0, z1])]) +} + +/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed +/// little-endian 16-bit values. The HID layer may pad either report to its maximum size. +fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool { + match report.first().copied() { + Some(1) => { + let Some(translation) = report.get(1..).and_then(three_axes) else { return false }; + reading.axes.translation = translation; + reading.translation_at = now; + true + } + Some(2) => { + let Some(rotation) = report.get(1..).and_then(three_axes) else { return false }; + reading.axes.rotation = rotation; + reading.rotation_at = now; + true + } + _ => false, + } +} + +fn reader(ctx: Context, shared: Arc>) { + let Ok(mut api) = HidApi::new() else { + log::warn!("3D mouse: HID initialization failed"); + return; + }; + loop { + if let Err(error) = api.refresh_devices() { + log::warn!("3D mouse: device scan failed: {error}"); + } else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) { + let mut report = [0_u8; 64]; + loop { + match device.read_timeout(&mut report, 100) { + Ok(0) => {} + Ok(size) => { + if let Ok(mut reading) = shared.lock() + && decode_report(&report[..size], &mut reading, Instant::now()) + { + ctx.request_repaint(); + } + } + Err(error) => { + log::info!("3D mouse disconnected: {error}"); + break; + } + } + } + if let Ok(mut reading) = shared.lock() { + *reading = Reading::new(Instant::now()); + } + ctx.request_repaint(); + } + std::thread::sleep(RETRY_AFTER); + } +} + +pub struct SpaceMouse { + shared: Arc>, + last_frame: Instant, +} + +impl SpaceMouse { + pub fn start(ctx: &Context) -> Self { + let now = Instant::now(); + let shared = Arc::new(Mutex::new(Reading::new(now))); + let worker_state = Arc::clone(&shared); + let worker_ctx = ctx.clone(); + if let Err(error) = std::thread::Builder::new().name("solvecraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) { + log::warn!("3D mouse: reader could not start: {error}"); + } + Self { shared, last_frame: 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); + self.last_frame = 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, axes, seconds * speed.clamp(0.1, 5.0), height, app.session.active_sketch.is_some()); + 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, axes: Axes, seconds: f64, height: f64, sketch: bool) { + if !(seconds > 0.0) || !seconds.is_finite() { + return; + } + let [x, y, z] = axes.translation.map(normalized); + let [rx, ry, rz] = axes.rotation.map(normalized); + cam.pan(x * height * 0.6 * seconds, -y * height * 0.6 * seconds, height); + cam.zoom_at((-z * 1.5 * seconds).exp(), None); + if !sketch { + cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds); + cam.roll_by(rz * 2.0 * seconds); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_separate_reports_without_stale_motion_or_padding_interference() { + let now = Instant::now(); + let mut reading = Reading::new(now); + assert!(!decode_report(&[1, 1, 2], &mut reading, now)); + assert_eq!(reading.current(now), Axes::default()); + assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now)); + assert_eq!(reading.current(now).translation, [-100, 300, 0]); + assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], &mut reading, now)); + assert_eq!(reading.current(now).rotation, [0, 1, -1]); + let later = now + STALE_AFTER + Duration::from_millis(1); + assert_eq!(reading.current(later), Axes::default()); + assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], &mut reading, later)); + assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later)); + assert_eq!(reading.current(later), 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, Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }, 0.02, 600.0, 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, Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }, 0.02, 600.0, true); + assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation); + assert!(cam.is_valid()); + } +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 9c90a2c6..cacbd175 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -12,3 +12,6 @@ workspace = true solvecraft-geom = { workspace = true } serde = { workspace = true } image = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs index 0357f988..011b02d2 100644 --- a/crates/render/src/camera.rs +++ b/crates/render/src/camera.rs @@ -224,6 +224,9 @@ pub struct Camera { pub yaw: f64, /// Elevation above the XY plane, in (−π/2, π/2]. pub pitch: f64, + /// Rotation about the viewing direction. Zero keeps the world's up axis vertical. + #[serde(default)] + pub roll: f64, /// Distance from target to eye. pub distance: f64, /// Vertical field of view (radians); 0 = orthographic. @@ -236,7 +239,7 @@ pub struct Camera { impl Default for Camera { fn default() -> Self { let (yaw, pitch) = StandardView::Iso.angles(); - Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0, up: UpAxis::Z } + Camera { target: Vec3::ZERO, yaw, pitch, roll: 0.0, distance: 200.0, fov: 0.0, up: UpAxis::Z } } } @@ -266,7 +269,8 @@ impl Camera { let b = self.back(); let right = self.up.from_frame(Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0)); let up = b.cross(right).normalized().unwrap_or(self.up.dir()); - (right, up, b) + let (s, c) = self.roll.sin_cos(); + (right * c + up * s, up * c - right * s, b) } pub fn view(&self) -> Mat4 { let (r, u, b) = self.basis(); @@ -349,11 +353,18 @@ impl Camera { self.yaw = (self.yaw + dx * 0.008) % std::f64::consts::TAU; self.pitch = (self.pitch + dy * 0.008).clamp(-lim, lim); } + /// Rotate the image about the viewing direction. + pub fn roll_by(&mut self, radians: f64) { + if radians.is_finite() { + self.roll = (self.roll + radians) % std::f64::consts::TAU; + } + } pub fn set_view(&mut self, v: StandardView) { let lim = std::f64::consts::FRAC_PI_2 - 1e-6; let (y, p) = v.angles(); self.yaw = y; self.pitch = p.clamp(-lim, lim); + self.roll = 0.0; } /// Frame a bounding box. pub fn fit(&mut self, b: &Aabb3) { @@ -376,15 +387,14 @@ impl Camera { } else { (-b.x).atan2(-b.y) }; - Camera { yaw, pitch, ..*self } + Camera { yaw, pitch, roll: 0.0, ..*self } } /// Orientation as a unit quaternion (camera → world rotation). pub fn orientation(&self) -> Quat { let (r, u, b) = self.basis(); Quat::from_basis(r, u, b) } - /// Blend between two cameras at `s` in [0, 1]: heading (the shorter way), elevation, - /// target, distance and field of view linearly. The ends are returned exactly. + /// Blend between two cameras at `s` in [0, 1]: heading and roll take the shorter way. pub fn interpolate(a: &Camera, b: &Camera, s: f64) -> Camera { if !(s > 0.0) { return *a; @@ -392,24 +402,29 @@ impl Camera { if s >= 1.0 { return *b; } - // The camera has no roll, so it turns in heading and elevation: both change smoothly - // to the very end (the heading the shorter way round). A slerp of orientations would - // need a roll-free heading at every step, and near straight up or down that heading - // swings fast, so a move to Top spun in its last frames. + // Keep heading and elevation smooth near straight up or down. A quaternion slerp + // swings the heading sharply there when moving to Top. let dy = (b.yaw - a.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; let yaw = a.yaw + dy * s; + let dr = (b.roll - a.roll + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; let pitch = a.pitch + (b.pitch - a.pitch) * s; Camera { target: a.target + (b.target - a.target) * s, yaw, pitch, + roll: a.roll + dr * s, distance: a.distance + (b.distance - a.distance) * s, fov: a.fov + (b.fov - a.fov) * s, up: b.up, } } pub fn is_valid(&self) -> bool { - self.target.is_finite() && self.yaw.is_finite() && self.pitch.is_finite() && self.distance.is_finite() && self.distance > 0.0 + self.target.is_finite() + && self.yaw.is_finite() + && self.pitch.is_finite() + && self.roll.is_finite() + && self.distance.is_finite() + && self.distance > 0.0 } } @@ -444,6 +459,26 @@ mod tests { assert!(x1 > x0, "dragging right must move the near side right: {x0} -> {x1}"); } + #[test] + fn roll_rotates_the_picture_and_standard_views_level_it() { + let mut c = Camera::default(); + c.set_view(StandardView::Front); + let (_, before_y, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap(); + c.roll_by(std::f64::consts::FRAC_PI_2); + let (after_x, after_y, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap(); + assert!((before_y - 300.0).abs() < 1e-9); + assert!((after_x - 400.0).abs() < 1e-9 && after_y > 300.0); + let (r, u, b) = c.basis(); + assert!(r.dot(u).abs() < 1e-12 && r.dot(b).abs() < 1e-12 && u.dot(b).abs() < 1e-12); + c.set_view(StandardView::Front); + assert_eq!(c.roll, 0.0); + let saved = serde_json::to_value(c).unwrap(); + let mut old = saved.as_object().unwrap().clone(); + old.remove("roll"); + let restored: Camera = serde_json::from_value(serde_json::Value::Object(old)).unwrap(); + assert_eq!(restored.roll, 0.0); + } + #[test] fn view_animation_interpolates() { let mut a = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 80.0, ..Default::default() }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb9b9e8c..722aa133 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -863,6 +863,15 @@ impl SolveApp { self.cam_anim = None; } + /// Hand the view to an external navigation device without a pending mouse orbit or spin + /// immediately taking it back on the next frame. + pub fn begin_external_navigation(&mut self) { + self.cancel_view_animation(); + self.viewport.orbit_snap = None; + self.viewport.orbit_free = None; + self.viewport.spin = false; + } + fn step_view_animation(&mut self, ctx: &egui::Context) { let Some(anim) = self.cam_anim else { return }; let (cam, done) = anim.sample(self.now); diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 5f361e79..74b70807 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -39,6 +39,10 @@ pub struct Prefs { pub zoom_reverse: bool, /// Orbit around the point under the cursor (else around the view's centre). pub orbit_cursor: bool, + /// Whether a connected 3D mouse moves the view. + pub space_mouse_enabled: bool, + /// Speed multiplier for 3D mouse navigation. + pub space_mouse_speed: f32, /// Anti-aliasing samples (applies on restart). pub msaa: u32, /// Decimals shown for lengths and angles. @@ -68,6 +72,8 @@ impl Default for Prefs { nav: "fusion".into(), zoom_reverse: false, orbit_cursor: false, + space_mouse_enabled: true, + space_mouse_speed: 1.0, msaa: 4, length_decimals: 2, angle_decimals: 1, @@ -175,6 +181,8 @@ fn defaults(app: &mut SolveApp, page: usize) { p.zoom_reverse = d.zoom_reverse; p.zoom_to_cursor = d.zoom_to_cursor; p.orbit_cursor = d.orbit_cursor; + p.space_mouse_enabled = d.space_mouse_enabled; + p.space_mouse_speed = d.space_mouse_speed; p.look_at_anim = d.look_at_anim; p.look_at_speed = d.look_at_speed; } @@ -577,6 +585,16 @@ 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("3D mouse (Windows)"); + ui.checkbox(&mut app.preferences.space_mouse_enabled, "Navigate with SpaceNavigator / SpaceMouse"); + ui.end_row(); + ui.label("3D mouse speed"); + ui.add_enabled( + app.preferences.space_mouse_enabled, + egui::Slider::new(&mut app.preferences.space_mouse_speed, 0.25..=3.0).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×")), + ); + ui.end_row(); + note(ui, "Move and rotate the view together; sketches use pan and zoom only."); } PAGE_DESIGN => { ui.label("Default modelling orientation"); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 15f9bcd6..2dd71180 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -671,7 +671,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec { let mut h = std::collections::hash_map::DefaultHasher::new(); pos.x.to_bits().hash(&mut h); pos.y.to_bits().hash(&mut h); - for v in [app.cam.yaw, app.cam.pitch, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] { + for v in [app.cam.yaw, app.cam.pitch, app.cam.roll, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] { v.to_bits().hash(&mut h); } app.session.revision.hash(&mut h); @@ -2241,7 +2241,7 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, } let mut h = std::collections::hash_map::DefaultHasher::new(); sketch_scene_key(app, proj).hash(&mut h); - for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] { + for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.roll, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] { v.to_bits().hash(&mut h); } (rect.width() as u32, rect.height() as u32).hash(&mut h);