Merge pull request #1299: Add Windows SpaceMouse navigation

This commit is contained in:
Hakan Seven committed 2026-09-16 09:41:25 +03:00
commit af847ad92e
32 files changed
+3508 -12

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
//! Device integrations. Camera ownership and CAD commands stay in the app.
pub(crate) mod spacemouse;
+593
View File
@@ -0,0 +1,593 @@
//! SpaceMouse navigation data and the thread-safe bridge to the platform driver.
//!
//! No SDK types escape this module. Driver callbacks operate on a snapshot;
//! only the app thread applies a proposed view to a drawing.
// Snapshot/ABI support is intentionally dormant until another platform has an adapter.
#![cfg_attr(not(windows), allow(dead_code))]
use glam::{DMat4, DQuat, DVec3};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
#[cfg(windows)]
mod navlib;
mod pan;
#[cfg(windows)]
mod raw_input;
#[cfg(windows)]
mod worker;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum NavigationMode {
#[default]
Auto,
PanOnly,
PanZoom,
Full3D,
}
impl NavigationMode {
pub const ALL: [Self; 4] = [Self::Auto, Self::PanOnly, Self::PanZoom, Self::Full3D];
pub fn label(self) -> &'static str {
match self {
Self::Auto => "Follow context",
Self::PanOnly => "Pan only",
Self::PanZoom => "Pan and zoom",
Self::Full3D => "3D navigation",
}
}
pub fn description(self) -> &'static str {
match self {
Self::Auto => "Pan and zoom on paper sheets; full navigation in model views.",
Self::PanOnly => {
"Push or tilt to pan. Twist and vertical pressure are ignored; zoom and rotation stay fixed."
}
Self::PanZoom => {
"Push or tilt to pan; lift or press to zoom. Twist is ignored and rotation stays fixed."
}
Self::Full3D => "Pan, zoom, and orbit model views. Paper sheets keep rotation locked.",
}
}
pub fn allows_rotation(self, sheet: bool) -> bool {
!sheet && matches!(self, Self::Auto | Self::Full3D)
}
pub fn allows_zoom(self) -> bool {
self != Self::PanOnly
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Preferences {
pub enabled: bool,
pub mode: NavigationMode,
pub pan_speed: u16,
pub pan_reversed: bool,
}
impl Default for Preferences {
fn default() -> Self {
Self {
enabled: true,
mode: NavigationMode::Auto,
pan_speed: 100,
pan_reversed: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Status {
Starting,
Ready,
Disconnected,
Unavailable(String),
Unsupported,
}
impl Status {
pub fn label(&self) -> &str {
match self {
Self::Starting => "Connecting…",
Self::Ready => "Ready",
Self::Disconnected => "No SpaceMouse connected",
Self::Unavailable(_) => "3DxWare unavailable",
Self::Unsupported => "Available in the Windows desktop app",
}
}
}
/// Stable target plus camera revision. Late callbacks must never move another
/// document, pane, layout, or a camera already changed by another input source.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Target {
pub tab: u64,
pub layout: String,
pub viewport: Option<u64>,
pub tile: usize,
pub generation: u64,
}
impl Target {
pub fn same_context(&self, other: &Self) -> bool {
self.tab == other.tab
&& self.layout == other.layout
&& self.viewport == other.viewport
&& self.tile == other.tile
}
}
#[derive(Clone, Debug)]
pub struct View {
pub target: Target,
pub eye: DVec3,
pub rotation: DQuat,
pub distance: f64,
pub fov: f64,
pub perspective: bool,
pub aspect: f64,
pub model_bounds: [f64; 6],
pub selection_bounds: Option<[f64; 6]>,
pub pointer: DVec3,
pub pivot: DVec3,
pub pivot_visible: bool,
pub rotate: bool,
pub zoom: bool,
pub motion_enabled: bool,
pub units_to_meters: f64,
pub pan_speed: f64,
}
impl View {
fn planar(&self) -> bool {
!self.rotate
}
pub fn affine(&self) -> [f64; 16] {
DMat4::from_rotation_translation(self.rotation, self.eye).to_cols_array()
}
pub fn half_height(&self) -> f64 {
self.distance * (self.fov * 0.5).tan()
}
pub fn extents(&self) -> [f64; 6] {
let h = self.half_height();
let w = h * self.aspect;
[
-w,
-h,
-self.distance * 1000.0,
w,
h,
self.distance * 1000.0,
]
}
/// Validate before accepting driver data. A pan-only view projects the
/// translation onto its original screen plane, including in perspective.
pub fn set_affine(&mut self, values: [f64; 16]) -> bool {
if !values.iter().all(|v| v.is_finite()) {
return false;
}
let matrix = DMat4::from_cols_array(&values);
let x = matrix.x_axis.truncate();
let y = matrix.y_axis.truncate();
let z = matrix.z_axis.truncate();
if (x.length_squared() - 1.0).abs() > 0.01
|| (y.length_squared() - 1.0).abs() > 0.01
|| (z.length_squared() - 1.0).abs() > 0.01
|| x.dot(y).abs() > 0.01
|| x.cross(y).dot(z) < 0.99
|| matrix.w_axis.w != 1.0
|| matrix.x_axis.w != 0.0
|| matrix.y_axis.w != 0.0
|| matrix.z_axis.w != 0.0
{
return false;
}
let proposed_eye = matrix.w_axis.truncate();
if self.zoom {
self.eye = proposed_eye;
} else {
let normal = self.rotation * DVec3::Z;
let delta = proposed_eye - self.eye;
self.eye += delta - normal * delta.dot(normal);
}
if self.rotate {
self.rotation = DQuat::from_mat4(&matrix).normalize();
}
true
}
pub fn set_extents(&mut self, extents: [f64; 6]) -> bool {
if !extents.iter().all(|v| v.is_finite()) {
return false;
}
let distance = (extents[4] - extents[1]) * 0.5 / (self.fov * 0.5).tan();
if !(1e-6..=1e15).contains(&distance) {
return false;
}
if self.zoom {
// Orthographic zoom changes scale while retaining the view center.
self.eye += self.rotation * DVec3::Z * (distance - self.distance);
self.distance = distance;
}
true
}
/// Honor the driver's Lock Horizon option in our Z-up world. Keep the
/// proposed eye and viewing direction away from the poles. A pitch through
/// a pole must stop there: rebuilding world-up on the far side would reverse
/// screen-right and turn the view through 180 degrees.
fn lock_horizon(&mut self, previous: DQuat, orbit_about_pivot: bool) {
if !self.rotate {
return;
}
// Leave enough room for the app camera's f32 quaternion round-trip.
const POLE_MARGIN: f64 = 1e-4;
let proposed_normal = self.rotation * DVec3::Z;
let mut normal = proposed_normal;
let previous_right = (previous * DVec3::X)
.truncate()
.extend(0.)
.try_normalize()
.unwrap_or(DVec3::X);
let right = DVec3::Z.cross(normal);
let crossed_pole =
right.dot(previous_right) < 0. && (self.rotation * DVec3::X).dot(previous_right) > 0.;
let right = if crossed_pole || right.length_squared() < POLE_MARGIN.powi(2) {
let horizontal_normal = previous_right.cross(DVec3::Z);
normal = horizontal_normal * POLE_MARGIN
+ DVec3::Z * normal.z.signum() * (1. - POLE_MARGIN.powi(2)).sqrt();
// Apply the rejected pitch to the eye around the SDK's orbit pivot
// too. Correcting orientation alone lets a held orbit carry the eye
// over the pole and shifts the object across the screen.
if orbit_about_pivot {
let correction = DQuat::from_rotation_arc(proposed_normal, normal);
self.eye = self.pivot + correction * (self.eye - self.pivot);
}
previous_right
} else {
right.normalize()
};
self.rotation =
DQuat::from_mat3(&glam::DMat3::from_cols(right, normal.cross(right), normal))
.normalize();
}
}
#[derive(Clone, Debug)]
pub struct Action {
pub id: String,
pub label: String,
pub description: String,
pub icon: Option<&'static [u8]>,
}
#[derive(Default)]
struct Bridge {
view: Option<View>,
pending_view: Option<View>,
actions: Vec<Action>,
commands: VecDeque<(Target, String)>,
focus: bool,
moving: bool,
in_transaction: bool,
frame_time: Option<f64>,
reset_motion: bool,
driver_settings_changed: bool,
lock_horizon: bool,
orbit_about_pivot: bool,
pan: pan::Pan,
status: Option<Status>,
ever_connected: bool,
wake: Option<iced::futures::channel::mpsc::Sender<()>>,
#[cfg(windows)]
control: Option<worker::Wake>,
}
impl Bridge {
fn wake(&mut self) {
if let Some(wake) = &mut self.wake {
let _ = wake.try_send(());
}
}
}
#[derive(Clone, Default)]
pub struct Service(Arc<Mutex<Bridge>>);
impl Hash for Service {
fn hash<H: Hasher>(&self, state: &mut H) {
Arc::as_ptr(&self.0).hash(state);
}
}
impl Service {
pub fn status(&self) -> Status {
if !cfg!(any(windows, test)) {
return Status::Unsupported;
}
self.0
.lock()
.unwrap()
.status
.clone()
.unwrap_or(Status::Starting)
}
pub fn visible(&self) -> bool {
self.0.lock().unwrap().ever_connected
}
pub fn moving(&self) -> bool {
let bridge = self.0.lock().unwrap();
if let Some(view) = bridge.view.as_ref().filter(|v| v.planar()) {
bridge.pan.moving(view.zoom)
} else {
bridge.moving
}
}
pub fn frame(&self, milliseconds: f64) {
let mut bridge = self.0.lock().unwrap();
if bridge.focus
&& bridge
.view
.as_ref()
.is_some_and(|v| v.planar() && v.motion_enabled)
{
let zoom = bridge.view.as_ref().unwrap().zoom;
let delta = bridge.pan.frame(milliseconds, zoom);
if delta != DVec3::ZERO {
let view = bridge.view.as_mut().unwrap();
view.eye -= view.rotation
* delta.truncate().extend(0.)
* view.half_height()
* 2.
* view.pan_speed;
// Lift/pressure owns zoom in both projections. Keep the center
// fixed and leave push/tilt exclusively responsible for pan.
let distance = (view.distance * (delta.z * 2.).exp()).clamp(1e-6, 1e15);
view.eye += view.rotation * DVec3::Z * (distance - view.distance);
view.distance = distance;
bridge.pending_view = bridge.view.clone();
}
// Also wake after a stale report stops motion, so rendering can idle.
bridge.wake();
return;
}
if bridge.focus && bridge.moving {
bridge.frame_time = Some(milliseconds);
#[cfg(windows)]
if let Some(wake) = &bridge.control {
wake.signal();
}
}
}
#[cfg(test)]
pub fn test_connect(&self) {
self.0.lock().unwrap().status = Some(Status::Ready);
}
#[cfg(test)]
pub fn test_view(&self) -> Option<View> {
self.0.lock().unwrap().view.clone()
}
#[cfg(test)]
pub fn test_pan_report(&self, axes: [i16; 6]) {
let report: Vec<_> = std::iter::once(1)
.chain(axes.into_iter().flat_map(i16::to_le_bytes))
.collect();
self.0.lock().unwrap().pan.report(&report);
}
#[cfg(test)]
pub fn test_propose(&self, change: impl FnOnce(&mut View)) {
let mut bridge = self.0.lock().unwrap();
let mut view = bridge.view.clone().expect("focused drawing snapshot");
change(&mut view);
bridge.pending_view = Some(view);
bridge.moving = true;
}
pub fn set_actions(&self, actions: Vec<Action>) {
self.0.lock().unwrap().actions = actions;
}
/// Rebase callbacks that arrived while the UI applied the previous frame.
/// They still belong to the same gesture, despite the scene revision bump.
pub fn acknowledge(&self, previous: &Target, applied: &Target) {
let mut bridge = self.0.lock().unwrap();
if previous.same_context(applied) {
let Bridge {
view, pending_view, ..
} = &mut *bridge;
for view in [view, pending_view].into_iter().flatten() {
if &view.target == previous {
view.target = applied.clone();
}
}
}
}
pub fn sync(&self, view: Option<View>, focus: bool) {
let mut bridge = self.0.lock().unwrap();
let context_changed = match (&bridge.view, &view) {
(Some(old), Some(new)) => !old.target.same_context(&new.target),
(None, None) => false,
_ => true,
};
let changed = bridge.view.as_ref().map(|v| &v.target) != view.as_ref().map(|v| &v.target)
|| bridge.focus != focus
|| bridge
.view
.as_ref()
.map(|v| (v.rotate, v.zoom, v.motion_enabled))
!= view.as_ref().map(|v| (v.rotate, v.zoom, v.motion_enabled));
if changed {
bridge.reset_motion = true;
bridge.frame_time = None;
bridge.pan.clear();
bridge.pending_view = None;
if context_changed || bridge.focus != focus {
bridge.commands.clear();
bridge.moving = false;
}
bridge.in_transaction = false;
bridge.view = view;
} else if bridge.pending_view.is_none() {
bridge.view = view;
}
bridge.focus = focus;
if changed {
#[cfg(windows)]
if let Some(wake) = &bridge.control {
wake.signal();
}
}
}
pub fn drain(&self) -> (Option<View>, Vec<(Target, String)>) {
let mut bridge = self.0.lock().unwrap();
let view = if bridge.in_transaction {
None
} else {
bridge.pending_view.take()
};
(view, bridge.commands.drain(..).collect())
}
pub fn subscription(&self) -> iced::Subscription<()> {
#[cfg(windows)]
{
iced::Subscription::run_with(self.clone(), navlib::subscription)
}
#[cfg(not(windows))]
{
iced::Subscription::none()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(super) fn view() -> View {
View {
target: Target {
tab: 1,
layout: "Model".into(),
viewport: None,
tile: 0,
generation: 0,
},
eye: DVec3::new(0., 0., 10.),
rotation: DQuat::IDENTITY,
distance: 10.,
fov: 1.,
perspective: false,
aspect: 1.,
model_bounds: [-1., -1., -1., 1., 1., 1.],
selection_bounds: None,
pointer: DVec3::ZERO,
pivot: DVec3::ZERO,
pivot_visible: false,
rotate: false,
zoom: false,
motion_enabled: true,
units_to_meters: 1.,
pan_speed: 1.,
}
}
#[test]
fn pan_only_preserves_depth_rotation_and_zoom() {
let mut v = view();
v.rotation = DQuat::from_rotation_x(0.7);
let initial = v.clone();
let normal = initial.rotation * DVec3::Z;
let proposed = DMat4::from_rotation_translation(
DQuat::from_rotation_y(0.4),
v.eye + DVec3::new(4., 5., 6.),
);
assert!(v.set_affine(proposed.to_cols_array()));
assert!((v.eye - initial.eye).dot(normal).abs() < 1e-10);
assert_eq!(v.rotation, initial.rotation);
assert!(v.set_extents([-20., -20., -20., 20., 20., 20.]));
assert_eq!(v.distance, initial.distance);
}
#[test]
fn invalid_driver_matrices_do_not_corrupt_the_camera() {
let mut v = view();
let eye = v.eye;
assert!(!v.set_affine([f64::NAN; 16]));
assert!(!v.set_affine([0.; 16]));
assert!(!v.set_extents([0.; 6]));
assert_eq!(v.eye, eye);
}
#[test]
fn context_and_focus_changes_discard_queued_input() {
let service = Service::default();
let v = view();
service.sync(Some(v.clone()), true);
service.0.lock().unwrap().pending_view = Some(v.clone());
service.sync(Some(v.clone()), false);
assert!(service.drain().0.is_none());
service.0.lock().unwrap().pending_view = Some(v.clone());
let mut other = v;
other.target.tab = 2;
service.sync(Some(other), true);
assert!(service.drain().0.is_none());
}
#[test]
fn paper_cannot_rotate_in_any_mode() {
for mode in NavigationMode::ALL {
assert!(!mode.allows_rotation(true));
}
assert!(NavigationMode::Auto.allows_rotation(false));
assert!(!NavigationMode::PanOnly.allows_rotation(false));
}
#[test]
fn navigation_frames_are_drained_atomically() {
let service = Service::default();
service.sync(Some(view()), true);
service.test_propose(|v| v.eye.x += 2.);
service.0.lock().unwrap().in_transaction = true;
assert!(service.drain().0.is_none());
service.0.lock().unwrap().in_transaction = false;
assert_eq!(service.drain().0.unwrap().eye.x, 2.);
assert!(service.drain().0.is_none());
}
#[test]
fn camera_updates_retain_buttons_but_document_changes_drop_them() {
let service = Service::default();
let mut v = view();
service.sync(Some(v.clone()), true);
service
.0
.lock()
.unwrap()
.commands
.push_back((v.target.clone(), "UNDO".into()));
v.target.generation += 1;
service.sync(Some(v.clone()), true);
assert_eq!(service.drain().1.len(), 1);
service
.0
.lock()
.unwrap()
.commands
.push_back((v.target.clone(), "UNDO".into()));
v.target.viewport = Some(123);
service.sync(Some(v), true);
assert!(service.drain().1.is_empty());
}
#[test]
fn applying_a_frame_retains_newer_callbacks_in_the_same_gesture() {
let service = Service::default();
let mut applied = view();
service.sync(Some(applied.clone()), true);
service.0.lock().unwrap().reset_motion = false;
service.test_propose(|v| v.eye.x = 1.);
let first = service.drain().0.unwrap();
service.test_propose(|v| v.eye.x = 2.);
service.0.lock().unwrap().in_transaction = true;
applied.eye = first.eye;
applied.target.generation += 1;
service.acknowledge(&first.target, &applied.target);
service.sync(Some(applied.clone()), true);
assert!(service.drain().0.is_none());
assert!(!service.0.lock().unwrap().reset_motion);
service.0.lock().unwrap().in_transaction = false;
let second = service.drain().0.unwrap();
assert_eq!(second.eye.x, 2.);
assert_eq!(second.target, applied.target);
// A subsequent wheel/mouse camera revision must reset the SDK model.
applied.target.generation += 1;
service.sync(Some(applied), true);
assert!(service.0.lock().unwrap().reset_motion);
}
}
File diff suppressed because it is too large. Load diff
+138
View File
@@ -0,0 +1,138 @@
//! Planar input in device space: pushes and tilts have the same meaning.
//! The SDK's camera matrices have already combined orbit, pan, and zoom, so
//! they cannot reliably recover these independent physical inputs.
use glam::{DVec2, DVec3};
use std::time::{Duration, Instant};
#[derive(Default)]
pub(super) struct Pan {
axes: [i16; 6],
received: Option<Instant>,
frame_time: Option<f64>,
}
impl Pan {
pub fn clear(&mut self) {
*self = Self::default();
}
/// SpaceMouse USB uses split translation/rotation reports; Wireless also
/// sends a combined report. Ignore buttons, battery, and truncated reports.
pub fn report(&mut self, report: &[u8]) -> bool {
let (start, count) = match (report.first(), report.len()) {
(Some(1), 13..) => (0, 6),
(Some(1), 7) => (0, 3),
(Some(2), 7..) => (3, 3),
_ => return false,
};
for i in 0..count {
self.axes[start + i] = i16::from_le_bytes([report[1 + i * 2], report[2 + i * 2]]);
}
self.received = Some(Instant::now());
if !self.moving(true) {
self.frame_time = None;
}
true
}
pub fn moving(&self, zoom: bool) -> bool {
direction(self.axes) != DVec2::ZERO || (zoom && response(self.axes[2] as f64) != 0.)
}
pub fn frame(&mut self, milliseconds: f64, zoom: bool) -> DVec3 {
if !milliseconds.is_finite()
|| self
.received
.is_none_or(|t| t.elapsed() > Duration::from_millis(350))
{
self.clear();
return DVec3::ZERO;
}
let dt = self.frame_time.map_or(1. / 60., |old| {
((milliseconds - old) / 1000.).clamp(0., 0.05)
});
self.frame_time = Some(milliseconds);
direction(self.axes).extend(if zoom {
response(self.axes[2] as f64)
} else {
0.
}) * dt
}
}
fn response(v: f64) -> f64 {
let n = (v.abs() / 350.).min(1.);
if n <= 0.08 {
0.
} else {
v.signum() * ((n - 0.08) / 0.92).powf(1.5)
}
}
fn direction(axes: [i16; 6]) -> DVec2 {
fn strongest(push: f64, tilt: f64) -> f64 {
// A hand often pushes and tilts together. Do not double the speed.
if push.abs() >= tilt.abs() {
push
} else {
tilt
}
}
let right = strongest(response(axes[0] as f64), response(-(axes[4] as f64)));
let up = strongest(response(-(axes[1] as f64)), response(-(axes[3] as f64)));
DVec2::new(right, up).clamp_length_max(1.)
}
#[cfg(test)]
mod tests {
use super::*;
fn report(axes: [i16; 6]) -> Vec<u8> {
std::iter::once(1)
.chain(axes.into_iter().flat_map(i16::to_le_bytes))
.collect()
}
#[test]
fn pushes_and_tilts_pan_in_the_same_screen_directions() {
for (push, tilt, expected) in [
([350, 0, 0, 0, 0, 0], [0, 0, 0, 0, -350, 0], DVec2::X),
([-350, 0, 0, 0, 0, 0], [0, 0, 0, 0, 350, 0], -DVec2::X),
([0, -350, 0, 0, 0, 0], [0, 0, 0, -350, 0, 0], DVec2::Y),
([0, 350, 0, 0, 0, 0], [0, 0, 0, 350, 0, 0], -DVec2::Y),
] {
assert_eq!(direction(push), expected);
assert_eq!(direction(tilt), expected);
}
assert_eq!(direction([350, 0, 0, 0, -350, 0]), DVec2::X);
}
#[test]
fn twist_vertical_pressure_and_sensor_noise_do_not_pan() {
assert_eq!(direction([0, 0, 350, 0, 0, -350]), DVec2::ZERO);
assert_eq!(direction([10, -10, 350, 20, -20, 350]), DVec2::ZERO);
}
#[test]
fn held_input_continues_and_release_or_disconnect_stops_it() {
let mut pan = Pan::default();
assert!(pan.report(&report([350, 0, 0, 0, 0, 0])));
for frame in 0..20 {
assert!(pan.frame(frame as f64 * 16., false).x > 0.);
}
assert!(pan.report(&report([0; 6])));
assert!(!pan.moving(false));
assert_eq!(pan.frame(400., false), DVec3::ZERO);
pan.report(&report([350, 0, 0, 0, 0, 0]));
pan.received = Some(Instant::now() - Duration::from_secs(1));
assert_eq!(pan.frame(500., false), DVec3::ZERO);
assert!(!pan.moving(false));
}
#[test]
fn split_and_combined_reports_agree_and_other_reports_are_ignored() {
let mut pan = Pan::default();
let full = report([1, 2, 3, 4, 5, 6]);
assert!(pan.report(&full[..7]));
let mut rotation = vec![2];
rotation.extend_from_slice(&full[7..]);
assert!(pan.report(&rotation));
assert_eq!(pan.axes, [1, 2, 3, 4, 5, 6]);
assert!(!pan.report(&[1, 2, 3]));
assert!(!pan.report(&[23; 13]));
assert_eq!(pan.axes, [1, 2, 3, 4, 5, 6]);
}
}
+235
View File
@@ -0,0 +1,235 @@
//! Windows multi-axis HID receiver for push-or-tilt planar navigation.
//! Registers only Generic Desktop / Multi-axis Controller, never a keyboard
//! or ordinary mouse. The driver continues to own buttons and 3D navigation.
use super::*;
use std::sync::atomic::{AtomicIsize, Ordering};
use windows_sys::Win32::{
Foundation::{HANDLE, HWND, LPARAM, LRESULT, WPARAM},
System::LibraryLoader::GetModuleHandleW,
UI::{Input::*, WindowsAndMessaging::*},
};
pub(super) struct Listener {
window: Arc<AtomicIsize>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Listener {
pub fn start(shared: Arc<Mutex<Bridge>>) -> Self {
let window = Arc::new(AtomicIsize::new(0));
let worker_window = window.clone();
let thread = std::thread::Builder::new()
.name("spacemouse-pan".into())
.spawn(move || {
if let Err(error) = run(shared, &worker_window) {
eprintln!("SpaceMouse pan input: {error}");
}
})
.ok();
Self { window, thread }
}
}
impl Drop for Listener {
fn drop(&mut self) {
let hwnd = self.window.swap(-1, Ordering::AcqRel);
if hwnd > 0 {
unsafe {
PostMessageW(hwnd as HWND, WM_CLOSE, 0, 0);
}
}
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn run(shared: Arc<Mutex<Bridge>>, window: &AtomicIsize) -> Result<(), String> {
let class: Vec<u16> = "OpenCADStudio.SpaceMouse.Pan\0".encode_utf16().collect();
unsafe {
let instance = GetModuleHandleW(std::ptr::null());
let definition = WNDCLASSW {
lpfnWndProc: Some(procedure),
hInstance: instance,
lpszClassName: class.as_ptr(),
..Default::default()
};
// Another subscription may have registered the class earlier.
if RegisterClassW(&definition) == 0 {
let mut existing = WNDCLASSW::default();
if GetClassInfoW(instance, class.as_ptr(), &mut existing) == 0 {
return Err(std::io::Error::last_os_error().to_string());
}
}
let hwnd = CreateWindowExW(
0,
class.as_ptr(),
std::ptr::null(),
0,
0,
0,
0,
0,
HWND_MESSAGE,
std::ptr::null_mut(),
instance,
std::ptr::null(),
);
if hwnd.is_null() {
return Err(std::io::Error::last_os_error().to_string());
}
SetWindowLongPtrW(hwnd, GWLP_USERDATA, Arc::as_ptr(&shared) as isize);
if window
.compare_exchange(0, hwnd as isize, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
DestroyWindow(hwnd);
return Ok(());
}
let device = RAWINPUTDEVICE {
usUsagePage: 1,
usUsage: 8,
dwFlags: RIDEV_INPUTSINK | RIDEV_DEVNOTIFY,
hwndTarget: hwnd,
};
if RegisterRawInputDevices(&device, 1, std::mem::size_of::<RAWINPUTDEVICE>() as u32) == 0 {
window.store(0, Ordering::Release);
DestroyWindow(hwnd);
return Err(std::io::Error::last_os_error().to_string());
}
let mut message = MSG::default();
while GetMessageW(&mut message, std::ptr::null_mut(), 0, 0) > 0 {
TranslateMessage(&message);
DispatchMessageW(&message);
}
let remove = RAWINPUTDEVICE {
dwFlags: RIDEV_REMOVE,
hwndTarget: std::ptr::null_mut(),
..device
};
RegisterRawInputDevices(&remove, 1, std::mem::size_of::<RAWINPUTDEVICE>() as u32);
window.store(0, Ordering::Release);
}
Ok(())
}
unsafe extern "system" fn procedure(hwnd: HWND, message: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
if message == WM_DESTROY {
unsafe {
PostQuitMessage(0);
}
return 0;
}
let pointer = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const Mutex<Bridge>;
if !pointer.is_null() {
let shared = unsafe { &*pointer };
if message == WM_INPUT {
input(shared, lp as HRAWINPUT);
} else if message == WM_INPUT_DEVICE_CHANGE && wp == GIDC_REMOVAL as usize {
if let Ok(mut bridge) = shared.lock() {
bridge.pan.clear();
bridge.wake();
}
}
}
unsafe { DefWindowProcW(hwnd, message, wp, lp) }
}
fn is_spacemouse(device: HANDLE) -> bool {
let mut info = RID_DEVICE_INFO {
cbSize: std::mem::size_of::<RID_DEVICE_INFO>() as u32,
..Default::default()
};
let mut size = info.cbSize;
unsafe {
if GetRawInputDeviceInfoW(
device,
RIDI_DEVICEINFO,
(&mut info as *mut RID_DEVICE_INFO).cast(),
&mut size,
) == u32::MAX
|| info.dwType != RIM_TYPEHID
{
return false;
}
let hid = info.Anonymous.hid;
// Logitech's legacy SpaceMouse IDs and current 3Dconnexion vendor ID.
hid.usUsagePage == 1
&& hid.usUsage == 8
&& (hid.dwVendorId == 0x256f
|| (hid.dwVendorId == 0x046d && (0xc603..=0xc631).contains(&hid.dwProductId)))
}
}
/// Older NavLib versions report device.present as always true. Enumerate the
/// registered multi-axis HID devices as well, so availability reflects hardware.
pub(super) fn connected() -> Result<bool, String> {
let element_size = std::mem::size_of::<RAWINPUTDEVICELIST>() as u32;
let mut count = 0;
unsafe {
if GetRawInputDeviceList(std::ptr::null_mut(), &mut count, element_size) == u32::MAX {
return Err(std::io::Error::last_os_error().to_string());
}
let mut devices = vec![RAWINPUTDEVICELIST::default(); count as usize];
let read = GetRawInputDeviceList(devices.as_mut_ptr(), &mut count, element_size);
if read == u32::MAX {
return Err(std::io::Error::last_os_error().to_string());
}
Ok(devices
.iter()
.take(read as usize)
.any(|device| device.dwType == RIM_TYPEHID && is_spacemouse(device.hDevice)))
}
}
fn input(shared: &Mutex<Bridge>, handle: HRAWINPUT) {
// u64 storage provides the alignment required by RAWINPUTHEADER.
let mut buffer = [0_u64; 128];
let mut size = std::mem::size_of_val(&buffer) as u32;
let header_size = std::mem::size_of::<RAWINPUTHEADER>();
let read = unsafe {
GetRawInputData(
handle,
RID_INPUT,
buffer.as_mut_ptr().cast(),
&mut size,
header_size as u32,
)
};
if read == u32::MAX
|| read < (header_size + 8) as u32
|| read as usize > std::mem::size_of_val(&buffer)
{
return;
}
let header = unsafe { &*buffer.as_ptr().cast::<RAWINPUTHEADER>() };
if header.dwType != RIM_TYPEHID || !is_spacemouse(header.hDevice) {
return;
}
let bytes = unsafe { std::slice::from_raw_parts(buffer.as_ptr().cast::<u8>(), read as usize) };
let width =
u32::from_le_bytes(bytes[header_size..header_size + 4].try_into().unwrap()) as usize;
let count =
u32::from_le_bytes(bytes[header_size + 4..header_size + 8].try_into().unwrap()) as usize;
if width == 0 || count > (bytes.len() - header_size - 8) / width {
return;
}
let Ok(mut bridge) = shared.lock() else {
return;
};
if !bridge.focus
|| !bridge
.view
.as_ref()
.is_some_and(|v| v.planar() && v.motion_enabled)
{
return;
}
let zoom = bridge.view.as_ref().unwrap().zoom;
let was_moving = bridge.pan.moving(zoom);
let mut accepted = false;
for report in bytes[header_size + 8..].chunks_exact(width).take(count) {
accepted |= bridge.pan.report(report);
}
if accepted && (was_moving || bridge.pan.moving(zoom)) {
bridge.wake();
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Wait for app work and dispatch messages to windows owned by the SDK thread.
//! A channel-only wait leaves Windows-delivered driver settings unprocessed.
use std::{sync::Arc, time::Duration};
use windows_sys::Win32::{
Foundation::{CloseHandle, HANDLE},
System::Threading::{CreateEventW, SetEvent},
UI::WindowsAndMessaging::*,
};
struct Event(isize);
impl Drop for Event {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0 as HANDLE);
}
}
}
#[derive(Clone)]
pub(super) struct Wake(Arc<Event>);
impl Wake {
pub fn new() -> Result<Self, std::io::Error> {
let event = unsafe { CreateEventW(std::ptr::null(), 0, 0, std::ptr::null()) };
if event.is_null() {
Err(std::io::Error::last_os_error())
} else {
Ok(Self(Arc::new(Event(event as isize))))
}
}
pub fn signal(&self) {
unsafe {
SetEvent(self.0 .0 as HANDLE);
}
}
pub fn wait(&self, timeout: Duration) {
let handle = self.0 .0 as HANDLE;
unsafe {
MsgWaitForMultipleObjectsEx(
1,
&handle,
timeout.as_millis().min(u32::MAX as u128) as u32,
QS_ALLINPUT,
MWMO_INPUTAVAILABLE,
);
let mut message = MSG::default();
// Bound each batch so an SDK message burst cannot starve shutdown
// or app frames. The next wait returns immediately if more remain.
for _ in 0..256 {
if PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) == 0 {
break;
}
TranslateMessage(&message);
DispatchMessageW(&message);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use windows_sys::Win32::{Foundation::*, System::LibraryLoader::GetModuleHandleW};
static DELIVERED: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn procedure(
window: HWND,
message: u32,
wp: WPARAM,
lp: LPARAM,
) -> LRESULT {
if message == WM_APP {
DELIVERED.fetch_add(1, Ordering::SeqCst);
return 0;
}
unsafe { DefWindowProcW(window, message, wp, lp) }
}
#[test]
fn sdk_worker_dispatches_window_messages_and_app_wakes() {
std::thread::spawn(|| unsafe {
let wake = Wake::new().unwrap();
let class: Vec<u16> = "OpenCADStudio.SpaceMouse.Worker.Test\0"
.encode_utf16()
.collect();
let instance = GetModuleHandleW(std::ptr::null());
let definition = WNDCLASSW {
lpfnWndProc: Some(procedure),
hInstance: instance,
lpszClassName: class.as_ptr(),
..Default::default()
};
assert_ne!(RegisterClassW(&definition), 0);
let window = CreateWindowExW(
0,
class.as_ptr(),
std::ptr::null(),
0,
0,
0,
0,
0,
HWND_MESSAGE,
std::ptr::null_mut(),
instance,
std::ptr::null(),
);
assert!(!window.is_null());
assert_ne!(PostMessageW(window, WM_APP, 0, 0), 0);
wake.wait(Duration::from_secs(1));
assert_eq!(DELIVERED.load(Ordering::SeqCst), 1);
let other = wake.clone();
std::thread::spawn(move || other.signal()).join().unwrap();
let start = std::time::Instant::now();
wake.wait(Duration::from_secs(5));
assert!(start.elapsed() < Duration::from_secs(1));
DestroyWindow(window);
UnregisterClassW(class.as_ptr(), instance);
})
.join()
.unwrap();
}
}