Add raw-HID SpaceMouse 2D navigation (#650)

Squash of PR #650 by @PedroGuille.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
PedroGuille 2026-10-11 21:29:48 -03:00 committed by GitHub
parent ade5469a24
commit ac9483c9c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 681 additions and 3 deletions

34
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -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")]

View File

@ -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<String>,
detail: Option<String>,
#[cfg(any(target_os = "windows", target_os = "linux"))]
motion: motion::Motion,
#[cfg(any(target_os = "windows", target_os = "linux"))]
last_report: Option<Instant>,
buttons: u8,
recalibrate: bool,
}
pub struct SpaceMouse {
shared: Arc<Mutex<Shared>>,
stop: Arc<AtomicBool>,
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<Shared>, ctx: &egui::Context, status: Status, device: Option<String>, detail: Option<String>) {
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<Mutex<Shared>>, stop: Arc<AtomicBool>, 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<Shared>, 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(())
}

View File

@ -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<Report> {
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<Instant>,
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<Instant>; 2],
idle_since: Option<Instant>,
}
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) }
}
}

View File

@ -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),

View File

@ -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.

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -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.

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -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 Автозбереження кожні

Can't render this file because it has a wrong number of fields in line 8.

View File

@ -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::<Value>(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;
}

View File

@ -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<Resu
}
fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
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);
}

View File

@ -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<String>,
pub detail: Option<String>,
/// 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<Result<Value, String>> {
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<Value, String> {
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."));
});
}