forked from 1b-agent/opencadstudio-upstream
Merge pull request #1299: Add Windows SpaceMouse navigation
This commit is contained in:
commit
af847ad92e
32 files changed
+3508
-12
No files matched your search
@@ -0,0 +1,2 @@
|
||||
//! Device integrations. Camera ownership and CAD commands stay in the app.
|
||||
pub(crate) mod spacemouse;
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user