diff --git a/README.md b/README.md index 8071a783..bc17fc47 100644 --- a/README.md +++ b/README.md @@ -221,8 +221,12 @@ With 3DxWare on Windows, SolidCraft follows 3DxWare's profile for it when there 3DxWare's object mode (tilt to roll, twist to spin) until there is. The mouse-wheel turns that 3DxWare makes from the cap's motion are ignored, so the view doesn't zoom in steps as the cap tilts. -On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the -device. A udev rule that adds the `uaccess` tag does that, for example +On Linux SolidCraft reads the 3D mouse through `spacenavd` when that daemon runs (its socket +`/var/run/spnav.sock`), so what you set in `spnavcfg` (swapped or inverted axes, sensitivity, dead +zones) applies as it does in other programs, and no udev rule is needed. With nothing set there, +the motion is the same as without the daemon. Without `spacenavd`, SolidCraft reads `/dev/hidrawN` +directly, so the logged-in user needs access to the device. A udev rule that adds the `uaccess` +tag does that, for example `/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id: ``` diff --git a/apps/solidcraft/src/space_mouse.rs b/apps/solidcraft/src/space_mouse.rs index 79f08ccd..d261fd0e 100644 --- a/apps/solidcraft/src/space_mouse.rs +++ b/apps/solidcraft/src/space_mouse.rs @@ -1,10 +1,12 @@ -//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through hidraw). The device -//! reader never touches the document or UI; it publishes the latest axes, and the egui thread -//! applies them once per frame. +//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through spacenavd when it +//! runs, else hidraw). The device reader never touches the document or UI; it publishes the +//! latest axes, and the egui thread applies them once per frame. mod hid_descriptor; #[cfg(target_os = "linux")] mod hidraw; +#[cfg(target_os = "linux")] +mod spacenavd; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -156,14 +158,21 @@ struct Reading { axes: Axes, translation_at: Instant, rotation_at: Instant, + /// The source says when the cap is back at rest, so a reading stays until the next one. + /// A HID device repeats its report while the cap is held; spacenavd sends a record only when + /// the values change, and a steady hold can go seconds without one. + held: bool, } impl Reading { fn new(now: Instant) -> Self { - Self { axes: Axes::default(), translation_at: now, rotation_at: now } + Self { axes: Axes::default(), translation_at: now, rotation_at: now, held: false } } fn current(self, now: Instant) -> Axes { + if self.held { + return self.axes; + } Axes { translation: if now.duration_since(self.translation_at) < STALE_AFTER { self.axes.translation } else { [0; 3] }, rotation: if now.duration_since(self.rotation_at) < STALE_AFTER { self.axes.rotation } else { [0; 3] }, @@ -226,6 +235,15 @@ fn publish(ctx: &Context, shared: &Mutex, layout: Layout, report: &[u8] } } +/// Publishes the cap's position from spacenavd. +#[cfg(target_os = "linux")] +fn publish_held(ctx: &Context, shared: &Mutex, axes: Axes) { + if let Ok(mut reading) = shared.lock() { + *reading = Reading { axes, held: true, ..Reading::new(Instant::now()) }; + ctx.request_repaint(); + } +} + /// The device is gone: stop reporting its last position. fn reset(ctx: &Context, shared: &Mutex) { if let Ok(mut reading) = shared.lock() { @@ -276,15 +294,21 @@ fn device_layout(device: &hidapi::HidDevice) -> Layout { } } -/// Reads `/dev/hidrawN` with plain blocking reads, which end with an error when the device is -/// unplugged. Opening needs permission: most distributions grant it to the logged-in user for -/// devices that carry the `uaccess` udev tag, which a rule for the 3D mouse has to add. +/// Reads the 3D mouse through spacenavd when it runs, so what `spnavcfg` sets (swapped and inverted +/// axes, sensitivity, dead zones) applies here as in other programs. Without the daemon it reads +/// `/dev/hidrawN` with plain blocking reads, which end with an error when the device is unplugged. +/// Opening needs permission: most distributions grant it to the logged-in user for devices that +/// carry the `uaccess` udev tag, which a rule for the 3D mouse has to add. #[cfg(target_os = "linux")] fn reader(ctx: Context, shared: Arc>) { use std::io::Read; let mut denied = None; loop { - if let Some(device) = hidraw::find() { + if let Some(daemon) = spacenavd::connect() { + log::info!("3D mouse: reading spacenavd, which applies the spnavcfg settings"); + spacenavd::read(daemon, |axes| publish_held(&ctx, &shared, axes)); + reset(&ctx, &shared); + } else if let Some(device) = hidraw::find() { match std::fs::File::open(&device.path) { Ok(mut file) => { denied = None; @@ -410,6 +434,19 @@ mod tests { assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] }); } + #[test] + fn a_held_reading_stays_until_the_next_one_and_a_hid_reading_goes_stale() { + let now = Instant::now(); + let axes = Axes { translation: [0, 200, 0], rotation: [0; 3] }; + let later = now + STALE_AFTER * 20; + // spacenavd sends nothing while the values stay the same: the push lasts. + let held = Reading { axes, held: true, ..Reading::new(now) }; + assert_eq!(held.current(later), axes); + // A HID device repeats its report: silence means the cap, or the device, is gone. + let hid = Reading { axes, ..Reading::new(now) }; + assert_eq!(hid.current(later), Axes::default()); + } + #[test] fn combined_report_one_carries_translation_and_rotation_together() { let now = Instant::now(); diff --git a/apps/solidcraft/src/space_mouse/spacenavd.rs b/apps/solidcraft/src/space_mouse/spacenavd.rs new file mode 100644 index 00000000..4e282764 --- /dev/null +++ b/apps/solidcraft/src/space_mouse/spacenavd.rs @@ -0,0 +1,144 @@ +//! Reading a 3D mouse through `spacenavd` on Linux. The daemon owns the device and applies what +//! `spnavcfg` sets (swapped and inverted axes, sensitivity, dead zones) before it hands the motion +//! to its clients, so a program that reads the HID device itself bypasses that setup. Over its +//! socket the daemon sends fixed 32-byte records: eight native-endian `i32`, the first the kind of +//! record (0 for motion, then x y z rx ry rz and the milliseconds since the last record). +//! Other records (button presses, from newer daemons also configuration changes) are ignored. +//! +//! The socket is plain file I/O, so no library is needed, and it needs no udev rule: the daemon +//! is what has access to the device. + +use std::io::Read; +use std::os::unix::net::UnixStream; +use std::path::Path; + +use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE}; + +/// Where the daemon listens. `/var/run` is a link to `/run` on current systems. +const SOCKETS: [&str; 2] = ["/var/run/spnav.sock", "/run/spnav.sock"]; + +const RECORD_BYTES: usize = 32; + +/// What the daemon reports at full deflection (seen with a SpaceMouse Compact and a default +/// sensitivity). Our axes are in the raw HID units of [`FULL_SCALE`]. +const DAEMON_FULL_SCALE: i64 = 500; + +pub fn connect() -> Option { + SOCKETS.iter().find_map(|path| connect_to(Path::new(path))) +} + +fn connect_to(path: &Path) -> Option { + UnixStream::connect(path).ok() +} + +/// The axes of one record, or `None` for a record that is not motion. +/// +/// The daemon's frame is right-handed with x to the right, y away from the user and z up; ours is +/// the device's raw one, x right, y towards the user and z down, so y and z (and the rotations +/// about them) change sign. With nothing set in `spnavcfg` the two give the same motion; what +/// the user swaps or inverts there then carries over. +fn decode(record: &[u8; RECORD_BYTES]) -> Option { + let mut data = [0_i32; 8]; + for (slot, bytes) in data.iter_mut().zip(record.as_chunks::<4>().0) { + *slot = i32::from_ne_bytes(*bytes); + } + let [kind, x, y, z, rx, ry, rz, _period] = data; + (kind == 0).then(|| Axes { + translation: [unit(x), unit(-i64::from(y)), unit(-i64::from(z))], + rotation: [unit(rx), unit(-i64::from(ry)), unit(-i64::from(rz))], + }) +} + +/// A daemon value in our raw units. Out-of-range values stay in `i16`: they are input from another +/// process. +fn unit(value: impl Into) -> i16 { + let scaled = value.into() * i64::from(FULL_SCALE) / DAEMON_FULL_SCALE; + i16::try_from(scaled.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default() +} + +/// Hands every motion record to `publish` until the daemon closes the connection or fails. A +/// partial record at the end is dropped. +pub fn read(mut stream: impl Read, mut publish: impl FnMut(Axes)) { + let mut record = [0_u8; RECORD_BYTES]; + loop { + match stream.read_exact(&mut record) { + Ok(()) => { + if let Some(axes) = decode(&record) { + publish(axes); + } + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => { + log::info!("3D mouse: spacenavd connection ended: {error}"); + return; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn record(data: [i32; 8]) -> [u8; RECORD_BYTES] { + let mut bytes = [0_u8; RECORD_BYTES]; + for (chunk, value) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(data) { + *chunk = value.to_ne_bytes(); + } + bytes + } + + #[test] + fn motion_records_become_axes_in_the_devices_raw_frame() { + // Full deflection of the daemon's x, y and z is the device's full scale, and y and z flip. + assert_eq!(decode(&record([0, 500, 500, 500, 0, 0, 0, 8])), Some(Axes { translation: [350, -350, -350], rotation: [0; 3] })); + // Rotations follow translations: right-handed in both frames, so ry and rz flip as y and z do. + assert_eq!(decode(&record([0, 0, 0, 0, 250, 250, 250, 8])), Some(Axes { translation: [0; 3], rotation: [175, -175, -175] })); + // Rest is a motion record of zeros. + assert_eq!(decode(&record([0, 0, 0, 0, 0, 0, 0, 1200])), Some(Axes::default())); + } + + #[test] + fn what_spnavcfg_swaps_reaches_the_view() { + // The daemon sends a push away as y. With Y and Z swapped in spnavcfg it sends the same push + // as z, and the view gets a lift (raw z) instead of the zoom (raw y). + let before = decode(&record([0, 0, 500, 0, 0, 0, 0, 8])).map(|axes| axes.translation); + let after = decode(&record([0, 0, 0, 500, 0, 0, 0, 8])).map(|axes| axes.translation); + assert_eq!(before, Some([0, -350, 0])); + assert_eq!(after, Some([0, 0, -350])); + } + + #[test] + fn other_records_and_hostile_values_are_harmless() { + // A button record (kind 1, press) and one of an unknown kind are not motion. + assert_eq!(decode(&record([1, 0, 1, 0, 0, 0, 0, 0])), None); + assert_eq!(decode(&record([7, 500, 500, 500, 500, 500, 500, 0])), None); + // Nothing overflows on the extremes of `i32`. + let extreme = decode(&record([0, i32::MIN, i32::MIN, i32::MAX, i32::MAX, i32::MIN, i32::MIN, -1])); + assert_eq!(extreme.map(|axes| axes.translation[0]), Some(i16::MIN)); + assert_eq!(extreme.map(|axes| axes.translation[1]), Some(i16::MAX)); + assert_eq!(extreme.map(|axes| axes.rotation[0]), Some(i16::MAX)); + } + + #[test] + fn a_stream_is_read_record_by_record_until_it_ends() { + let (mut daemon, client) = UnixStream::pair().expect("a socket pair"); + let mut bytes = Vec::new(); + bytes.extend(record([0, 100, 0, 0, 0, 0, 0, 8])); + bytes.extend(record([2, 1, 0, 0, 0, 0, 0, 0])); // a button release: skipped + bytes.extend(record([0, 0, 0, 0, 0, 0, 0, 900])); + bytes.extend([1, 2, 3]); // the daemon went away mid-record + daemon.write_all(&bytes).expect("written"); + drop(daemon); + let mut seen = Vec::new(); + read(client, |axes| seen.push(axes.translation)); + assert_eq!(seen, [[70, 0, 0], [0, 0, 0]]); + } + + #[test] + fn no_daemon_means_no_connection() { + let missing = std::env::temp_dir().join(format!("sc-no-spnav-{}.sock", std::process::id())); + assert!(connect_to(&missing).is_none()); + } +}