From ac9483c9c174fc72e9542949204c42956369f6a2 Mon Sep 17 00:00:00 2001 From: PedroGuille Date: Sun, 11 Oct 2026 21:29:48 -0300 Subject: [PATCH] Add raw-HID SpaceMouse 2D navigation (#650) Squash of PR #650 by @PedroGuille. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 34 ++++ apps/cadcraft/Cargo.toml | 7 + apps/cadcraft/src/main.rs | 4 + apps/cadcraft/src/spacemouse.rs | 242 +++++++++++++++++++++++++ apps/cadcraft/src/spacemouse/motion.rs | 158 ++++++++++++++++ crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/i18n/de.tsv | 18 ++ crates/ui-egui/src/i18n/es.tsv | 18 ++ crates/ui-egui/src/i18n/uk.tsv | 18 ++ crates/ui-egui/src/lib.rs | 16 +- crates/ui-egui/src/menus.rs | 6 + crates/ui-egui/src/spacemouse.rs | 162 +++++++++++++++++ 12 files changed, 681 insertions(+), 3 deletions(-) create mode 100644 apps/cadcraft/src/spacemouse.rs create mode 100644 apps/cadcraft/src/spacemouse/motion.rs create mode 100644 crates/ui-egui/src/spacemouse.rs diff --git a/Cargo.lock b/Cargo.lock index 68b19674..3a7a4fc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -433,6 +433,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "basic-udev" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a45f9771ced8a774de5e5ebffbe520f52e3943bf5a9a6baa3a5d14a5de1afe6" + [[package]] name = "bit-set" version = "0.10.0" @@ -547,6 +553,7 @@ dependencies = [ "cadcraft-ui-egui", "eframe", "egui", + "hidapi", "log", "muda", "rfd", @@ -1744,6 +1751,21 @@ 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 = [ + "basic-udev", + "cc", + "cfg-if", + "libc", + "nix", + "pkg-config", + "windows-sys 0.61.2", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -2274,6 +2296,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index b8673533..93cbaa6b 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -35,3 +35,10 @@ sys-locale = "0.3" [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } + +# Rust HID backends: no proprietary SDK or C HID library. +[target.'cfg(target_os = "windows")'.dependencies] +hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +hidapi = { version = "2.6.7", default-features = false, features = ["linux-native-basic-udev"] } diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 00f72ce7..43f6b760 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -21,6 +21,7 @@ mod graphics; mod logging; #[cfg(target_os = "macos")] mod native_menu; +mod spacemouse; mod workspace; use cadcraft_engine::Session; @@ -28,6 +29,7 @@ use cadcraft_ui_egui::{CadApp, Services, i18n}; struct App { cad: CadApp, + spacemouse: spacemouse::SpaceMouse, /// The desktop's light/dark choice where winit reports none (Linux, the BSDs). desktop_theme: desktop_theme::DesktopTheme, workspace: workspace::Workspace, @@ -71,6 +73,7 @@ impl eframe::App for App { } } self.cad.logic(ctx); + self.spacemouse.poll(&mut self.cad, ctx); self.persist_workspace(false); if std::mem::take(&mut self.cad.quit_requested) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -327,6 +330,7 @@ fn main() -> eframe::Result { Ok(Box::new(App { desktop_theme, cad: app, + spacemouse: spacemouse::SpaceMouse::start(cc.egui_ctx.clone()), workspace, workspace_error, #[cfg(target_os = "macos")] diff --git a/apps/cadcraft/src/spacemouse.rs b/apps/cadcraft/src/spacemouse.rs new file mode 100644 index 00000000..51352962 --- /dev/null +++ b/apps/cadcraft/src/spacemouse.rs @@ -0,0 +1,242 @@ +//! Native 3Dconnexion input. The reader publishes one latest sample, never an input queue; +//! only movement starting/stopping or status/buttons wake egui. Active motion runs at 60 Hz. + +#[cfg(any(target_os = "windows", target_os = "linux"))] +mod motion; + +use cadcraft_ui_egui::{CadApp, spacemouse::Status}; +#[cfg(any(target_os = "windows", target_os = "linux"))] +use std::time::Duration; +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Instant, +}; + +#[derive(Default)] +struct Shared { + status: Status, + device: Option, + detail: Option, + #[cfg(any(target_os = "windows", target_os = "linux"))] + motion: motion::Motion, + #[cfg(any(target_os = "windows", target_os = "linux"))] + last_report: Option, + buttons: u8, + recalibrate: bool, +} + +pub struct SpaceMouse { + shared: Arc>, + stop: Arc, + last_frame: Instant, + #[cfg(any(target_os = "windows", target_os = "linux"))] + moving: bool, +} + +impl SpaceMouse { + pub fn start(ctx: egui::Context) -> Self { + let shared = Arc::new(Mutex::new(Shared::default())); + let stop = Arc::new(AtomicBool::new(false)); + #[cfg(any(target_os = "windows", target_os = "linux"))] + { + let (input, quit, context) = (Arc::clone(&shared), Arc::clone(&stop), ctx.clone()); + if let Err(error) = std::thread::Builder::new().name("spacemouse-hid".into()).spawn(move || read(input, quit, context)) { + set_status(&shared, &ctx, Status::Error, None, Some(error.to_string())); + } + } + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + let _ = ctx; + Self { + shared, + stop, + last_frame: Instant::now(), + #[cfg(any(target_os = "windows", target_os = "linux"))] + moving: false, + } + } + + pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) { + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.last_frame).as_secs_f64().min(0.05); + self.last_frame = now; + let Ok(mut shared) = self.shared.lock() else { return }; + if std::mem::take(&mut app.spacemouse.calibrate_requested) { + shared.recalibrate = true; + } + let changed = app.spacemouse.status != shared.status || app.spacemouse.device != shared.device || app.spacemouse.detail != shared.detail; + app.spacemouse.status = shared.status; + app.spacemouse.device.clone_from(&shared.device); + app.spacemouse.detail.clone_from(&shared.detail); + if changed && !matches!(shared.status, Status::Unavailable | Status::Searching) { + app.set_status(cadcraft_ui_egui::i18n::tr(app.language(), shared.status.message())); + } + let buttons = std::mem::take(&mut shared.buttons); + #[cfg(any(target_os = "windows", target_os = "linux"))] + let motion = if shared.last_report.is_some_and(|at| now.saturating_duration_since(at) < Duration::from_millis(250)) { + shared.motion + } else { + motion::Motion::default() + }; + drop(shared); + let allowed = app.spacemouse.settings.enabled + && ctx.input(|i| i.focused) + && !app.ui.start_tab + && app.ui.dialog.is_none() + && app.closing.is_none() + && app.canvas.rect.is_some() + && !egui::Popup::is_any_open(ctx); + if allowed && buttons & 1 != 0 { + let _ = app.run("zoom", serde_json::json!({"mode": "extents"})); + } + #[cfg(any(target_os = "windows", target_os = "linux"))] + { + let moving = allowed && motion.active(); + if moving { + if self.moving { + let settings = app.spacemouse.settings; + let rate = if settings.reverse_zoom { -settings.zoom_speed } else { settings.zoom_speed }; + if let Err(error) = app.run( + "ui.spacemouse.navigate", + serde_json::json!({ + "pan": motion.pan.map(|v| v * settings.pan_speed * elapsed), + "zoom": (motion.zoom * rate * elapsed).exp() + }), + ) { + app.set_status(error); + } + } + ctx.request_repaint_after(Duration::from_millis(16)); + } + self.moving = moving; + } + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + let _ = elapsed; + } +} + +impl Drop for SpaceMouse { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } +} + +#[cfg(any(target_os = "windows", target_os = "linux"))] +fn set_status(shared: &Mutex, ctx: &egui::Context, status: Status, device: Option, detail: Option) { + let Ok(mut state) = shared.lock() else { return }; + let changed = state.status != status || state.device != device || state.detail != detail; + if !changed { + return; + } + if let Some(error) = &detail { + log::warn!("3D mouse: {error}"); + } + state.status = status; + state.device = device; + state.detail = detail; + state.motion = motion::Motion::default(); + state.last_report = None; + state.buttons = 0; + drop(state); + ctx.request_repaint(); +} + +#[cfg(any(target_os = "windows", target_os = "linux"))] +fn retry(stop: &AtomicBool) { + for _ in 0..40 { + if stop.load(Ordering::Relaxed) { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +#[cfg(any(target_os = "windows", target_os = "linux"))] +fn read(shared: Arc>, stop: Arc, ctx: egui::Context) { + while !stop.load(Ordering::Relaxed) { + let mut api = match hidapi::HidApi::new() { + Ok(api) => api, + Err(error) => { + set_status(&shared, &ctx, Status::Error, None, Some(error.to_string())); + retry(&stop); + continue; + } + }; + while !stop.load(Ordering::Relaxed) { + if let Err(error) = api.refresh_devices() { + set_status(&shared, &ctx, Status::Error, None, Some(error.to_string())); + break; + } + let device = + api.device_list().find(|device| matches!(device.vendor_id(), 0x046d | 0x256f) && device.usage_page() == 1 && device.usage() == 8); + let Some(info) = device else { + set_status(&shared, &ctx, Status::Searching, None, None); + retry(&stop); + continue; + }; + let name = info.product_string().unwrap_or("3Dconnexion SpaceMouse").to_string(); + match info.open_device(&api) { + Ok(device) => { + set_status(&shared, &ctx, Status::Calibrating, Some(name.clone()), None); + if let Err(error) = read_device(&device, &shared, &stop, &ctx, &name) { + set_status(&shared, &ctx, Status::Disconnected, Some(name), Some(error.to_string())); + } + } + Err(error) => set_status(&shared, &ctx, Status::OpenFailed, Some(name), Some(error.to_string())), + } + retry(&stop); + } + retry(&stop); + } +} + +#[cfg(any(target_os = "windows", target_os = "linux"))] +fn read_device(device: &hidapi::HidDevice, shared: &Mutex, stop: &AtomicBool, ctx: &egui::Context, name: &str) -> hidapi::HidResult<()> { + let mut filter = motion::Filter::default(); + let mut buffer = [0; 64]; + let mut buttons = 0; + while !stop.load(Ordering::Relaxed) { + let recalibrate = if let Ok(mut state) = shared.lock() { std::mem::take(&mut state.recalibrate) } else { return Ok(()) }; + if recalibrate { + filter.recalibrate(); + set_status(shared, ctx, Status::Calibrating, Some(name.into()), None); + } + let len = device.read_timeout(&mut buffer, 50)?; + let now = Instant::now(); + let report = buffer.get(..len).and_then(motion::decode); + let Ok(mut state) = shared.lock() else { return Ok(()) }; + let mut wake = false; + match report { + Some(motion::Report::Buttons(next)) => { + let pressed = next & !buttons; + buttons = next; + state.buttons |= pressed & 1; + state.recalibrate |= pressed & 2 != 0; + wake = pressed != 0; + } + Some(motion::Report::Axes { values, mask }) => { + let motion = filter.update(values, mask, now); + wake = state.motion.active() != motion.active(); + state.motion = motion; + state.last_report = Some(now); + if filter.calibrated() && state.status != Status::Connected { + state.status = Status::Connected; + wake = true; + } + } + None => { + if state.motion.active() && state.last_report.is_some_and(|at| now.saturating_duration_since(at) >= Duration::from_millis(250)) { + state.motion = motion::Motion::default(); + wake = true; + } + } + } + drop(state); + if wake { + ctx.request_repaint(); + } + } + Ok(()) +} diff --git a/apps/cadcraft/src/spacemouse/motion.rs b/apps/cadcraft/src/spacemouse/motion.rs new file mode 100644 index 00000000..1ef6d195 --- /dev/null +++ b/apps/cadcraft/src/spacemouse/motion.rs @@ -0,0 +1,158 @@ +//! Bounded, dependency-free decoding and rest calibration of 3Dconnexion HID input reports. + +use std::time::{Duration, Instant}; + +const FULL_SCALE: f64 = 500.0; +const DEADZONE: f64 = 0.04; +const REST_LIMIT: f64 = 60.0; +const REST_RANGE: f64 = 10.0; +const CALIBRATION_TIME: Duration = Duration::from_millis(400); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Report { + /// Mask 1 = translation, 2 = rotation, 3 = a combined six-axis report. + Axes { + values: [i16; 6], + mask: u8, + }, + Buttons(u8), +} + +/// hidapi includes the report ID. A legacy motion report has six payload bytes; combined +/// report 1 has twelve. Windows may pad the short button report to the input-report size. +pub fn decode(bytes: &[u8]) -> Option { + let (&id, payload) = bytes.split_first()?; + if id == 3 { + return payload.first().copied().map(Report::Buttons); + } + let (mask, offset, count) = match id { + 1 if payload.len() >= 12 => (3, 0, 6), + 1 if payload.len() >= 6 => (1, 0, 3), + 2 if payload.len() >= 6 => (2, 3, 3), + _ => return None, + }; + let mut values = [0; 6]; + for (out, pair) in values.iter_mut().skip(offset).take(count).zip(payload.as_chunks::<2>().0.iter()) { + *out = i16::from_le_bytes([*pair.first()?, *pair.get(1)?]); + } + Some(Report::Axes { values, mask }) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Motion { + pub pan: [f64; 2], + pub zoom: f64, +} + +impl Motion { + pub fn active(self) -> bool { + self.pan.iter().any(|v| *v != 0.0) || self.zoom != 0.0 + } +} + +#[derive(Default)] +struct Rest { + since: Option, + count: u32, + sum: [f64; 6], + min: [f64; 6], + max: [f64; 6], +} + +impl Rest { + fn observe(&mut self, raw: [f64; 6], now: Instant) -> Option<[f64; 6]> { + if raw.iter().any(|v| v.abs() > REST_LIMIT) { + *self = Self::default(); + return None; + } + if self.since.is_none() || raw.iter().zip(self.min.iter().zip(&self.max)).any(|(v, (min, max))| v.max(*max) - v.min(*min) > REST_RANGE) { + *self = Self { since: Some(now), count: 1, sum: raw, min: raw, max: raw }; + return None; + } + for ((min, max), value) in self.min.iter_mut().zip(&mut self.max).zip(raw) { + *min = min.min(value); + *max = max.max(value); + } + // A fixed bound even if a device sends far more than the expected 125 reports/sec. + if self.count < 128 { + self.count += 1; + for (sum, value) in self.sum.iter_mut().zip(raw) { + *sum += value; + } + } + if self.count >= 16 && self.since.is_some_and(|since| now.saturating_duration_since(since) >= CALIBRATION_TIME) { + Some(self.sum.map(|sum| sum / f64::from(self.count))) + } else { + None + } + } +} + +#[derive(Default)] +pub struct Filter { + raw: [f64; 6], + bias: Option<[f64; 6]>, + rest: Rest, + parts: u8, + seen: [Option; 2], + idle_since: Option, +} + +impl Filter { + pub fn calibrated(&self) -> bool { + self.bias.is_some() + } + + pub fn recalibrate(&mut self) { + *self = Self::default(); + } + + pub fn update(&mut self, values: [i16; 6], mask: u8, now: Instant) -> Motion { + for (index, seen) in self.seen.iter_mut().enumerate() { + if mask & (1 << index) != 0 { + *seen = Some(now); + } + } + for (index, (raw, value)) in self.raw.iter_mut().zip(values).enumerate() { + if mask & if index < 3 { 1 } else { 2 } != 0 { + *raw = f64::from(value); + } + } + self.parts |= mask & 3; + let complete = self.parts == 3; + if complete { + self.parts = 0; + } + if self.bias.is_none() && complete { + self.bias = self.rest.observe(self.raw, now); + } + let Some(mut bias) = self.bias else { return Motion::default() }; + // Follow small resting drift only after two seconds entirely inside the deadzone. + if complete { + if self.raw.iter().zip(bias).all(|(raw, bias)| (*raw - bias).abs() <= FULL_SCALE * DEADZONE) { + let since = *self.idle_since.get_or_insert(now); + if now.saturating_duration_since(since) >= Duration::from_secs(2) { + for (bias, raw) in bias.iter_mut().zip(self.raw) { + *bias += (raw - *bias) * 0.01; + } + self.bias = Some(bias); + } + } else { + self.idle_since = None; + } + } + let mut axes = [0.0; 6]; + for ((axis, raw), bias) in axes.iter_mut().zip(self.raw).zip(bias) { + let value = ((raw - bias) / FULL_SCALE).clamp(-1.0, 1.0); + *axis = if value.abs() <= DEADZONE { 0.0 } else { value.signum() * (value.abs() - DEADZONE) / (1.0 - DEADZONE) }; + } + // A missing rotation report must not keep zooming while translations still arrive. + for (group, seen) in axes.as_chunks_mut::<3>().0.iter_mut().zip(self.seen) { + if seen.is_none_or(|at| now.saturating_duration_since(at) >= Duration::from_millis(250)) { + group.fill(0.0); + } + } + let [tx, ty, tz, _rx, _ry, rz] = axes; + Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) } + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f464ea71..2ec76d65 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -14,6 +14,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { let Some(d) = app.ui.dialog.clone() else { return }; let mut open = true; match d.as_str() { + "spacemouse" => crate::spacemouse::dialog(app, ctx, &mut open), "language" => language(app, ctx, &mut open), "layers" => crate::layers::dialog(app, ctx, &mut open), "qselect" => crate::quick::qselect_dialog(app, ctx, &mut open), diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fb193f23..a940c4ab 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -994,3 +994,21 @@ Hide Others Andere ausblenden Accessibility Bedienungshilfen Show System Cursor Systemzeiger anzeigen + 3D Mouse... 3D-Maus... + 3D Mouse 3D-Maus + Calibrate 3D Mouse 3D-Maus kalibrieren + 3D Mouse Navigation Navigation mit der 3D-Maus + 3D mouse input is unavailable on this platform 3D-Mauseingabe ist auf dieser Plattform nicht verfügbar + No supported 3D mouse found Keine unterstützte 3D-Maus gefunden + Release the 3D mouse to calibrate its resting position 3D-Maus loslassen, um ihre Ruheposition zu kalibrieren + 3D mouse connected 3D-Maus verbunden + 3D mouse busy, possibly 3DxWare or missing device permissions 3D-Maus belegt, möglicherweise durch 3DxWare oder fehlende Geräteberechtigungen + 3D mouse disconnected 3D-Maus getrennt + 3D mouse input could not be started 3D-Mauseingabe konnte nicht gestartet werden + Enable 3D mouse navigation Navigation mit der 3D-Maus aktivieren + Pan sensitivity Empfindlichkeit beim Verschieben + Zoom sensitivity Zoom-Empfindlichkeit + Reverse zoom direction Zoomrichtung umkehren + Calibrate Kalibrieren + Move sideways to pan; push/pull or twist to zoom. Seitlich bewegen zum Verschieben; drücken, ziehen oder drehen zum Zoomen. + Left device button: zoom extents. Right device button: calibrate. Linke Gerätetaste: auf Grenzen zoomen. Rechte Gerätetaste: kalibrieren. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b53fe85c..4f194c64 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -994,3 +994,21 @@ Hide Others Ocultar otros Accessibility Accesibilidad Show System Cursor Mostrar cursor del sistema + 3D Mouse... Ratón 3D... + 3D Mouse Ratón 3D + Calibrate 3D Mouse Calibrar ratón 3D + 3D Mouse Navigation Navegación con ratón 3D + 3D mouse input is unavailable on this platform El ratón 3D no está disponible en esta plataforma + No supported 3D mouse found No se encontró un ratón 3D compatible + Release the 3D mouse to calibrate its resting position Suelta el ratón 3D para calibrar su posición de reposo + 3D mouse connected Ratón 3D conectado + 3D mouse busy, possibly 3DxWare or missing device permissions Ratón 3D ocupado, posiblemente por 3DxWare o por falta de permisos del dispositivo + 3D mouse disconnected Ratón 3D desconectado + 3D mouse input could not be started No se pudo iniciar el ratón 3D + Enable 3D mouse navigation Activar navegación con ratón 3D + Pan sensitivity Sensibilidad de desplazamiento + Zoom sensitivity Sensibilidad de zoom + Reverse zoom direction Invertir dirección del zoom + Calibrate Calibrar + Move sideways to pan; push/pull or twist to zoom. Mueve el mando lateralmente para desplazar; empuja, tira o gira para hacer zoom. + Left device button: zoom extents. Right device button: calibrate. Botón izquierdo del dispositivo: zoom a la extensión. Botón derecho: calibrar. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 155e646b..00bbc70b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1203,6 +1203,24 @@ Multiline... Мультилінія... Multiline Style... Стиль мультилінії... Solid Editing Редагування тіл + 3D Mouse... 3D-миша... + 3D Mouse 3D-миша + Calibrate 3D Mouse Калібрувати 3D-мишу + 3D Mouse Navigation Навігація за допомогою 3D-миші + 3D mouse input is unavailable on this platform Введення з 3D-миші недоступне на цій платформі + No supported 3D mouse found Сумісну 3D-мишу не знайдено + Release the 3D mouse to calibrate its resting position Відпустіть 3D-мишу, щоб відкалібрувати її положення спокою + 3D mouse connected 3D-мишу підключено + 3D mouse busy, possibly 3DxWare or missing device permissions 3D-миша зайнята, можливо, через 3DxWare або недостатні права доступу до пристрою + 3D mouse disconnected 3D-мишу відключено + 3D mouse input could not be started Не вдалося запустити введення з 3D-миші + Enable 3D mouse navigation Увімкнути навігацію 3D-мишею + Pan sensitivity Чутливість панорамування + Zoom sensitivity Чутливість масштабування + Reverse zoom direction Змінити напрямок масштабування + Calibrate Калібрувати + Move sideways to pan; push/pull or twist to zoom. Рухайте вбік для панорамування; натискайте, тягніть або обертайте для масштабування. + Left device button: zoom extents. Right device button: calibrate. Ліва кнопка пристрою: масштабування до меж. Права кнопка: калібрування. More toolbar commands Інші команди панелі інструментів Default save version Типова версія збереження Automatic save every Автозбереження кожні diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 925457c3..9a2b9e3e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod control; pub mod credits; pub mod dialogs; pub mod docking; +pub mod spacemouse; // The External References palette. pub mod xrefs; @@ -181,6 +182,8 @@ pub struct CadApp { pub options: options::UiOptions, pub services: Services, pub canvas: canvas::CanvasState, + /// Desktop HID status and navigation preferences; the web host supplies no HID backend. + pub spacemouse: spacemouse::State, pub cmd: cmdline::CmdLine, pub status: Option<(String, f64)>, pub integrated_titlebar: bool, @@ -219,6 +222,7 @@ impl CadApp { options: options::UiOptions::default(), services, canvas: canvas::CanvasState::default(), + spacemouse: spacemouse::State::default(), cmd: cmdline::CmdLine::default(), status: None, integrated_titlebar: false, @@ -308,18 +312,24 @@ impl CadApp { /// Preferences kept across restarts, as JSON for the host's storage ([`PREFS_KEY`]). pub fn prefs_json(&self) -> String { - json!({ + let mut prefs = json!({ "theme": self.ui.theme.as_str(), "interfaceLanguage": self.ui.interface_language.code(), "saveFormat": self.ui.save_format.as_str(), "saveVersion": self.ui.save_version, - }) - .to_string() + }); + if self.spacemouse.settings != spacemouse::Settings::default() { + prefs["spacemouse"] = json!(self.spacemouse.settings); + } + prefs.to_string() } /// Restore preferences saved by [`Self::prefs_json`]; unknown or malformed values are ignored. pub fn load_prefs(&mut self, json: &str) { let Ok(v) = serde_json::from_str::(json) else { return }; + if let Some(prefs) = v.get("spacemouse") { + let _ = self.spacemouse.load_settings(prefs); + } if let Some(t) = v.get("theme").and_then(Value::as_str).and_then(theme::ThemePref::parse) { self.ui.theme = t; } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 55ceb396..59c377a4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -10,6 +10,9 @@ pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools" /// UI-only commands: (id, label, menu path, shortcut). pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ + ("ui.spacemouse", "3D Mouse...", &["Tools", "3D Mouse..."], None), + ("ui.spacemouse.calibrate", "Calibrate 3D Mouse", &[], None), + ("ui.spacemouse.navigate", "3D Mouse Navigation", &[], None), ("ui.dock", "Arrange Panel", &[], None), ("ui.dock.reset", "Reset Panel Layout", &["Window", "Reset Panel Layout"], None), ("ui.open", "Open...", &[], Some("Cmd+O")), @@ -71,6 +74,9 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option Option> { + if let Some(result) = crate::spacemouse::route(app, id, params) { + return Some(result); + } if let Some(r) = crate::xrefs::route(app, id, params) { return Some(r); } diff --git a/crates/ui-egui/src/spacemouse.rs b/crates/ui-egui/src/spacemouse.rs new file mode 100644 index 00000000..8f2c90e1 --- /dev/null +++ b/crates/ui-egui/src/spacemouse.rs @@ -0,0 +1,162 @@ +//! Host-independent 3D mouse preferences and navigation. Device I/O lives in the desktop app. + +use cadcraft_engine::View; +use cadcraft_geom::Vec2; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::CadApp; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Settings { + pub enabled: bool, + /// Visible extents per second at full deflection. + pub pan_speed: f64, + /// Exponential zoom rate per second at full deflection. + pub zoom_speed: f64, + pub reverse_zoom: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { enabled: true, pan_speed: 0.8, zoom_speed: 1.6, reverse_zoom: false } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Status { + #[default] + Unavailable, + Searching, + Calibrating, + Connected, + OpenFailed, + Disconnected, + Error, +} + +impl Status { + pub fn message(self) -> &'static str { + match self { + Self::Unavailable => "3D mouse input is unavailable on this platform", + Self::Searching => "No supported 3D mouse found", + Self::Calibrating => "Release the 3D mouse to calibrate its resting position", + Self::Connected => "3D mouse connected", + Self::OpenFailed => "3D mouse busy, possibly 3DxWare or missing device permissions", + Self::Disconnected => "3D mouse disconnected", + Self::Error => "3D mouse input could not be started", + } + } +} + +#[derive(Default)] +pub struct State { + pub settings: Settings, + pub status: Status, + pub device: Option, + pub detail: Option, + /// The native host consumes this request; no device I/O runs on the UI thread. + pub calibrate_requested: bool, +} + +impl State { + pub fn load_settings(&mut self, value: &Value) -> Result<(), String> { + let settings: Settings = serde_json::from_value(value.clone()).map_err(|e| e.to_string())?; + if !settings.pan_speed.is_finite() + || !(0.1..=3.0).contains(&settings.pan_speed) + || !settings.zoom_speed.is_finite() + || !(0.1..=3.0).contains(&settings.zoom_speed) + { + return Err("3D mouse sensitivity must be between 0.1 and 3.0".into()); + } + self.settings = settings; + Ok(()) + } + + fn summary(&self) -> Value { + json!({"settings": self.settings, "status": self.status, "device": self.device, "detail": self.detail}) + } +} + +pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option> { + match id { + "ui.spacemouse" => { + if params.is_null() { + app.ui.dialog = Some("spacemouse".into()); + } else if let Some(settings) = params.get("settings") + && let Err(error) = app.spacemouse.load_settings(settings) + { + return Some(Err(error)); + } + Some(Ok(app.spacemouse.summary())) + } + "ui.spacemouse.calibrate" => { + if !matches!(app.spacemouse.status, Status::Connected | Status::Calibrating) { + return Some(Err("Connect a 3D mouse before calibrating".into())); + } + app.spacemouse.calibrate_requested = true; + Some(Ok(Value::Null)) + } + "ui.spacemouse.navigate" => Some(navigate(app, params)), + _ => None, + } +} + +/// One frame of fractional pan and magnification, shared by device input and the control API. +/// Quiet view updates keep model/paper/tiled/floating viewport handling in the engine and do +/// not flood ZOOM Previous's history with one entry per frame. +fn navigate(app: &mut CadApp, params: &Value) -> Result { + let pan = params.get("pan").and_then(Value::as_array).filter(|p| p.len() == 2); + let x = pan.and_then(|p| p.first()).and_then(Value::as_f64); + let y = pan.and_then(|p| p.get(1)).and_then(Value::as_f64); + let zoom = params.get("zoom").and_then(Value::as_f64); + let (Some(x), Some(y), Some(zoom)) = (x, y, zoom) else { return Err("Expected {pan: [x, y], zoom: factor}".into()) }; + if !x.is_finite() || !y.is_finite() || x.abs() > 0.25 || y.abs() > 0.25 || !zoom.is_finite() || !(0.5..=2.0).contains(&zoom) { + return Err("3D mouse navigation frame is outside its limits".into()); + } + if !app.spacemouse.settings.enabled { + return Ok(Value::Null); + } + let (width, height) = app.session.viewport_px; + let aspect = width / height.max(1.0); + let state = app.session.state_mut().map_err(|e| e.to_string())?; + let view = state.view(); + let center = view.center - Vec2::new(x * view.height * aspect, y * view.height); + let height = (view.height / zoom).clamp(1e-9, 1e12); + if !center.is_finite() || !height.is_finite() { + return Err("3D mouse navigation would produce an invalid view".into()); + } + state.set_view_quiet(View { center, height }); + Ok(json!({"center": [center.x, center.y], "height": height})) +} + +pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + egui::Window::new(crate::tl!("3D Mouse")).id(egui::Id::new("spacemouse")).open(open).resizable(false).show(ctx, |ui| { + ui.label(crate::i18n::t(app.spacemouse.status.message())); + if let Some(device) = &app.spacemouse.device { + ui.label(device); + } + if let Some(detail) = &app.spacemouse.detail { + ui.label(detail); + } + ui.separator(); + let mut settings = app.spacemouse.settings; + let mut changed = ui.checkbox(&mut settings.enabled, crate::tl!("Enable 3D mouse navigation")).changed(); + changed |= ui.add(egui::Slider::new(&mut settings.pan_speed, 0.1..=3.0).text(crate::tl!("Pan sensitivity"))).changed(); + changed |= ui.add(egui::Slider::new(&mut settings.zoom_speed, 0.1..=3.0).text(crate::tl!("Zoom sensitivity"))).changed(); + changed |= ui.checkbox(&mut settings.reverse_zoom, crate::tl!("Reverse zoom direction")).changed(); + if changed { + let _ = app.run("ui.spacemouse", json!({"settings": settings})); + } + if ui + .add_enabled(matches!(app.spacemouse.status, Status::Connected | Status::Calibrating), egui::Button::new(crate::tl!("Calibrate"))) + .clicked() + { + let _ = app.run("ui.spacemouse.calibrate", Value::Null); + } + ui.label(crate::tl!("Move sideways to pan; push/pull or twist to zoom.")); + ui.label(crate::tl!("Left device button: zoom extents. Right device button: calibrate.")); + }); +}