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:
parent
ade5469a24
commit
ac9483c9c1
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue