diff --git a/Cargo.lock b/Cargo.lock
index c22fb8c85..75d2c4495 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -35,6 +35,7 @@ dependencies = [
"image",
"inventory",
"js-sys",
+ "libloading",
"log",
"lyon_tessellation",
"meshopt",
diff --git a/Cargo.toml b/Cargo.toml
index 03f1476fa..05031cca2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -32,6 +32,7 @@ glam = { version = "0.33", features = ["bytemuck"] }
rfd = "0.17"
clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11"
+log = "0.4"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840", features = ["acis", "offset"] }
cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840" }
@@ -61,7 +62,8 @@ iced_test = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa
naga = { version = "27", features = ["wgsl-in"] }
[target.'cfg(target_os = "windows")'.dependencies]
-windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] }
+libloading = "0.8"
+windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] }
[target.'cfg(target_os = "linux")'.dependencies]
ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] }
@@ -94,7 +96,6 @@ ocs_plugin_api = { path = "crates/ocs_plugin_api" }
i18n-embed = { version = "0.16", features = ["web-sys-requester"] }
rust-embed = { version = "8", features = ["debug-embed"] }
console_error_panic_hook = "0.1"
-log = "0.4"
getrandom = { version = "0.3", features = ["wasm_js"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
diff --git a/assets/icons/ui/spacemouse.svg b/assets/icons/ui/spacemouse.svg
new file mode 100644
index 000000000..e9e73fc43
--- /dev/null
+++ b/assets/icons/ui/spacemouse.svg
@@ -0,0 +1 @@
+
diff --git a/docs/images/spacemouse-menu.png b/docs/images/spacemouse-menu.png
new file mode 100644
index 000000000..996ae27c1
Binary files /dev/null and b/docs/images/spacemouse-menu.png differ
diff --git a/docs/images/spacemouse-preferences.png b/docs/images/spacemouse-preferences.png
new file mode 100644
index 000000000..f6376e456
Binary files /dev/null and b/docs/images/spacemouse-preferences.png differ
diff --git a/docs/spacemouse.md b/docs/spacemouse.md
new file mode 100644
index 000000000..4e0ac1129
--- /dev/null
+++ b/docs/spacemouse.md
@@ -0,0 +1,145 @@
+# SpaceMouse
+
+The Windows desktop app supports 3Dconnexion SpaceMouse devices through the
+installed [3DxWare driver](https://3dconnexion.com/drivers/). Open CAD Studio
+loads NavLib from the Windows system directory at runtime. An SDK installation
+is not needed to build or run the app. macOS, Linux, and the web build currently
+show an unavailable state; their device adapters are not implemented.
+
+## Navigation
+
+Open **Options → User Preferences → SpaceMouse**, or enter `SPACEMOUSE`.
+
+| Mode | Behavior |
+| --- | --- |
+| Follow context | Pan and zoom on a paper sheet; pan, zoom, and orbit in model views. |
+| Pan only | Push or tilt the puck to pan in the current screen plane. Twist and vertical pressure are ignored. Scale, depth, and orientation stay fixed, including in perspective. |
+| Pan and zoom | Uses the same push/tilt panning as Pan only. Lift or press the puck to zoom about the view center. Twist is ignored and orientation stays fixed. |
+| 3D navigation | Pan, zoom, and orbit in model views. Paper sheets keep orientation fixed. |
+
+For a flat drawing, choose **Pan only** to use the puck as a panning joystick.
+Push or tilt right/left to move the drawing right/left; push or tilt away/toward
+you to move it up/down. Pushing and tilting together does not double the speed.
+Enable **Reverse pan direction** if you prefer moving the viewpoint in the same
+direction as the puck. **Reverse pan direction** and **Pan speed** apply to both
+flat-navigation modes, including paper sheets in Follow context. A small dead zone
+filters incidental forces, and releasing the puck stops movement.
+The app does not infer that a model-space file is 2D from its extension or from
+the current camera angle. These preferences belong to the user and persist
+across drawings and sessions.
+
+The status bar shows the effective mode after a device connects, and keeps the
+control available after disconnection. An explicitly selected navigation mode
+also makes it visible. Its menu provides mode selection, Pause/Resume, preferences,
+and a shortcut to 3Dconnexion settings. Status-bar customization can hide it.
+
+Only the focused drawing receives input. Dialogs suspend navigation. The
+view metadata remains available to the driver while its settings UI has focus.
+Native 3DxWare chooses the foreground application; the adapter marks its view
+active without forcing the NLServer-specific keyboard-focus property. Entered
+paper-space viewports use their own cameras; their display locks are respected.
+The active model tile receives navigation in a split view. Moving the puck
+preserves an unfinished drawing command, refreshes its preview at a stationary
+pointer, and keeps an active selection-box anchor attached to the drawing.
+
+
+
+
+
+## Buttons and radial menus
+
+Use **Open 3Dconnexion settings…** to assign buttons and radial-menu entries.
+The app exports registered CAD command IDs and reuses ribbon labels and icons
+where available. Speed, axis inversion, dominant-axis filtering, and radial-menu
+layout for SDK navigation remain in the driver settings. Pan only and Pan and zoom
+share the app's push/tilt mapping and Pan speed control; driver axis remappings do
+not apply to these flat-navigation modes. Button assignments continue to work in
+all modes.
+
+For orbiting with a fixed up direction, enable **Lock Horizon** in the driver's
+Advanced Settings for Open CAD Studio. The adapter reads `settings.LockHorizon`
+through NavLib at connection/focus and after `settings.changed` notifications.
+When enabled, accepted orbit matrices remove roll relative to world Z. Pitch
+stops just short of the top/bottom pole, retaining heading instead of flipping
+the view. In the driver's Object mode the eye receives the same pitch correction
+around the SDK's orbit pivot, so the object stays in place; Camera/Helicopter
+modes retain their proposed eye position. Disabling Lock Horizon restores
+unrestricted driver rotation.
+The app does not duplicate this control, the driver's rotation lock, or
+its object/camera navigation modes.
+
+Useful actions include Undo, Redo, Cancel, Fit view, Top view, and these commands:
+
+| Command | Action |
+| --- | --- |
+| `SPACEMOUSE` | Open SpaceMouse preferences |
+| `SPACEMOUSEPAUSE` | Pause/resume puck navigation for this session |
+| `SPACEMOUSEAUTO` | Follow context |
+| `SPACEMOUSEPAN` | Pan only |
+| `SPACEMOUSEPANZOOM` | Pan and zoom |
+| `SPACEMOUSE3D` | 3D navigation |
+
+Fit view (`SPACEMOUSEFIT`) and Top view (`SPACEMOUSETOP`) are also available as
+button/keyboard actions. Button actions share the keyboard dispatcher: for
+example, Undo removes the last entered point of an unfinished polyline. A
+Pause/Resume button remains usable while puck navigation is paused.
+
+## Implementation
+
+- `src/input/spacemouse/`: portable preferences, camera snapshots, and a bounded
+ thread-safe bridge. `navlib.rs` contains the Windows ABI and runtime loading.
+ `raw_input.rs` receives SpaceMouse multi-axis HID reports for planar navigation;
+ `pan.rs` maps pushes and tilts to screen velocity and vertical pressure to zoom.
+ The planar path suppresses SDK camera updates so one gesture cannot be applied
+ twice. Only 3Dconnexion/legacy Logitech multi-axis
+ controllers are accepted. Other HID devices and report types are ignored.
+ `worker.rs` waits for app work and dispatches Windows messages on the SDK's
+ owning thread, including settings changes while the drawing is unfocused.
+- `src/app/navigation.rs`: focus/context policy, cached selection bounds, camera
+ application, pivot feedback, and the action catalog.
+- `src/scene/mspace.rs`: routing to the active model or floating-viewport camera.
+- `src/ui/window/options/spacemouse.rs` and `src/ui/statusbar/spacemouse.rs`:
+ controls within the existing Options and status-menu systems.
+- `src/app/settings.rs`: preferences; `src/app/shortcuts.rs`: shared actions.
+
+Driver callbacks operate on snapshots and never access the scene or GUI. A
+navigation transaction is coalesced into one pending camera update. Target
+stamps discard late motion after a tab, layout, viewport, tile, or camera change.
+Acknowledging an applied frame retains newer callbacks from the same gesture;
+an external camera change resets the SDK navigation model. Raw pan state is
+cleared on context/focus changes, pause, disconnection, and stale reports.
+Button requests retain their context across camera revisions. Losing focus or
+changing drawing context discards queued input. Connection shutdown closes
+NavLib before releasing callback, command, image, or library storage.
+The subscription joins its worker at shutdown so NavLib can finish persisting
+the driver's per-application preferences before the process exits.
+
+For driver-setting diagnostics, set
+`RUST_LOG=OpenCADStudio::input::spacemouse=debug` before launching. The trace
+records settings-change notifications and the Lock Horizon value read from
+NavLib; it does not change the user's driver settings.
+
+Iced supplies monotonic frame timestamps while navigation is active. Idle
+drawings do not request continuous rendering. Driver-originated camera updates
+are not echoed back as external-camera notifications, which would reset the
+driver's navigation model during a held input.
+The optional SDK `view.target` property returns no-data: the app's camera center
+does not represent the SDK's separate target-camera navigation model.
+
+The adapter uses the NavLib C ABI described in the 3DxWare SDK headers
+(`navlib.h`, `navlib_types.h`, and `siappcmd_types.h`). No vendor headers or
+runtime binaries are included. SpaceMouse and 3Dconnexion are trademarks of
+3Dconnexion.
+
+## Validation
+
+```sh
+cargo test --locked --lib spacemouse
+```
+
+An opt-in smoke test checks the installed driver, command/icon export, device
+presence, and reading its Lock Horizon setting:
+
+```sh
+cargo test --locked --lib installed_driver_connects_and_reports_device_presence -- --ignored --nocapture
+```
diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs
index 9c60e3660..a1cd82915 100644
--- a/src/app/commands/mod.rs
+++ b/src/app/commands/mod.rs
@@ -65,6 +65,9 @@ impl OpenCADStudio {
// the first space are left untouched. A non-alias passes through as-is.
let resolved = self.resolve_alias(cmd);
let cmd = resolved.as_deref().unwrap_or(cmd);
+ if is_spacemouse_command(cmd) {
+ return self.run_action(cmd);
+ }
// A drafting aid only flips a flag, so it must not disturb whatever is
// already running: pressing F8 partway through a LINE means "constrain
// the rest of this line", not "abandon it". Everything below tears the
@@ -270,9 +273,22 @@ impl OpenCADStudio {
/// vanish. Nothing here starts a command, opens a document or reads geometry,
/// so there is nothing for the teardown to protect. (#677)
pub fn is_transparent(cmd: &str) -> bool {
+ is_spacemouse_command(cmd)
+ || matches!(
+ cmd,
+ "ORTHO" | "GRID" | "SNAP" | "POLAR" | "OSNAP" | "DSETTINGS"
+ )
+}
+
+fn is_spacemouse_command(cmd: &str) -> bool {
matches!(
cmd,
- "ORTHO" | "GRID" | "SNAP" | "POLAR" | "OSNAP" | "DSETTINGS"
+ "SPACEMOUSE"
+ | "SPACEMOUSEPAUSE"
+ | "SPACEMOUSEPAN"
+ | "SPACEMOUSEPANZOOM"
+ | "SPACEMOUSEAUTO"
+ | "SPACEMOUSE3D"
)
}
@@ -281,7 +297,7 @@ pub fn is_transparent(cmd: &str) -> bool {
/// source of truth: the dispatch gate refuses everything else, and the ribbon
/// dims the tools this rejects.
pub fn start_allowed(cmd: &str) -> bool {
- matches!(
+ is_spacemouse_command(cmd) || matches!(
cmd,
"NEW"
| "OPEN"
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 157b4312f..acd4fcdba 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -18,6 +18,7 @@ pub(crate) mod helpers;
mod history;
mod layers;
mod model_ops;
+mod navigation;
mod mtext_editor;
#[cfg(not(target_arch = "wasm32"))]
pub mod plugin_host;
@@ -540,6 +541,14 @@ pub(super) struct OpenCADStudio {
dyn_input: bool,
/// Currently visible page in the application Options dialog.
options_tab: crate::ui::window::options::OptionsTab,
+ spacemouse: crate::input::spacemouse::Service,
+ spacemouse_preferences: crate::input::spacemouse::Preferences,
+ spacemouse_paused: bool,
+ spacemouse_focused: bool,
+ spacemouse_details: bool,
+ spacemouse_was_moving: bool,
+ spacemouse_pivot: Option<(crate::input::spacemouse::Target, glam::DVec3)>,
+ spacemouse_selection: navigation::SelectionCache,
/// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single).
pub texteditmode: bool,
/// QDIM extension-origin priority: 0 = endpoints, 1 = intersections.
@@ -1895,6 +1904,17 @@ pub enum ArrowKey {
#[derive(Debug, Clone)]
pub enum Message {
+ SpaceMouseWake,
+ SpaceMouseFrame(iced::time::Instant),
+ SpaceMouseFocus(iced::window::Id, bool),
+ SpaceMouseEnabled(bool),
+ SpaceMouseMode(crate::input::spacemouse::NavigationMode),
+ SpaceMousePanSpeed(u16),
+ SpaceMousePanReversed(bool),
+ SpaceMousePause,
+ SpaceMousePreferences,
+ SpaceMouseDriverSettings,
+ SpaceMouseDetails,
ControlRequest(control::Envelope),
PollWebControl,
ControlStep(String, Box),
@@ -3718,6 +3738,18 @@ impl OpenCADStudio {
show_grid: false,
dyn_input: true,
options_tab: crate::ui::window::options::OptionsTab::General,
+ spacemouse: {
+ let service = crate::input::spacemouse::Service::default();
+ service.set_actions(navigation::actions());
+ service
+ },
+ spacemouse_preferences: crate::input::spacemouse::Preferences::default(),
+ spacemouse_paused: false,
+ spacemouse_focused: false,
+ spacemouse_details: false,
+ spacemouse_was_moving: false,
+ spacemouse_pivot: None,
+ spacemouse_selection: navigation::SelectionCache::default(),
texteditmode: false,
quick_dimension_snap_priority: 0,
dimension_continue_mode: 1,
diff --git a/src/app/navigation.rs b/src/app/navigation.rs
new file mode 100644
index 000000000..5a9575230
--- /dev/null
+++ b/src/app/navigation.rs
@@ -0,0 +1,699 @@
+//! Application policy for device navigation. All scene writes run on the UI
+//! thread, preserving active commands and the scene's viewport locks.
+use super::{Message, ModalKind, OpenCADStudio};
+use crate::input::spacemouse::{Action, NavigationMode, Target, View};
+use crate::scene::view::camera::Projection;
+use iced::Task;
+
+#[derive(Default)]
+pub(super) struct SelectionCache(
+ std::cell::RefCell)>>,
+);
+impl SelectionCache {
+ fn bounds(
+ &self,
+ scene: &crate::scene::Scene,
+ target: &Target,
+ ) -> Option<(glam::DVec3, glam::DVec3)> {
+ let mut cache = self.0.borrow_mut();
+ if let Some((stamp, geometry, selection, bounds)) = cache.as_ref() {
+ if stamp.same_context(target)
+ && *geometry == scene.geometry_epoch
+ && *selection == scene.selection_generation
+ {
+ return *bounds;
+ }
+ }
+ let bounds = scene.navigation_selection_bounds();
+ *cache = Some((
+ target.clone(),
+ scene.geometry_epoch,
+ scene.selection_generation,
+ bounds,
+ ));
+ bounds
+ }
+}
+
+inventory::submit!(crate::command::CommandRegistration {
+ names: &[
+ "SPACEMOUSE",
+ "SPACEMOUSEPAUSE",
+ "SPACEMOUSEPAN",
+ "SPACEMOUSEPANZOOM",
+ "SPACEMOUSEAUTO",
+ "SPACEMOUSE3D"
+ ],
+});
+
+impl OpenCADStudio {
+ fn navigation_target(&self) -> Target {
+ let tab = &self.tabs[self.active_tab];
+ Target {
+ tab: tab.id,
+ layout: format!("{}:{:?}", tab.scene.current_layout, tab.active_block_edit),
+ viewport: tab.scene.active_viewport.map(|h| h.value()),
+ tile: tab.scene.active_model_tile.get(),
+ generation: tab.scene.camera_generation,
+ }
+ }
+ pub(super) fn spacemouse_sheet(&self) -> bool {
+ let scene = &self.tabs[self.active_tab].scene;
+ scene.current_layout != "Model" && scene.active_viewport.is_none()
+ }
+ pub(super) fn spacemouse_label(&self) -> String {
+ if !self.spacemouse_preferences.enabled {
+ return crate::t!("Disabled").into_owned();
+ }
+ if self.spacemouse_paused {
+ return crate::t!("Paused").into_owned();
+ }
+ if self.tabs[self.active_tab].scene.navigation_locked() {
+ return crate::t!("Viewport locked").into_owned();
+ }
+ if self.spacemouse.status() != crate::input::spacemouse::Status::Ready {
+ return crate::t!(self.spacemouse.status().label()).into_owned();
+ }
+ let mode = self.spacemouse_preferences.mode;
+ let effective = if self.spacemouse_sheet() && mode.allows_zoom() {
+ NavigationMode::PanZoom
+ } else if mode == NavigationMode::Auto {
+ NavigationMode::Full3D
+ } else {
+ mode
+ };
+ crate::t!(effective.label()).into_owned()
+ }
+ pub(super) fn sync_spacemouse(&self) {
+ let tab = &self.tabs[self.active_tab];
+ let scene = &tab.scene;
+ let available = self.spacemouse_preferences.enabled
+ && !tab.is_start
+ && self.opening.is_none()
+ && self.spacemouse.status() == crate::input::spacemouse::Status::Ready;
+ if !available {
+ self.spacemouse.sync(None, false);
+ return;
+ }
+ // The driver's settings dialog must still be able to query this view
+ // after it takes keyboard focus. Gate input separately from its metadata.
+ let allowed = self.spacemouse_focused && self.active_modal.is_none();
+ let camera = scene.navigation_camera();
+ let (w, h) = scene.selection.borrow().vp_size;
+ let bounds = scene.active_model_tile_bounds(w, h);
+ let aspect = scene
+ .active_viewport
+ .and_then(|handle| match scene.document.get_entity(handle) {
+ Some(acadrust::EntityType::Viewport(vp)) => Some(vp.width / vp.height.max(1e-6)),
+ _ => None,
+ })
+ .unwrap_or((bounds.width / bounds.height.max(1.)).max(0.01) as f64);
+ let center = camera.target;
+ let span = glam::DVec3::splat(camera.distance.max(1.) as f64);
+ let (min, max) = camera
+ .model_bounds
+ .or_else(|| {
+ scene
+ .model_space_extents()
+ .map(|(min, max)| (min.as_dvec3(), max.as_dvec3()))
+ })
+ .unwrap_or((center - span, center + span));
+ let target = self.navigation_target();
+ let selected = self.spacemouse_selection.bounds(scene, &target);
+ let pivot = selected
+ .map(|(min, max)| (min + max) * 0.5)
+ .or_else(|| {
+ self.spacemouse_pivot
+ .as_ref()
+ .filter(|(stamp, _)| stamp.same_context(&target))
+ .map(|(_, point)| *point)
+ })
+ .unwrap_or(center);
+ let selection_bounds =
+ selected.map(|(min, max)| [min.x, min.y, min.z, max.x, max.y, max.z]);
+ self.spacemouse.sync(
+ Some(View {
+ target,
+ eye: camera.eye(),
+ rotation: camera.rotation.as_dquat().normalize(),
+ distance: camera.distance as f64,
+ fov: camera.fov_y as f64,
+ perspective: camera.projection == Projection::Perspective,
+ aspect,
+ model_bounds: [min.x, min.y, min.z, max.x, max.y, max.z],
+ selection_bounds,
+ pointer: tab.last_cursor_world,
+ pivot,
+ pivot_visible: self.spacemouse_pivot.is_some(),
+ rotate: self
+ .spacemouse_preferences
+ .mode
+ .allows_rotation(self.spacemouse_sheet()),
+ zoom: self.spacemouse_preferences.mode.allows_zoom(),
+ motion_enabled: !self.spacemouse_paused && !scene.navigation_locked(),
+ units_to_meters: super::properties::insunits_to_mm(
+ scene.document.header.insertion_units,
+ )
+ .unwrap_or(1000.)
+ / 1000.,
+ pan_speed: self.spacemouse_preferences.pan_speed.clamp(10, 300) as f64 / 100.
+ * if self.spacemouse_preferences.pan_reversed {
+ -1.
+ } else {
+ 1.
+ },
+ }),
+ allowed,
+ );
+ }
+ pub(super) fn on_spacemouse_wake(&mut self) -> Task {
+ let (view, commands) = self.spacemouse.drain();
+ if !self.spacemouse_focused
+ || self.active_modal.is_some()
+ || !self.spacemouse_preferences.enabled
+ || self.tabs[self.active_tab].is_start
+ {
+ self.spacemouse_was_moving = false;
+ self.spacemouse_pivot = None;
+ return Task::none();
+ }
+ let target = self.navigation_target();
+ let mut tasks = Vec::new();
+ let moving = self.spacemouse.moving() && !self.spacemouse_paused;
+ if moving && !self.spacemouse_was_moving {
+ self.tabs[self.active_tab].scene.remember_current_view();
+ self.clear_navigation_hover(self.active_tab);
+ }
+ if !moving && self.spacemouse_was_moving {
+ self.arm_hover_after_navigation(self.active_tab);
+ }
+ self.spacemouse_was_moving = moving;
+ if !moving {
+ self.spacemouse_pivot = None;
+ }
+ if let Some(view) = view.filter(|view| view.target == target && !self.spacemouse_paused) {
+ let i = self.active_tab;
+ let mut camera = self.tabs[i].scene.navigation_camera();
+ if view.rotate {
+ camera.rotation = view.rotation.as_quat();
+ camera.sync_yaw_pitch();
+ }
+ if view.zoom {
+ camera.distance = view.distance as f32;
+ }
+ // Use the same f32 basis/distance as Camera::eye so a callback
+ // that only updates the pivot cannot introduce camera drift.
+ camera.target =
+ view.eye - (camera.rotation * glam::Vec3::Z).as_dvec3() * camera.distance as f64;
+ let started = iced::time::Instant::now();
+ if self.tabs[i].scene.apply_navigation_camera(camera) {
+ self.spacemouse
+ .acknowledge(&view.target, &self.navigation_target());
+ self.spacemouse_pivot = (view.pivot_visible && moving)
+ .then_some((self.navigation_target(), view.pivot));
+ let (w, h) = self.tabs[i].scene.selection.borrow().vp_size;
+ self.reproject_box_anchor(i, w, h);
+ // The pointer may be stationary while the left hand navigates.
+ // Refresh the active command's snap/preview against the new
+ // camera, using the existing pointer path without a click.
+ let cursor = {
+ let selection = self.tabs[i].scene.selection.borrow();
+ (!selection.left_down
+ && !selection.middle_down
+ && self.tabs[i].active_cmd.is_some())
+ .then_some(selection.last_move_pos)
+ .flatten()
+ };
+ if let Some(cursor) = cursor {
+ tasks.push(self.on_viewport_move(cursor));
+ }
+ self.tabs[i]
+ .scene
+ .record_nav_perf(crate::scene::NavPerfOp::Pan, started);
+ }
+ }
+ for (stamp, command) in commands {
+ if stamp.same_context(&self.navigation_target()) && self.active_modal.is_none() {
+ tasks.push(self.run_action(&command));
+ }
+ }
+ Task::batch(tasks)
+ }
+
+ pub(super) fn spacemouse_pivot_overlay(&self) -> Option> {
+ let (target, point) = self.spacemouse_pivot.as_ref()?;
+ if target != &self.navigation_target() || self.active_modal.is_some() {
+ return None;
+ }
+ let scene = &self.tabs[self.active_tab].scene;
+ let (w, h) = scene.selection.borrow().vp_size;
+ let rect = scene
+ .active_viewport
+ .and_then(|handle| scene.viewport_screen_rect(handle, (w, h)))
+ .unwrap_or_else(|| scene.active_model_tile_bounds(w, h));
+ let p = scene.navigation_camera().project(*point, rect)?;
+ if p.x < 0. || p.y < 0. || p.x > rect.width || p.y > rect.height {
+ return None;
+ }
+ let marker = iced::widget::container(iced::widget::Space::new())
+ .width(10)
+ .height(10)
+ .style(|theme: &iced::Theme| iced::widget::container::Style {
+ border: iced::Border {
+ color: theme.palette().primary.base.color,
+ width: 2.,
+ radius: 5.0.into(),
+ },
+ ..Default::default()
+ });
+ Some(
+ iced::widget::pin(marker)
+ .position(iced::Point::new(rect.x + p.x - 5., rect.y + p.y - 5.))
+ .into(),
+ )
+ }
+ pub(super) fn open_spacemouse_preferences(&mut self) {
+ self.options_tab = crate::ui::window::options::OptionsTab::UserPreferences;
+ self.active_modal = Some(ModalKind::Options);
+ }
+ pub(super) fn open_spacemouse_driver_settings(&self) -> Task {
+ #[cfg(windows)]
+ {
+ if let Some(program_files) =
+ std::env::var_os("ProgramW6432").or_else(|| std::env::var_os("ProgramFiles"))
+ {
+ let path = std::path::PathBuf::from(program_files)
+ .join("3Dconnexion/3DxWare/3DxWinCore/3DxSmartUi.exe");
+ if path.is_file() {
+ return Task::perform(
+ async move {
+ let _ = std::process::Command::new(path).spawn();
+ },
+ |_| Message::SpaceMouseWake,
+ );
+ }
+ }
+ }
+ crate::sys::open_url("https://3dconnexion.com/drivers/", self.main_window)
+ }
+}
+
+pub(super) fn actions() -> Vec {
+ use crate::modules::{IconKind, ModuleEvent, RibbonItem, ToolDef};
+ use std::collections::BTreeMap;
+ let mut names = crate::command::all_registered_command_names();
+ names.extend([
+ "UNDO",
+ "REDO",
+ "CANCEL",
+ "QSAVE",
+ "OPEN",
+ "ZOOM",
+ "PLAN",
+ "SPACEMOUSEFIT",
+ "SPACEMOUSETOP",
+ ]);
+ names.sort_unstable();
+ names.dedup();
+ let mut actions: BTreeMap = names
+ .into_iter()
+ .map(|name| {
+ let label = match name {
+ "SPACEMOUSE" => "SpaceMouse preferences",
+ "SPACEMOUSEPAUSE" => "Pause / resume SpaceMouse",
+ "SPACEMOUSEPAN" => "SpaceMouse: pan only",
+ "SPACEMOUSEPANZOOM" => "SpaceMouse: pan and zoom",
+ "SPACEMOUSEAUTO" => "SpaceMouse: follow context",
+ "SPACEMOUSE3D" => "SpaceMouse: 3D navigation",
+ "SPACEMOUSEFIT" => "Fit view",
+ "SPACEMOUSETOP" => "Top view",
+ _ => name,
+ };
+ (
+ name.into(),
+ Action {
+ id: name.into(),
+ label: crate::t!(label).into_owned(),
+ description: format!("{} ({name})", crate::t!(label)),
+ icon: name
+ .starts_with("SPACEMOUSE")
+ .then_some(crate::ui::window::options::spacemouse::ICON),
+ },
+ )
+ })
+ .collect();
+ fn describe(
+ actions: &mut BTreeMap,
+ command: &str,
+ label: &str,
+ icon: IconKind,
+ ) {
+ if let Some(action) = actions.get_mut(command) {
+ action.label = crate::t!(label).into_owned();
+ action.description = format!("{} ({command})", action.label);
+ action.icon = match icon {
+ IconKind::Svg(bytes) => Some(bytes),
+ _ => None,
+ };
+ }
+ }
+ fn tool(actions: &mut BTreeMap, tool: &ToolDef) {
+ if let ModuleEvent::Command(command) = &tool.event {
+ describe(actions, command, tool.label, tool.icon);
+ }
+ }
+ for module in crate::modules::registry::all_modules() {
+ for group in module.ribbon_groups() {
+ for item in &group.tools {
+ match item {
+ RibbonItem::Tool(t) | RibbonItem::LabeledTool(t) | RibbonItem::LargeTool(t) => {
+ tool(&mut actions, t)
+ }
+ RibbonItem::Dropdown { items, .. }
+ | RibbonItem::LabeledDropdown { items, .. }
+ | RibbonItem::LargeDropdown { items, .. } => {
+ for (label, command, icon) in items {
+ describe(&mut actions, command, label, *icon);
+ }
+ }
+ RibbonItem::ToolGrid { columns }
+ | RibbonItem::StyleComboGroup { rows: columns, .. } => {
+ for t in columns.iter().flatten() {
+ tool(&mut actions, t);
+ }
+ }
+ RibbonItem::LayerComboGroup { row2, row3 } => {
+ for t in row2.iter().chain(row3) {
+ tool(&mut actions, t);
+ }
+ }
+ RibbonItem::PropertiesGroup { match_prop } => tool(&mut actions, match_prop),
+ }
+ }
+ }
+ }
+ actions.into_values().collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use glam::DVec3;
+
+ fn app() -> OpenCADStudio {
+ let mut app = OpenCADStudio::new_for_test();
+ app.automation_op(r#"{"op":"new"}"#);
+ app.tabs[app.active_tab]
+ .scene
+ .selection
+ .borrow_mut()
+ .vp_size = (800., 600.);
+ app.spacemouse_focused = true;
+ app.spacemouse.test_connect();
+ app.sync_spacemouse();
+ app
+ }
+
+ #[test]
+ fn spacemouse_navigation_and_buttons_preserve_a_live_polyline() {
+ let mut app = app();
+ let _ = app.dispatch_command("PLINE");
+ for point in [
+ DVec3::ZERO,
+ DVec3::new(10., 0., 0.),
+ DVec3::new(10., 10., 0.),
+ ] {
+ let _ = app.feed_command(crate::command::StepInput::Point(point));
+ }
+ let i = app.active_tab;
+ let before = app.tabs[i].scene.navigation_camera();
+ let epoch = app.tabs[i].scene.geometry_epoch;
+ app.sync_spacemouse();
+ app.spacemouse.test_propose(|v| v.eye.x += 3.);
+ let _ = app.on_spacemouse_wake();
+ assert_eq!(
+ app.tabs[i].scene.navigation_camera().target.x,
+ before.target.x + 3.
+ );
+ assert_eq!(app.tabs[i].scene.geometry_epoch, epoch);
+ assert!(app.tabs[i].active_cmd.is_some());
+ let _ = app.run_action("UNDO");
+ assert!(app.tabs[i].active_cmd.is_some());
+ // Finish after Undo: the last point was removed, leaving one segment.
+ let _ = app.feed_command(crate::command::StepInput::Enter);
+ let polyline = app.tabs[i]
+ .scene
+ .document
+ .entities()
+ .find_map(|e| {
+ if let acadrust::EntityType::LwPolyline(p) = e {
+ Some(p)
+ } else {
+ None
+ }
+ })
+ .expect("finished polyline");
+ assert_eq!(polyline.vertices.len(), 2);
+ }
+
+ #[test]
+ fn spacemouse_refreshes_a_live_command_at_a_stationary_pointer() {
+ let mut app = app();
+ app.snapper.snap_enabled = false;
+ app.snapper.grid_snap_on = false;
+ app.snapper.otrack_enabled = false;
+ app.ortho_mode = false;
+ app.polar_mode = false;
+ let _ = app.dispatch_command("LINE");
+ let _ = app.feed_command(crate::command::StepInput::Point(DVec3::ZERO));
+ let _ = app.on_viewport_move(iced::Point::new(480., 300.));
+ let i = app.active_tab;
+ let before = app.tabs[i].last_cursor_world;
+ let count = app.tabs[i].scene.document.entities().count();
+ app.sync_spacemouse();
+ app.spacemouse.test_propose(|v| v.eye.x += 3.);
+ let _ = app.on_spacemouse_wake();
+ assert!((app.tabs[i].last_cursor_world.x - before.x - 3.).abs() < 1e-4);
+ assert!(app.tabs[i].active_cmd.is_some());
+ assert_eq!(app.tabs[i].scene.document.entities().count(), count);
+ }
+
+ #[test]
+ fn spacemouse_stale_camera_modal_and_pause_block_pending_motion() {
+ for guard in ["camera", "modal", "pause", "focus"] {
+ let mut app = app();
+ let before = app.tabs[app.active_tab].scene.navigation_camera().target;
+ app.spacemouse.test_propose(|v| v.eye.x += 30.);
+ match guard {
+ "camera" => app.tabs[app.active_tab].scene.camera_generation += 1,
+ "modal" => app.active_modal = Some(ModalKind::Options),
+ "pause" => app.spacemouse_paused = true,
+ _ => app.spacemouse_focused = false,
+ }
+ let _ = app.on_spacemouse_wake();
+ assert_eq!(
+ app.tabs[app.active_tab].scene.navigation_camera().target,
+ before,
+ "{guard}"
+ );
+ }
+ }
+
+ #[test]
+ fn spacemouse_settings_dialog_retains_view_metadata_without_accepting_motion() {
+ for modal in [false, true] {
+ let mut app = app();
+ app.spacemouse_preferences.mode = NavigationMode::Full3D;
+ app.sync_spacemouse();
+ let before = app.tabs[app.active_tab].scene.navigation_camera();
+ if modal {
+ app.active_modal = Some(ModalKind::Options);
+ } else {
+ app.spacemouse_focused = false;
+ }
+ app.sync_spacemouse();
+ let snapshot = app
+ .spacemouse
+ .test_view()
+ .expect("driver can describe the inactive view");
+ assert!(snapshot.rotate);
+ assert_eq!(snapshot.distance, before.distance as f64);
+ app.spacemouse.test_propose(|v| v.eye.x += 10.);
+ let _ = app.on_spacemouse_wake();
+ assert_eq!(
+ app.tabs[app.active_tab].scene.navigation_camera().target,
+ before.target
+ );
+ }
+ }
+
+ #[test]
+ fn spacemouse_floating_camera_round_trip_and_lock() {
+ let mut app = app();
+ let scene = &mut app.tabs[app.active_tab].scene;
+ let paper_camera = scene.camera.borrow().clone();
+ let mut vp = acadrust::entities::Viewport::default();
+ vp.width = 200.;
+ vp.height = 100.;
+ vp.view_height = 50.;
+ let handle = scene.add_entity(acadrust::EntityType::Viewport(vp));
+ scene.active_viewport = Some(handle);
+ let mut camera = scene.navigation_camera();
+ camera.target = DVec3::new(120., 30., 8.);
+ camera.rotation = glam::Quat::from_euler(glam::EulerRot::XYZ, 0.7, 0.3, 0.2);
+ camera.distance = 80.;
+ assert!(scene.apply_navigation_camera(camera.clone()));
+ let round_trip = scene.navigation_camera();
+ assert!(round_trip.target.distance(camera.target) < 1e-6);
+ assert!(round_trip.rotation.abs_diff_eq(camera.rotation, 1e-5));
+ assert!((round_trip.distance - camera.distance).abs() < 1e-4);
+ assert_eq!(scene.camera.borrow().target, paper_camera.target);
+ assert_eq!(scene.camera.borrow().distance, paper_camera.distance);
+ if let Some(acadrust::EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) {
+ vp.status.locked = true;
+ }
+ camera.target.x += 100.;
+ assert!(!scene.apply_navigation_camera(camera));
+ assert_eq!(scene.navigation_camera().target, round_trip.target);
+ }
+
+ #[test]
+ fn spacemouse_push_and_tilt_move_the_drawing_in_screen_space_continuously() {
+ for mode in [NavigationMode::PanOnly, NavigationMode::PanZoom] {
+ for projection in [Projection::Orthographic, Projection::Perspective] {
+ for reversed in [false, true] {
+ for (axes, direction) in [
+ ([350, 0, 0, 0, 0, 0], glam::Vec2::X),
+ ([0, 0, 0, 0, -350, 0], glam::Vec2::X),
+ ([-350, 0, 0, 0, 0, 0], -glam::Vec2::X),
+ ([0, 0, 0, 0, 350, 0], -glam::Vec2::X),
+ ([0, -350, 0, 0, 0, 0], -glam::Vec2::Y),
+ ([0, 0, 0, -350, 0, 0], -glam::Vec2::Y),
+ ([0, 350, 0, 0, 0, 0], glam::Vec2::Y),
+ ([0, 0, 0, 350, 0, 0], glam::Vec2::Y),
+ ] {
+ let mut app = app();
+ app.spacemouse_preferences.mode = mode;
+ app.spacemouse_preferences.pan_reversed = reversed;
+ let i = app.active_tab;
+ {
+ let mut camera = app.tabs[i].scene.camera.borrow_mut();
+ camera.rotation =
+ glam::Quat::from_euler(glam::EulerRot::XYZ, 0.7, 0.4, 0.2);
+ camera.projection = projection;
+ }
+ app.sync_spacemouse();
+ let before = app.tabs[i].scene.navigation_camera();
+ let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
+ let point = before.target;
+ let screen = before.project(point, bounds).unwrap();
+ let direction = direction * if reversed { -1. } else { 1. };
+ let mut last = glam::Vec2::new(screen.x, screen.y);
+ for frame in 0..20 {
+ app.spacemouse.test_pan_report(axes);
+ app.spacemouse.frame(frame as f64 * 16.);
+ let _ = app.on_spacemouse_wake();
+ app.sync_spacemouse();
+ let camera = app.tabs[i].scene.navigation_camera();
+ let moved = camera.project(point, bounds).unwrap();
+ let moved = glam::Vec2::new(moved.x, moved.y);
+ let displacement = moved - last;
+ assert!(displacement.dot(direction) > 0., "{mode:?}: {axes:?}");
+ assert!(displacement.perp_dot(direction).abs() < 0.001);
+ assert_eq!(camera.rotation, before.rotation);
+ assert_eq!(camera.distance, before.distance);
+ last = moved;
+ }
+ let stopped = app.tabs[i].scene.navigation_camera().target;
+ app.spacemouse.test_pan_report([0, 0, 0, 0, 0, 350]);
+ app.spacemouse.frame(400.);
+ let _ = app.on_spacemouse_wake();
+ assert_eq!(app.tabs[i].scene.navigation_camera().target, stopped);
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn spacemouse_panzoom_lift_and_pressure_zoom_about_the_center_and_release_stops() {
+ for projection in [Projection::Orthographic, Projection::Perspective] {
+ for mode in [NavigationMode::PanOnly, NavigationMode::PanZoom] {
+ for pressure in [-350, 350] {
+ let mut app = app();
+ app.spacemouse_preferences.mode = mode;
+ let i = app.active_tab;
+ {
+ let mut camera = app.tabs[i].scene.camera.borrow_mut();
+ camera.rotation =
+ glam::Quat::from_euler(glam::EulerRot::XYZ, 0.7, 0.4, 0.2);
+ camera.projection = projection;
+ }
+ app.sync_spacemouse();
+ let before = app.tabs[i].scene.navigation_camera();
+ let mut distance = before.distance;
+ for frame in 0..20 {
+ app.spacemouse.test_pan_report([0, 0, pressure, 0, 0, 0]);
+ app.spacemouse.frame(frame as f64 * 16.);
+ let _ = app.on_spacemouse_wake();
+ app.sync_spacemouse();
+ let camera = app.tabs[i].scene.navigation_camera();
+ assert!(camera.target.distance(before.target) < 1e-4);
+ assert_eq!(camera.rotation, before.rotation);
+ if mode == NavigationMode::PanOnly {
+ assert_eq!(camera.distance, distance);
+ } else {
+ assert!((camera.distance - distance) * pressure as f32 > 0.);
+ }
+ distance = camera.distance;
+ }
+ app.spacemouse.test_pan_report([0; 6]);
+ app.spacemouse.frame(400.);
+ let _ = app.on_spacemouse_wake();
+ assert_eq!(app.tabs[i].scene.navigation_camera().distance, distance);
+ assert!(!app.spacemouse.moving());
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn spacemouse_preferences_round_trip_and_old_settings_default() {
+ let old: super::super::settings::UserSettings = serde_json::from_str("{}").unwrap();
+ assert_eq!(
+ old.spacemouse,
+ crate::input::spacemouse::Preferences::default()
+ );
+ let mut app = app();
+ app.spacemouse_preferences.mode = NavigationMode::PanOnly;
+ app.spacemouse_preferences.pan_speed = 170;
+ app.spacemouse_preferences.pan_reversed = true;
+ let encoded = serde_json::to_string(&app.current_settings()).unwrap();
+ let restored: super::super::settings::UserSettings =
+ serde_json::from_str(&encoded).unwrap();
+ app.spacemouse_preferences.mode = NavigationMode::Auto;
+ app.apply_settings(&restored);
+ assert_eq!(app.spacemouse_preferences.mode, NavigationMode::PanOnly);
+ assert_eq!(app.spacemouse_preferences.pan_speed, 170);
+ assert!(app.spacemouse_preferences.pan_reversed);
+ }
+
+ #[test]
+ fn spacemouse_action_catalog_has_stable_unique_ids_and_ui_icons() {
+ let actions = actions();
+ let ids: std::collections::HashSet<_> = actions.iter().map(|a| &a.id).collect();
+ assert_eq!(ids.len(), actions.len());
+ for id in [
+ "LINE",
+ "UNDO",
+ "SPACEMOUSEPAN",
+ "SPACEMOUSEPAUSE",
+ "SPACEMOUSEFIT",
+ ] {
+ assert!(actions.iter().any(|a| a.id == id && !a.label.is_empty()));
+ }
+ assert!(actions.iter().any(|a| a.id == "LINE" && a.icon.is_some()));
+ assert!(crate::app::commands::is_transparent("SPACEMOUSEPAN"));
+ assert!(crate::app::commands::start_allowed("SPACEMOUSE"));
+ }
+}
diff --git a/src/app/properties.rs b/src/app/properties.rs
index a942c5ca6..415a10483 100644
--- a/src/app/properties.rs
+++ b/src/app/properties.rs
@@ -3806,7 +3806,7 @@ fn format_unit_factor(factor: f64) -> String {
}
/// Convert INSUNITS (DXF group 70) to millimetres.
-fn insunits_to_mm(code: i16) -> Option {
+pub(super) fn insunits_to_mm(code: i16) -> Option {
Some(match code {
1 => 25.4, // Inches
2 => 304.8, // Feet
diff --git a/src/app/settings.rs b/src/app/settings.rs
index 9c2757542..b19d6daab 100644
--- a/src/app/settings.rs
+++ b/src/app/settings.rs
@@ -289,6 +289,7 @@ pub const DEFAULT_GRIP_OBJECT_LIMIT: i32 = 100;
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct UserSettings {
+ pub spacemouse: crate::input::spacemouse::Preferences,
pub dyn_input: bool,
pub polar: bool,
pub polar_increment_deg: f32,
@@ -497,6 +498,7 @@ pub fn clamp_clipromptlines(v: i32) -> i32 {
impl Default for UserSettings {
fn default() -> Self {
Self {
+ spacemouse: crate::input::spacemouse::Preferences::default(),
dyn_input: true,
polar: false,
polar_increment_deg: 45.0,
diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs
index 3682bbbe3..8df20b273 100644
--- a/src/app/shortcuts.rs
+++ b/src/app/shortcuts.rs
@@ -145,6 +145,8 @@ fn is_named_key(key: &str) -> bool {
/// the command dispatcher — valid shortcut commands that never appear in the
/// command registry. Used to validate the shortcut editor's command column.
pub(super) const INPUT_ACTIONS: &[&str] = &[
+ "SPACEMOUSEFIT",
+ "SPACEMOUSETOP",
"FINALIZE",
"COMMANDSPACE",
"CANCEL",
@@ -270,7 +272,36 @@ impl OpenCADStudio {
let Some(action) = action.cloned() else {
return Task::none();
};
- let message = match action.as_str() {
+ self.run_action(&action)
+ }
+
+ /// Shared by keyboard bindings and exported device actions. In particular,
+ /// Undo retains its command-local behavior during PLINE and SPLINE.
+ pub(super) fn run_action(&mut self, action: &str) -> Task {
+ let message = match action {
+ "SPACEMOUSEFIT" => {
+ let i = self.active_tab;
+ self.clear_navigation_hover(i);
+ self.tabs[i].scene.remember_current_view();
+ self.tabs[i].scene.fit_all();
+ self.arm_hover_after_navigation(i);
+ return Task::none();
+ }
+ "SPACEMOUSETOP" => Message::ViewCubeHome,
+ "SPACEMOUSE" => Message::SpaceMousePreferences,
+ "SPACEMOUSEPAUSE" => Message::SpaceMousePause,
+ "SPACEMOUSEPAN" => {
+ Message::SpaceMouseMode(crate::input::spacemouse::NavigationMode::PanOnly)
+ }
+ "SPACEMOUSEPANZOOM" => {
+ Message::SpaceMouseMode(crate::input::spacemouse::NavigationMode::PanZoom)
+ }
+ "SPACEMOUSEAUTO" => {
+ Message::SpaceMouseMode(crate::input::spacemouse::NavigationMode::Auto)
+ }
+ "SPACEMOUSE3D" => {
+ Message::SpaceMouseMode(crate::input::spacemouse::NavigationMode::Full3D)
+ }
"FINALIZE" => Message::CommandFinalize,
"COMMANDSPACE" => Message::CommandSpace,
"CANCEL" => Message::CommandEscape,
diff --git a/src/app/update/file.rs b/src/app/update/file.rs
index e31f8c07f..3454f0e59 100644
--- a/src/app/update/file.rs
+++ b/src/app/update/file.rs
@@ -382,6 +382,7 @@ impl OpenCADStudio {
/// Snapshot the persisted UI preferences from live state.
pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings {
crate::app::settings::UserSettings {
+ spacemouse: self.spacemouse_preferences,
dyn_input: self.dyn_input,
polar: self.polar_mode,
polar_increment_deg: self.polar_increment_deg,
@@ -457,6 +458,7 @@ impl OpenCADStudio {
self.cursor_size = s.cursor_size.clamp(1, 100);
self.pick_box = s.pick_box.clamp(0, 50);
self.options_tab = s.options_tab;
+ self.spacemouse_preferences = s.spacemouse;
// These four drive real features with commands and status-bar pills,
// but they lived only on the app struct: turning the ViewCube off and
// restarting brought it straight back.
diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs
index 0a0607929..c33de52b9 100644
--- a/src/app/update/mod.rs
+++ b/src/app/update/mod.rs
@@ -357,6 +357,7 @@ impl OpenCADStudio {
self.block_palette.placing = None;
}
self.control_settle();
+ self.sync_spacemouse();
task
}
@@ -372,6 +373,48 @@ impl OpenCADStudio {
fn update_inner(&mut self, msg: Message) -> Task {
match msg {
+ Message::SpaceMouseWake => self.on_spacemouse_wake(),
+ Message::SpaceMouseFrame(time) => {
+ self.spacemouse.frame(
+ time.saturating_duration_since(self.start).as_secs_f64() * 1000.,
+ );
+ Task::none()
+ }
+ Message::SpaceMouseFocus(id, focused) => {
+ if Some(id) == self.main_window {
+ self.spacemouse_focused = focused;
+ }
+ Task::none()
+ }
+ Message::SpaceMouseEnabled(enabled) => {
+ self.spacemouse_preferences.enabled = enabled;
+ Task::none()
+ }
+ Message::SpaceMouseMode(mode) => {
+ self.spacemouse_preferences.mode = mode;
+ Task::none()
+ }
+ Message::SpaceMousePanSpeed(speed) => {
+ self.spacemouse_preferences.pan_speed = speed.clamp(10, 300);
+ Task::none()
+ }
+ Message::SpaceMousePanReversed(reversed) => {
+ self.spacemouse_preferences.pan_reversed = reversed;
+ Task::none()
+ }
+ Message::SpaceMousePause => {
+ self.spacemouse_paused = !self.spacemouse_paused;
+ Task::none()
+ }
+ Message::SpaceMousePreferences => {
+ self.open_spacemouse_preferences();
+ Task::none()
+ }
+ Message::SpaceMouseDriverSettings => self.open_spacemouse_driver_settings(),
+ Message::SpaceMouseDetails => {
+ self.spacemouse_details = !self.spacemouse_details;
+ Task::none()
+ }
Message::ControlRequest(envelope) => {
let (response, task) = self.control_request(envelope.request);
envelope.reply.send(response);
diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs
index 20b72ab1d..d44a92a34 100644
--- a/src/app/update/viewport.rs
+++ b/src/app/update/viewport.rs
@@ -922,7 +922,7 @@ impl OpenCADStudio {
Task::none()
}
- pub(super) fn on_viewport_move(&mut self, p: Point) -> Task {
+ pub(in crate::app) fn on_viewport_move(&mut self, p: Point) -> Task {
// A ribbon dropdown is open over the viewport. Its backdrop
// cannot swallow cursor motion — in iced 0.14 mouse_area/opaque
// capture only button presses, never CursorMoved — so the move
@@ -5284,7 +5284,7 @@ properties={:.1}ms picked={}",
/// model-space view moves (zoom/pan), so the selection rectangle tracks the
/// drawing instead of staying frozen at its original pixel. No-op when no
/// box is in progress. (#234)
- pub(super) fn reproject_box_anchor(&mut self, i: usize, vw: f32, vh: f32) {
+ pub(in crate::app) fn reproject_box_anchor(&mut self, i: usize, vw: f32, vh: f32) {
let world = self.tabs[i].scene.selection.borrow().box_anchor_world;
let Some(world) = world else { return };
let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs
index 72d0e2dd6..e239808b9 100644
--- a/src/app/view/mod.rs
+++ b/src/app/view/mod.rs
@@ -1202,6 +1202,9 @@ bg={bg_ms:.1}ms n={view_count}"
};
if !thumbnail_capture_clean && !self.layout_settling {
+ if let Some(pivot) = self.spacemouse_pivot_overlay() {
+ viewport_stack = viewport_stack.push(pivot);
+ }
// Per-pane input pane_grid goes ABOVE the crosshair overlay so it
// receives mouse events (the overlay's `Hidden` cursor would otherwise
// starve any layer beneath it). The controls bar is pushed on top of it.
@@ -2203,6 +2206,18 @@ bg={bg_ms:.1}ms n={view_count}"
.map(|s| s.conflicts.len())
.unwrap_or(0),
&self.gpu_status,
+ (self.spacemouse.visible()
+ || self.spacemouse_preferences.mode
+ != crate::input::spacemouse::NavigationMode::Auto)
+ .then(|| {
+ crate::ui::statusbar::spacemouse::view(
+ self.spacemouse_preferences,
+ self.spacemouse.status(),
+ self.spacemouse_paused,
+ self.spacemouse_label(),
+ self.spacemouse_sheet(),
+ )
+ }),
)
})
.width(Fill)
@@ -2662,6 +2677,21 @@ impl OpenCADStudio {
let control = iced::time::every(std::time::Duration::from_millis(50))
.map(|_| Message::PollWebControl);
iced::Subscription::batch([
+ if self.spacemouse.moving() && self.spacemouse_focused && !self.spacemouse_paused {
+ window::frames().map(Message::SpaceMouseFrame)
+ } else {
+ Subscription::none()
+ },
+ self.spacemouse.subscription().map(|_| Message::SpaceMouseWake),
+ event::listen_with(|event, _, id| match event {
+ iced::Event::Window(window::Event::Focused) => {
+ Some(Message::SpaceMouseFocus(id, true))
+ }
+ iced::Event::Window(window::Event::Unfocused) => {
+ Some(Message::SpaceMouseFocus(id, false))
+ }
+ _ => None,
+ }),
control,
frames,
history_tick,
diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs
index 8df1e0324..498ba1dcd 100644
--- a/src/app/view/modal.rs
+++ b/src/app/view/modal.rs
@@ -232,6 +232,10 @@ impl OpenCADStudio {
show_ucs_icon: self.show_ucs_icon,
ucs_icon_at_origin: self.ucs_icon_at_origin,
},
+ crate::ui::window::options::spacemouse::view(
+ self.spacemouse_preferences, self.spacemouse.status(),
+ self.spacemouse_paused, self.spacemouse_details,
+ ),
&self.snap_angle_input,
{
let header = self
diff --git a/src/input/mod.rs b/src/input/mod.rs
new file mode 100644
index 000000000..c1131bdeb
--- /dev/null
+++ b/src/input/mod.rs
@@ -0,0 +1,2 @@
+//! Device integrations. Camera ownership and CAD commands stay in the app.
+pub(crate) mod spacemouse;
diff --git a/src/input/spacemouse/mod.rs b/src/input/spacemouse/mod.rs
new file mode 100644
index 000000000..16a7725eb
--- /dev/null
+++ b/src/input/spacemouse/mod.rs
@@ -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,
+ 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,
+ pending_view: Option,
+ actions: Vec,
+ commands: VecDeque<(Target, String)>,
+ focus: bool,
+ moving: bool,
+ in_transaction: bool,
+ frame_time: Option,
+ reset_motion: bool,
+ driver_settings_changed: bool,
+ lock_horizon: bool,
+ orbit_about_pivot: bool,
+ pan: pan::Pan,
+ status: Option,
+ ever_connected: bool,
+ wake: Option>,
+ #[cfg(windows)]
+ control: Option,
+}
+impl Bridge {
+ fn wake(&mut self) {
+ if let Some(wake) = &mut self.wake {
+ let _ = wake.try_send(());
+ }
+ }
+}
+
+#[derive(Clone, Default)]
+pub struct Service(Arc>);
+impl Hash for Service {
+ fn hash(&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 {
+ 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) {
+ 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, 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, 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);
+ }
+}
diff --git a/src/input/spacemouse/navlib.rs b/src/input/spacemouse/navlib.rs
new file mode 100644
index 000000000..2f57623db
--- /dev/null
+++ b/src/input/spacemouse/navlib.rs
@@ -0,0 +1,1121 @@
+//! Windows NavLib adapter. Loads the user's installed 3DxWare runtime; neither
+//! SDK binaries nor SDK source files are bundled with Open CAD Studio.
+//!
+//! ABI reference: 3DxWare SDK v4 navlib.h, navlib_types.h, siappcmd_types.h.
+//! All callbacks own only a mutex-protected snapshot. NlClose runs before their
+//! storage is released. No mutex is held while calling into the SDK.
+
+use super::*;
+use iced::futures::{channel::mpsc::Sender, Stream};
+use std::ffi::{c_char, c_long, CStr, CString};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::Duration;
+
+type Get = unsafe extern "C" fn(u64, *const c_char, *mut Value) -> c_long;
+type Set = unsafe extern "C" fn(u64, *const c_char, *const Value) -> c_long;
+#[repr(C)]
+struct Accessor {
+ name: *const c_char,
+ get: Option,
+ set: Option,
+ param: u64,
+}
+#[repr(C)]
+struct Options {
+ size: u32,
+ multithreaded: u8,
+ flags: u32,
+}
+#[repr(C)]
+#[derive(Clone, Copy)]
+struct Array {
+ data: *const u8,
+ len: usize,
+}
+#[repr(C)]
+#[derive(Clone, Copy)]
+union Data {
+ boolean: u32,
+ long: c_long,
+ number: f64,
+ point: [f64; 3],
+ bounds: [f64; 6],
+ matrix: [f64; 16],
+ array: Array,
+ ptr: *const Node,
+}
+#[repr(C)]
+#[derive(Clone, Copy)]
+struct Value {
+ kind: i32,
+ data: Data,
+}
+impl Value {
+ fn boolean(v: bool) -> Self {
+ Self {
+ kind: 1,
+ data: Data { boolean: v.into() },
+ }
+ }
+ fn number(v: f64) -> Self {
+ Self {
+ kind: 4,
+ data: Data { number: v },
+ }
+ }
+ fn values(kind: i32, v: &[f64]) -> Self {
+ let mut matrix = [0.; 16];
+ matrix[..v.len()].copy_from_slice(v);
+ Self {
+ kind,
+ data: Data { matrix },
+ }
+ }
+ fn string(v: &CStr) -> Self {
+ Self {
+ kind: 13,
+ data: Data {
+ array: Array {
+ data: v.as_ptr().cast(),
+ len: v.to_bytes().len(),
+ },
+ },
+ }
+ }
+}
+
+type Create =
+ unsafe extern "C" fn(*mut u64, *const c_char, *const Accessor, usize, *const Options) -> c_long;
+type Close = unsafe extern "C" fn(u64) -> c_long;
+type Read = unsafe extern "C" fn(u64, *const c_char, *mut Value) -> c_long;
+type Write = unsafe extern "C" fn(u64, *const c_char, *const Value) -> c_long;
+struct Api {
+ _library: libloading::Library,
+ create: Create,
+ close: Close,
+ read: Read,
+ write: Write,
+}
+impl Api {
+ fn load() -> Result {
+ // Search only the Windows system directory, never a drawing's folder.
+ let library: libloading::Library = unsafe {
+ libloading::os::windows::Library::load_with_flags("TDxNavLib.dll", 0x00000800)
+ }
+ .map_err(|_| "Install or start 3DxWare, then reconnect your SpaceMouse.".to_owned())?
+ .into();
+ unsafe {
+ Ok(Self {
+ create: *library.get(b"NlCreate\0").map_err(|e| e.to_string())?,
+ close: *library.get(b"NlClose\0").map_err(|e| e.to_string())?,
+ read: *library.get(b"NlReadValue\0").map_err(|e| e.to_string())?,
+ write: *library.get(b"NlWriteValue\0").map_err(|e| e.to_string())?,
+ _library: library,
+ })
+ }
+ }
+ fn write(&self, handle: u64, name: &CStr, value: Value) -> c_long {
+ unsafe { (self.write)(handle, name.as_ptr(), &value) }
+ }
+}
+
+const NO_DATA: c_long = 0x80040078_u32 as c_long;
+const INVALID: c_long = 0x80040016_u32 as c_long;
+
+unsafe extern "C" fn get(param: u64, name: *const c_char, out: *mut Value) -> c_long {
+ if name.is_null() || out.is_null() {
+ return INVALID;
+ }
+ let name = unsafe { CStr::from_ptr(name) }.to_bytes();
+ // NavLib asks for the coordinate system during NlCreate, before any
+ // document snapshot or window-focus event is available.
+ let constant = match name {
+ b"coordinateSystem" => Some(DMat4::from_rotation_x(-std::f64::consts::FRAC_PI_2)),
+ b"views.front" => Some(DMat4::from_rotation_x(std::f64::consts::FRAC_PI_2)),
+ _ => None,
+ };
+ if let Some(matrix) = constant {
+ unsafe {
+ *out = Value::values(7, &matrix.to_cols_array());
+ }
+ return 0;
+ }
+ // This Arc's allocation is retained by Connection until NlClose completes.
+ let shared = unsafe { &*(param as *const Mutex) };
+ let Ok(bridge) = shared.lock() else {
+ return NO_DATA;
+ };
+ let Some(v) = &bridge.view else {
+ return NO_DATA;
+ };
+ let value = match name {
+ b"view.affine" => Value::values(7, &v.affine()),
+ b"view.extents" => Value::values(11, &v.extents()),
+ b"view.perspective" => Value::boolean(v.perspective),
+ b"view.rotatable" => Value::boolean(v.rotate),
+ b"view.fov" => Value::number(v.fov),
+ b"view.focusDistance" => Value::number(v.distance),
+ // This SDK property enables its separate target-camera model. OCS's
+ // camera.target is only a representation of a free camera's center;
+ // advertising it here prevents normal continuous camera navigation.
+ // https://forum.3dconnexion.com/viewtopic.php?t=53430
+ b"view.target" => return NO_DATA,
+ b"view.frustum" => {
+ let near = v.distance * 0.001;
+ let h = near * (v.fov * 0.5).tan();
+ Value::values(
+ 12,
+ &[-h * v.aspect, h * v.aspect, -h, h, near, v.distance * 1000.],
+ )
+ }
+ b"model.extents" => Value::values(11, &v.model_bounds),
+ b"model.unitsToMeters" => Value::number(v.units_to_meters),
+ b"selection.empty" => Value::boolean(v.selection_bounds.is_none()),
+ b"selection.extents" => match v.selection_bounds {
+ Some(b) => Value::values(11, &b),
+ None => return NO_DATA,
+ },
+ b"pivot.position" => Value::values(5, &v.pivot.to_array()),
+ b"pivot.user" => Value::boolean(v.selection_bounds.is_some()),
+ b"pivot.visible" => Value::boolean(v.pivot_visible),
+ b"pointer.position" => Value::values(5, &v.pointer.to_array()),
+ _ => return NO_DATA,
+ };
+ unsafe {
+ *out = value;
+ }
+ 0
+}
+
+unsafe extern "C" fn set(param: u64, name: *const c_char, value: *const Value) -> c_long {
+ if name.is_null() || value.is_null() {
+ return INVALID;
+ }
+ let shared = unsafe { &*(param as *const Mutex) };
+ let Ok(mut bridge) = shared.lock() else {
+ return NO_DATA;
+ };
+ let name = unsafe { CStr::from_ptr(name) }.to_bytes();
+ let value = unsafe { &*value };
+ // Profile changes can arrive while the settings window owns focus and
+ // there is no drawing snapshot. Query the SDK later, outside this mutex.
+ if name == b"settings.changed" && value.kind == 2 {
+ bridge.driver_settings_changed = true;
+ log::debug!("SpaceMouse: driver settings revision {}", unsafe {
+ value.data.long
+ });
+ if let Some(wake) = &bridge.control {
+ wake.signal();
+ }
+ return 0;
+ }
+ if name == b"motion" && value.kind == 1 {
+ if bridge.view.as_ref().is_some_and(View::planar) {
+ return 0;
+ }
+ bridge.moving = unsafe { value.data.boolean } != 0;
+ bridge.wake();
+ return 0;
+ }
+ if name == b"transaction" && value.kind == 2 {
+ if bridge.view.as_ref().is_some_and(View::planar) {
+ return 0;
+ }
+ bridge.in_transaction = unsafe { value.data.long } != 0;
+ if !bridge.in_transaction {
+ bridge.wake();
+ }
+ return 0;
+ }
+ if !bridge.focus {
+ return NO_DATA;
+ }
+ if name == b"commands.activeCommand" {
+ if !matches!(value.kind, 8 | 13) {
+ return INVALID;
+ }
+ let array = unsafe { value.data.array };
+ if array.data.is_null() || array.len > 1024 {
+ return INVALID;
+ }
+ let bytes = unsafe { std::slice::from_raw_parts(array.data, array.len) };
+ let Ok(id) = std::str::from_utf8(bytes) else {
+ return INVALID;
+ };
+ let id = id.trim_end_matches('\0');
+ if bridge.commands.len() < 32 && bridge.actions.iter().any(|a| a.id == id) {
+ if let Some(target) = bridge.view.as_ref().map(|v| v.target.clone()) {
+ bridge.commands.push_back((target, id.to_owned()));
+ bridge.wake();
+ }
+ }
+ return 0;
+ }
+ let lock_horizon = bridge.lock_horizon;
+ let orbit_about_pivot = bridge.orbit_about_pivot;
+ let Some(view) = &mut bridge.view else {
+ return NO_DATA;
+ };
+ if !view.motion_enabled {
+ return NO_DATA;
+ }
+ // Planar navigation combines physical pushes and tilts before NavLib turns them
+ // into orbit/zoom camera changes. Buttons still use the SDK connection.
+ if view.planar() {
+ return 0;
+ }
+ let accepted = match (name, value.kind) {
+ (b"view.affine", 7) => {
+ let previous = view.rotation;
+ let accepted = view.set_affine(unsafe { value.data.matrix });
+ if accepted && lock_horizon {
+ let proposed = view.rotation;
+ view.lock_horizon(previous, orbit_about_pivot);
+ if log::log_enabled!(log::Level::Trace) && (proposed * DVec3::Z).z.abs() > 0.99 {
+ log::trace!(
+ "SpaceMouse: pole previous={previous:?} driver={proposed:?} accepted={:?}",
+ view.rotation
+ );
+ }
+ }
+ accepted
+ }
+ (b"view.extents", 11) => view.set_extents(unsafe { value.data.bounds }),
+ (b"pivot.position", 5) => {
+ let p = unsafe { value.data.point };
+ let point = DVec3::new(p[0], p[1], p[2]);
+ if !point.is_finite() {
+ false
+ } else {
+ view.pivot = point;
+ true
+ }
+ }
+ (b"pivot.visible", 1) => {
+ view.pivot_visible = unsafe { value.data.boolean } != 0;
+ true
+ }
+ _ => return NO_DATA,
+ };
+ if !accepted {
+ return INVALID;
+ }
+ bridge.pending_view = bridge.view.clone();
+ if !bridge.in_transaction {
+ bridge.wake();
+ }
+ 0
+}
+
+#[repr(C)]
+struct Node {
+ size: u32,
+ kind: i32,
+ next: *const Node,
+ children: *const Node,
+ id: *const c_char,
+ label: *const c_char,
+ description: *const c_char,
+}
+#[repr(C)]
+#[derive(Clone, Copy)]
+struct ImageData {
+ data: *const u8,
+ size: usize,
+ index: u32,
+}
+#[repr(C)]
+union ImageUnion {
+ image: ImageData,
+ padding: [usize; 4],
+}
+#[repr(C)]
+struct Image {
+ size: u32,
+ kind: i32,
+ id: *const c_char,
+ data: ImageUnion,
+}
+
+/// Heap storage survives the entire connection, including deferred SDK reads.
+struct Commands {
+ _strings: Vec,
+ nodes: Box<[Node]>,
+ _pngs: Vec>,
+ images: Vec,
+}
+impl Commands {
+ fn new(actions: &[Action]) -> Self {
+ let mut strings = vec![
+ CString::new("Default").unwrap(),
+ CString::new("Open CAD Studio").unwrap(),
+ CString::new("").unwrap(),
+ ];
+ let mut nodes = vec![Node {
+ size: std::mem::size_of::() as u32,
+ kind: 0,
+ next: std::ptr::null(),
+ children: std::ptr::null(),
+ id: strings[0].as_ptr(),
+ label: strings[1].as_ptr(),
+ description: strings[2].as_ptr(),
+ }];
+ let mut pngs = Vec::new();
+ let mut images = Vec::new();
+ for action in actions {
+ let start = strings.len();
+ for text in [&action.id, &action.label, &action.description] {
+ strings.push(CString::new(text.replace('\0', "")).unwrap());
+ }
+ nodes.push(Node {
+ size: std::mem::size_of::() as u32,
+ kind: 2,
+ next: std::ptr::null(),
+ children: std::ptr::null(),
+ id: strings[start].as_ptr(),
+ label: strings[start + 1].as_ptr(),
+ description: strings[start + 2].as_ptr(),
+ });
+ if let Some(svg) = action.icon {
+ if let Some(png) = icon_png(svg) {
+ pngs.push(png);
+ let png = pngs.last().unwrap();
+ images.push(Image {
+ size: std::mem::size_of::() as u32,
+ kind: 3,
+ id: strings[start].as_ptr(),
+ data: ImageUnion {
+ image: ImageData {
+ data: png.as_ptr(),
+ size: png.len(),
+ index: 0,
+ },
+ },
+ });
+ }
+ }
+ }
+ let mut nodes = nodes.into_boxed_slice();
+ for i in 1..nodes.len().saturating_sub(1) {
+ nodes[i].next = &nodes[i + 1];
+ }
+ if nodes.len() > 1 {
+ nodes[0].children = &nodes[1];
+ }
+ Self {
+ _strings: strings,
+ nodes,
+ _pngs: pngs,
+ images,
+ }
+ }
+}
+fn icon_png(svg: &[u8]) -> Option> {
+ let tree = resvg::usvg::Tree::from_data(svg, &resvg::usvg::Options::default()).ok()?;
+ let mut pixmap = resvg::tiny_skia::Pixmap::new(32, 32)?;
+ let scale = 32.0 / tree.size().width().max(tree.size().height());
+ resvg::render(
+ &tree,
+ resvg::tiny_skia::Transform::from_scale(scale, scale),
+ &mut pixmap.as_mut(),
+ );
+ pixmap.encode_png().ok()
+}
+
+struct Connection {
+ api: Api,
+ handle: u64,
+ _accessors: Vec,
+ _commands: Commands,
+ _shared: Arc>,
+ policy: Option<(bool, bool, bool, bool)>,
+ target: Option,
+}
+impl Connection {
+ fn open(shared: Arc>) -> Result {
+ let api = Api::load()?;
+ let param = Arc::as_ptr(&shared) as u64;
+ let reads: &[&CStr] = &[
+ c"coordinateSystem",
+ c"view.affine",
+ c"view.extents",
+ c"view.perspective",
+ c"view.rotatable",
+ c"view.fov",
+ c"view.focusDistance",
+ c"view.target",
+ c"view.frustum",
+ c"views.front",
+ c"model.extents",
+ c"model.unitsToMeters",
+ c"selection.empty",
+ c"selection.extents",
+ c"pivot.position",
+ c"pivot.user",
+ c"pivot.visible",
+ c"pointer.position",
+ ];
+ let writes: &[&CStr] = &[
+ c"view.affine",
+ c"view.extents",
+ c"pivot.position",
+ c"pivot.visible",
+ ];
+ let mut accessors: Vec = reads
+ .iter()
+ .map(|name| Accessor {
+ name: name.as_ptr(),
+ get: Some(get),
+ set: writes.contains(name).then_some(set as Set),
+ param,
+ })
+ .collect();
+ for name in [
+ c"commands.activeCommand",
+ c"transaction",
+ c"motion",
+ c"settings.changed",
+ ] {
+ accessors.push(Accessor {
+ name: name.as_ptr(),
+ get: None,
+ set: Some(set),
+ param,
+ });
+ }
+ let options = Options {
+ size: std::mem::size_of::() as u32,
+ multithreaded: 1,
+ flags: 4,
+ };
+ let commands = Commands::new(&shared.lock().unwrap().actions);
+ let mut handle = 0;
+ let result = unsafe {
+ (api.create)(
+ &mut handle,
+ c"Open CAD Studio".as_ptr(),
+ accessors.as_ptr(),
+ accessors.len(),
+ &options,
+ )
+ };
+ if result != 0 {
+ return Err(format!(
+ "3DxWare connection failed ({result:#x}). Start or update 3DxWare."
+ ));
+ }
+ let connection = Self {
+ api,
+ handle,
+ _accessors: accessors,
+ _commands: commands,
+ _shared: shared,
+ policy: None,
+ target: None,
+ };
+ connection.refresh_settings();
+ // Iced requests animation frames only while motion is active and
+ // supplies their monotonic timestamps. A held puck must keep moving
+ // even when it sends no new axis values.
+ connection.write_checked(
+ c"frame.timingSource",
+ Value {
+ kind: 2,
+ data: Data { long: 1 },
+ },
+ )?;
+ connection.write_checked(
+ c"commands.tree",
+ Value {
+ kind: 9,
+ data: Data {
+ ptr: connection._commands.nodes.as_ptr(),
+ },
+ },
+ )?;
+ connection.write_checked(c"commands.activeSet", Value::string(c"Default"))?;
+ if !connection._commands.images.is_empty() {
+ connection.write_checked(
+ c"images",
+ Value {
+ kind: 14,
+ data: Data {
+ array: Array {
+ data: connection._commands.images.as_ptr().cast(),
+ len: connection._commands.images.len(),
+ },
+ },
+ },
+ )?;
+ }
+ Ok(connection)
+ }
+ fn write_checked(&self, name: &CStr, value: Value) -> Result<(), String> {
+ let code = self.api.write(self.handle, name, value);
+ if code == 0 {
+ Ok(())
+ } else {
+ Err(format!(
+ "3DxWare rejected {} ({code:#x}). Update 3DxWare.",
+ name.to_string_lossy()
+ ))
+ }
+ }
+ fn present(&self) -> Result {
+ let mut value = Value::boolean(false);
+ let result =
+ unsafe { (self.api.read)(self.handle, c"device.present".as_ptr(), &mut value) };
+ if result == 0 && value.kind == 1 {
+ Ok(unsafe { value.data.boolean } != 0)
+ } else {
+ Err(result)
+ }
+ }
+ fn string_setting(&self, name: &CStr) -> Option {
+ // settings.* properties use caller-owned UTF-8 string storage.
+ let mut buffer = [0_u8; 32];
+ let mut value = Value {
+ kind: 8,
+ data: Data {
+ array: Array {
+ data: buffer.as_mut_ptr(),
+ len: buffer.len(),
+ },
+ },
+ };
+ let result = unsafe { (self.api.read)(self.handle, name.as_ptr(), &mut value) };
+ if result != 0 {
+ return None;
+ }
+ let end = buffer.iter().position(|b| *b == 0).unwrap_or(buffer.len());
+ Some(std::str::from_utf8(&buffer[..end]).ok()?.trim().to_owned())
+ }
+ fn bool_setting(&self, name: &CStr) -> Option {
+ match self.string_setting(name)?.to_ascii_lowercase().as_str() {
+ "true" | "1" => Some(true),
+ "false" | "0" => Some(false),
+ _ => None,
+ }
+ }
+ fn refresh_settings(&self) {
+ let value = self.bool_setting(c"settings.LockHorizon");
+ let model = self.string_setting(c"settings.MotionModel");
+ log::debug!("SpaceMouse: driver Lock Horizon = {value:?}, motion model = {model:?}");
+ let mut bridge = self._shared.lock().unwrap();
+ if let Some(enabled) = value {
+ bridge.lock_horizon = enabled;
+ }
+ if let Some(model) = model {
+ // Object mode rotates about the SDK pivot; Camera/Helicopter modes
+ // keep their proposed eye when pitch is constrained.
+ bridge.orbit_about_pivot = model == "Examine";
+ }
+ }
+ fn sync(&mut self) {
+ let (focus, frame_time, reset_motion, settings_changed, view) = {
+ let mut bridge = self._shared.lock().unwrap();
+ (
+ bridge.focus,
+ bridge.frame_time.take(),
+ std::mem::take(&mut bridge.reset_motion),
+ std::mem::take(&mut bridge.driver_settings_changed),
+ bridge.view.clone(),
+ )
+ };
+ let policy = (
+ focus,
+ view.as_ref().is_some_and(|v| v.rotate),
+ view.as_ref().is_some_and(|v| v.zoom),
+ view.as_ref().is_some_and(|v| v.motion_enabled),
+ );
+ let context_changed = match (&self.target, &view) {
+ (Some(old), Some(view)) => !old.same_context(&view.target),
+ (None, None) => false,
+ _ => true,
+ };
+ if settings_changed || (focus && self.policy.is_none_or(|p| !p.0)) {
+ self.refresh_settings();
+ }
+ if self.policy != Some(policy) || context_changed || reset_motion {
+ self.api
+ .write(self.handle, c"motion", Value::boolean(false));
+ // Native 3DxWare selects the foreground process itself. `focus`
+ // is for clients hosted through NLServer; releasing it when the
+ // settings UI takes focus can select another application's profile.
+ self.api
+ .write(self.handle, c"active", Value::boolean(view.is_some()));
+ self.api
+ .write(self.handle, c"view.rotatable", Value::boolean(policy.1));
+ self.policy = Some(policy);
+ }
+ if let Some(view) = view {
+ self.target = Some(view.target);
+ } else {
+ self.target = None;
+ }
+ // Do not echo driver-originated camera changes back through Write:
+ // those notifications reset NavLib's current navigation model.
+ if focus {
+ if let Some(time) = frame_time {
+ self.api
+ .write(self.handle, c"frame.time", Value::number(time));
+ }
+ }
+ }
+}
+impl Drop for Connection {
+ fn drop(&mut self) {
+ self.api
+ .write(self.handle, c"active", Value::boolean(false));
+ unsafe {
+ (self.api.close)(self.handle);
+ }
+ log::debug!("SpaceMouse: navigation connection closed");
+ }
+}
+
+struct Stop(
+ Arc,
+ super::worker::Wake,
+ Option>,
+);
+impl Drop for Stop {
+ fn drop(&mut self) {
+ self.0.store(true, Ordering::Release);
+ self.1.signal();
+ // NlClose persists the driver's per-application settings. Do not leave
+ // a detached worker racing process exit after the subscription drops.
+ if let Some(thread) = self.2.take() {
+ let _ = thread.join();
+ }
+ }
+}
+
+pub(super) fn subscription(service: &Service) -> impl Stream- + use<> {
+ let shared = service.0.clone();
+ iced::stream::channel(1, move |output: Sender<()>| async move {
+ let wake = match super::worker::Wake::new() {
+ Ok(wake) => wake,
+ Err(error) => {
+ publish_status(
+ &shared,
+ Status::Unavailable(format!("SpaceMouse worker: {error}")),
+ );
+ return;
+ }
+ };
+ let stop = Arc::new(AtomicBool::new(false));
+ let mut stop_guard = Stop(stop.clone(), wake.clone(), None);
+ let _raw_input = super::raw_input::Listener::start(shared.clone());
+ {
+ let mut bridge = shared.lock().unwrap();
+ bridge.wake = Some(output);
+ bridge.control = Some(wake.clone());
+ }
+ stop_guard.2 = Some(
+ std::thread::Builder::new()
+ .name("spacemouse".into())
+ .spawn(move || {
+ while !stop.load(Ordering::Acquire) {
+ match Connection::open(shared.clone()) {
+ Ok(mut connection) => {
+ let mut last_check = None;
+ loop {
+ if stop.load(Ordering::Acquire) {
+ break;
+ }
+ connection.sync();
+ if last_check.is_none_or(|t: std::time::Instant| {
+ t.elapsed() >= Duration::from_secs(1)
+ }) {
+ last_check = Some(std::time::Instant::now());
+ let status = match connection.present() {
+ Ok(true) => match super::raw_input::connected() {
+ Ok(true) => Status::Ready,
+ Ok(false) => Status::Disconnected,
+ // Device enumeration can race hotplug. Keep the SDK's
+ // answer until the next hardware poll in that case.
+ Err(_) => Status::Ready,
+ },
+ Ok(false) => Status::Disconnected,
+ Err(code) => Status::Unavailable(format!(
+ "3DxWare stopped responding ({code:#x})."
+ )),
+ };
+ let failed = matches!(status, Status::Unavailable(_));
+ publish_status(&shared, status);
+ if failed {
+ break;
+ }
+ }
+ wake.wait(Duration::from_secs(1));
+ }
+ }
+ Err(reason) => publish_status(&shared, Status::Unavailable(reason)),
+ }
+ if !stop.load(Ordering::Acquire) {
+ wake.wait(Duration::from_secs(5));
+ }
+ }
+ })
+ .expect("SpaceMouse worker thread"),
+ );
+ std::future::pending::<()>().await;
+ })
+}
+fn publish_status(shared: &Mutex
, status: Status) {
+ let mut bridge = shared.lock().unwrap();
+ bridge.ever_connected |= status == Status::Ready;
+ if bridge.status.as_ref() != Some(&status) {
+ if status != Status::Ready {
+ bridge.pending_view = None;
+ bridge.commands.clear();
+ bridge.moving = false;
+ bridge.in_transaction = false;
+ bridge.pan.clear();
+ }
+ bridge.status = Some(status);
+ bridge.wake();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn sdk_abi_layout() {
+ assert_eq!(std::mem::size_of::(), 136);
+ assert_eq!(std::mem::offset_of!(Value, data), 8);
+ assert_eq!(std::mem::size_of::(), 12);
+ assert_eq!(std::mem::offset_of!(Options, flags), 8);
+ if cfg!(target_pointer_width = "64") {
+ assert_eq!(std::mem::size_of::(), 32);
+ assert_eq!(std::mem::size_of::(), 48);
+ assert_eq!(std::mem::size_of::(), 48);
+ }
+ }
+ #[test]
+ #[ignore = "requires an installed, running 3DxWare driver"]
+ fn installed_driver_connects_and_reports_device_presence() {
+ let shared = Arc::new(Mutex::new(Bridge {
+ actions: vec![Action {
+ id: "UNDO".into(),
+ label: "Undo".into(),
+ description: "Undo last step".into(),
+ icon: Some(crate::ui::window::options::spacemouse::ICON),
+ }],
+ ..Bridge::default()
+ }));
+ let connection = Connection::open(shared).unwrap();
+ eprintln!(
+ "3DxWare connected; device present: {}",
+ connection.present().unwrap()
+ );
+ let horizon = connection.bool_setting(c"settings.LockHorizon");
+ eprintln!("3DxWare Lock Horizon: {horizon:?}");
+ assert!(
+ horizon.is_some(),
+ "read Lock Horizon through the installed SDK"
+ );
+ assert!(connection.string_setting(c"settings.MotionModel").is_some());
+ }
+ #[test]
+ fn settings_changes_are_received_while_the_driver_dialog_has_focus() {
+ let service = Service::default();
+ let revision = Value {
+ kind: 2,
+ data: Data { long: 1 },
+ };
+ assert_eq!(
+ unsafe {
+ set(
+ Arc::as_ptr(&service.0) as u64,
+ c"settings.changed".as_ptr(),
+ &revision,
+ )
+ },
+ 0
+ );
+ assert!(service.0.lock().unwrap().driver_settings_changed);
+ }
+ #[test]
+ fn orbit_respects_the_driver_horizon_setting_and_unlock_restores_roll() {
+ let service = Service::default();
+ let mut view = super::super::tests::view();
+ view.rotate = true;
+ view.zoom = true;
+ service.sync(Some(view.clone()), true);
+ let param = Arc::as_ptr(&service.0) as u64;
+ for locked in [false, true, false] {
+ service.0.lock().unwrap().lock_horizon = locked;
+ for pitch in [0.4, 1.1, 0., std::f64::consts::PI] {
+ let rotation = DQuat::from_rotation_z(0.6)
+ * DQuat::from_rotation_x(pitch)
+ * DQuat::from_rotation_z(0.3);
+ let matrix = DMat4::from_rotation_translation(rotation, view.eye);
+ assert_eq!(
+ unsafe {
+ set(
+ param,
+ c"view.affine".as_ptr(),
+ &Value::values(7, &matrix.to_cols_array()),
+ )
+ },
+ 0
+ );
+ let result = service.drain().0.unwrap();
+ assert!(result.rotation.is_finite());
+ let at_pole = pitch == 0. || pitch == std::f64::consts::PI;
+ if locked && at_pole {
+ assert!((result.rotation * DVec3::Z).abs_diff_eq(rotation * DVec3::Z, 1.1e-4));
+ assert!(
+ (result.eye.distance(view.pivot) - view.eye.distance(view.pivot)).abs()
+ < 1e-10
+ );
+ } else {
+ assert_eq!(result.eye, view.eye);
+ assert!((result.rotation * DVec3::Z).abs_diff_eq(rotation * DVec3::Z, 1e-10));
+ }
+ if locked {
+ assert!((result.rotation * DVec3::X).z.abs() < 1e-10);
+ } else {
+ assert!(result.rotation.abs_diff_eq(rotation, 1e-10));
+ }
+ }
+ }
+ }
+ #[test]
+ fn horizon_locked_orbit_stops_at_both_poles_and_reverses_without_reflection() {
+ for perspective in [false, true] {
+ for bottom in [false, true] {
+ for yaw in [0., 0.7, -2.4] {
+ let service = Service::default();
+ let mut view = super::super::tests::view();
+ view.rotate = true;
+ view.zoom = true;
+ view.perspective = perspective;
+ view.pivot = DVec3::new(13., -27., 5.);
+ let pole = if bottom { std::f64::consts::PI } else { 0. };
+ let inward = if bottom { -1. } else { 1. };
+ let heading = DQuat::from_rotation_z(yaw);
+ view.rotation = heading * DQuat::from_rotation_x(pole + inward * 0.03);
+ view.eye = view.pivot + view.rotation * DVec3::Z * view.distance;
+ service.sync(Some(view.clone()), true);
+ service.0.lock().unwrap().lock_horizon = true;
+ service.0.lock().unwrap().orbit_about_pivot = true;
+ let param = Arc::as_ptr(&service.0) as u64;
+ // Approach, cross, hold beyond, then reverse away from each
+ // pole, including the app camera's f32 quaternion precision.
+ for offset in [0.02, 0.01, 0., -0.01, -0.03, -0.03, -0.03, 0.01, 0.02] {
+ let proposed = heading * DQuat::from_rotation_x(pole + inward * offset);
+ let eye = view.pivot + proposed * DVec3::Z * view.distance;
+ let matrix = DMat4::from_rotation_translation(proposed, eye);
+ assert_eq!(
+ unsafe {
+ set(
+ param,
+ c"view.affine".as_ptr(),
+ &Value::values(7, &matrix.to_cols_array()),
+ )
+ },
+ 0
+ );
+ let mut result = service.drain().0.unwrap();
+ result.rotation = result.rotation.as_quat().as_dquat().normalize();
+ let right = result.rotation * DVec3::X;
+ let up = result.rotation * DVec3::Y;
+ let normal = result.rotation * DVec3::Z;
+ assert!(
+ right.dot(heading * DVec3::X) > 0.99999,
+ "screen-right flipped"
+ );
+ assert!(right.z.abs() < 1e-7, "horizon rolled");
+ assert!(up.z >= 0., "camera passed the pole");
+ assert!(
+ (result.eye - normal * result.distance).distance(view.pivot) < 1e-5,
+ "orbit pivot moved on screen"
+ );
+ if offset > 0. {
+ assert!(
+ normal.abs_diff_eq(proposed * DVec3::Z, 1e-7),
+ "cannot reverse away from pole"
+ );
+ } else {
+ assert!(normal.z.abs() > 0.9999999, "pitch should stop at the pole");
+ }
+ service.sync(Some(result), true);
+ }
+ }
+ }
+ }
+ }
+ #[test]
+ fn horizon_locked_camera_mode_preserves_the_eye_at_a_pole() {
+ let service = Service::default();
+ let mut view = super::super::tests::view();
+ view.rotate = true;
+ view.zoom = true;
+ view.perspective = true;
+ view.rotation = DQuat::from_rotation_x(0.01);
+ service.sync(Some(view.clone()), true);
+ service.0.lock().unwrap().lock_horizon = true;
+ let param = Arc::as_ptr(&service.0) as u64;
+ for tilt in [-0.01, -0.02, 0.01] {
+ let rotation = DQuat::from_rotation_x(tilt);
+ let eye = view.eye + DVec3::new(1., 2., 3.);
+ let matrix = DMat4::from_rotation_translation(rotation, eye);
+ assert_eq!(
+ unsafe {
+ set(
+ param,
+ c"view.affine".as_ptr(),
+ &Value::values(7, &matrix.to_cols_array()),
+ )
+ },
+ 0
+ );
+ let result = service.drain().0.unwrap();
+ assert_eq!(
+ result.eye, eye,
+ "pitch limits must preserve camera-mode translation"
+ );
+ assert!((result.rotation * DVec3::Y).z > 0.);
+ }
+ }
+ #[test]
+ fn horizon_lock_allows_yaw_around_the_full_circle() {
+ let service = Service::default();
+ let mut view = super::super::tests::view();
+ view.rotate = true;
+ view.zoom = true;
+ view.rotation = DQuat::from_rotation_x(0.01);
+ service.sync(Some(view.clone()), true);
+ service.0.lock().unwrap().lock_horizon = true;
+ let param = Arc::as_ptr(&service.0) as u64;
+ // A large yaw change is not a pitch through the pole either.
+ for yaw in (0..=72).map(|i| i as f64 * 0.1).chain([10., 13.]) {
+ let rotation = DQuat::from_rotation_z(yaw) * DQuat::from_rotation_x(0.01);
+ let matrix = DMat4::from_rotation_translation(rotation, view.eye);
+ assert_eq!(
+ unsafe {
+ set(
+ param,
+ c"view.affine".as_ptr(),
+ &Value::values(7, &matrix.to_cols_array()),
+ )
+ },
+ 0
+ );
+ let result = service.drain().0.unwrap();
+ assert!(result.rotation.dot(rotation).abs() > 1. - 1e-10);
+ assert_eq!(result.eye, view.eye);
+ }
+ }
+ #[test]
+ fn sdk_motion_cannot_double_apply_planar_push_tilt_or_zoom() {
+ for zoom in [false, true] {
+ let service = Service::default();
+ let mut view = super::super::tests::view();
+ view.zoom = zoom;
+ service.sync(Some(view), true);
+ let param = Arc::as_ptr(&service.0) as u64;
+ for (name, value) in [
+ (c"motion", Value::boolean(true)),
+ (
+ c"view.affine",
+ Value::values(7, &DMat4::IDENTITY.to_cols_array()),
+ ),
+ (
+ c"view.extents",
+ Value::values(11, &[-2., -2., -2., 2., 2., 2.]),
+ ),
+ ] {
+ assert_eq!(unsafe { set(param, name.as_ptr(), &value) }, 0);
+ }
+ assert!(!service.moving());
+ assert!(service.drain().0.is_none());
+ }
+ }
+ #[test]
+ fn coordinate_system_is_available_before_a_drawing_exists() {
+ let bridge = Mutex::new(Bridge::default());
+ let mut value = Value::boolean(false);
+ assert_eq!(
+ unsafe {
+ get(
+ &bridge as *const _ as u64,
+ c"coordinateSystem".as_ptr(),
+ &mut value,
+ )
+ },
+ 0
+ );
+ let matrix = DMat4::from_cols_array(&unsafe { value.data.matrix });
+ assert!((matrix.transform_vector3(DVec3::Z) - DVec3::Y).length() < 1e-10);
+ }
+ #[test]
+ fn free_camera_does_not_advertise_the_sdk_target_camera_model() {
+ let service = Service::default();
+ service.sync(Some(super::super::tests::view()), true);
+ let mut value = Value::values(5, &[0., 0., 0.]);
+ assert_eq!(
+ unsafe {
+ get(
+ Arc::as_ptr(&service.0) as u64,
+ c"view.target".as_ptr(),
+ &mut value,
+ )
+ },
+ NO_DATA
+ );
+ }
+ #[test]
+ fn paused_motion_is_rejected_but_resume_action_is_queued() {
+ let service = Service::default();
+ let mut view = super::super::tests::view();
+ view.motion_enabled = false;
+ service.sync(Some(view), true);
+ service.set_actions(vec![Action {
+ id: "SPACEMOUSEPAUSE".into(),
+ label: "Resume".into(),
+ description: String::new(),
+ icon: None,
+ }]);
+ let param = Arc::as_ptr(&service.0) as u64;
+ assert_eq!(
+ unsafe {
+ set(
+ param,
+ c"view.affine".as_ptr(),
+ &Value::values(7, &DMat4::IDENTITY.to_cols_array()),
+ )
+ },
+ NO_DATA
+ );
+ assert_eq!(
+ unsafe {
+ set(
+ param,
+ c"commands.activeCommand".as_ptr(),
+ &Value::string(c"SPACEMOUSEPAUSE"),
+ )
+ },
+ 0
+ );
+ let (view, commands) = service.drain();
+ assert!(view.is_none());
+ assert_eq!(commands[0].1, "SPACEMOUSEPAUSE");
+ service.sync(None, false);
+ assert_eq!(
+ unsafe {
+ set(
+ param,
+ c"commands.activeCommand".as_ptr(),
+ &Value::string(c"SPACEMOUSEPAUSE"),
+ )
+ },
+ NO_DATA
+ );
+ assert!(service.drain().1.is_empty());
+ }
+}
diff --git a/src/input/spacemouse/pan.rs b/src/input/spacemouse/pan.rs
new file mode 100644
index 000000000..9fc435aaa
--- /dev/null
+++ b/src/input/spacemouse/pan.rs
@@ -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,
+ frame_time: Option,
+}
+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 {
+ 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]);
+ }
+}
diff --git a/src/input/spacemouse/raw_input.rs b/src/input/spacemouse/raw_input.rs
new file mode 100644
index 000000000..0947e52ed
--- /dev/null
+++ b/src/input/spacemouse/raw_input.rs
@@ -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,
+ thread: Option>,
+}
+impl Listener {
+ pub fn start(shared: Arc>) -> 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>, window: &AtomicIsize) -> Result<(), String> {
+ let class: Vec = "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::() 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::() 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;
+ 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::() 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 {
+ let element_size = std::mem::size_of::() 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, 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::();
+ 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::() };
+ if header.dwType != RIM_TYPEHID || !is_spacemouse(header.hDevice) {
+ return;
+ }
+ let bytes = unsafe { std::slice::from_raw_parts(buffer.as_ptr().cast::(), 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();
+ }
+}
diff --git a/src/input/spacemouse/worker.rs b/src/input/spacemouse/worker.rs
new file mode 100644
index 000000000..653b0a0fc
--- /dev/null
+++ b/src/input/spacemouse/worker.rs
@@ -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);
+impl Wake {
+ pub fn new() -> Result {
+ 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 = "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();
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index a02cd5bcb..073c04980 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -9,6 +9,7 @@ pub mod config;
pub mod discussions;
pub mod entities;
pub mod i18n;
+pub(crate) mod input;
pub mod io;
#[cfg(not(target_arch = "wasm32"))]
pub mod mcp;
diff --git a/src/scene/mod.rs b/src/scene/mod.rs
index 0634b4fe8..eba50ad2d 100644
--- a/src/scene/mod.rs
+++ b/src/scene/mod.rs
@@ -10921,11 +10921,21 @@ vis_index={:.1} visible_probe={:.1}",
/// when something is selected. `None` keeps the current camera target as
/// the orbit centre. (#229)
pub fn orbit_pivot(&self) -> Option {
+ self.navigation_selection_bounds()
+ .map(|(min, max)| (min + max) * 0.5)
+ }
+
+ pub(crate) fn navigation_selection_bounds(&self) -> Option<(glam::DVec3, glam::DVec3)> {
if self.selected.is_empty() {
return None;
}
- let block = self.current_layout_block_handle();
- let scale = if self.current_layout == "Model" {
+ let model_view = self.current_layout == "Model" || self.active_viewport.is_some();
+ let block = if self.active_viewport.is_some() {
+ self.model_space_block_handle()
+ } else {
+ self.current_layout_block_handle()
+ };
+ let scale = if model_view {
crate::scene::annotative::scale_handle_by_name(
&self.document,
&self.document.header.current_annotation_scale,
@@ -10982,7 +10992,7 @@ vis_index={:.1} visible_probe={:.1}",
}
}
if any {
- Some((min + max) * 0.5)
+ Some((min, max))
} else {
None
}
diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs
index 6cad6e5c5..a6c014aa0 100644
--- a/src/scene/mspace.rs
+++ b/src/scene/mspace.rs
@@ -446,6 +446,45 @@ impl Scene {
self.camera.borrow().rotation
}
+ /// Camera owning navigation, including an entered paper-space viewport.
+ pub(crate) fn navigation_camera(&self) -> view::camera::Camera {
+ self.active_viewport
+ .and_then(|h| self.camera_for_viewport(h))
+ .unwrap_or_else(|| self.camera.borrow().clone())
+ }
+
+ pub(crate) fn navigation_locked(&self) -> bool {
+ self.active_viewport.is_some_and(|h| {
+ !matches!(self.document.get_entity(h), Some(acadrust::EntityType::Viewport(v)) if !v.status.locked)
+ })
+ }
+
+ /// Commit an externally navigated camera through the same floating-view
+ /// encoding as the ViewCube. Camera coordinates already include view_center.
+ pub(crate) fn apply_navigation_camera(&mut self, camera: view::camera::Camera) -> bool {
+ if self.navigation_locked() {
+ return false;
+ }
+ if let Some(handle) = self.active_viewport {
+ if !self.mutate_active_viewport_camera(|c| *c = camera.clone()) {
+ return false;
+ }
+ if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) {
+ vp.view_target.x = camera.target.x;
+ vp.view_target.y = camera.target.y;
+ vp.view_target.z = camera.target.z;
+ vp.view_center.x = 0.;
+ vp.view_center.y = 0.;
+ vp.view_height = camera.ortho_size() as f64 * 2.;
+ vp.custom_scale = vp.height / vp.view_height;
+ }
+ } else {
+ *self.camera.borrow_mut() = camera;
+ }
+ self.camera_generation += 1;
+ true
+ }
+
pub fn active_camera_projection(&self) -> view::camera::Projection {
if let Some(handle) = self.active_viewport {
if let Some(camera) = self.camera_for_viewport(handle) {
diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs
index de94dd117..c5364b9f7 100644
--- a/src/scene/view/camera.rs
+++ b/src/scene/view/camera.rs
@@ -752,7 +752,7 @@ impl Camera {
// ── Internal helpers ───────────────────────────────────────────────────
/// Derive yaw and pitch from the current quaternion.
- fn sync_yaw_pitch(&mut self) {
+ pub(crate) fn sync_yaw_pitch(&mut self) {
let eye_dir = self.rotation * Vec3::Z;
self.pitch = eye_dir.z.clamp(-1.0, 1.0).asin();
self.yaw = if eye_dir.x.abs() < 1e-6 && eye_dir.y.abs() < 1e-6 {
diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs
index 52ec2916e..ea546a0cf 100644
--- a/src/ui/statusbar/mod.rs
+++ b/src/ui/statusbar/mod.rs
@@ -1,6 +1,7 @@
//! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info
pub mod status_menu;
+pub(crate) mod spacemouse;
pub mod statusbar_config;
pub mod statusbar_menu;
@@ -135,6 +136,7 @@ impl StatusBar {
parametric_conflicts: usize,
// What is drawing the scene. Only a degraded verdict shows anything.
gpu_status: &'a crate::scene::pipeline::GpuStatus,
+ spacemouse: Option>,
) -> Element<'a, Message> {
let StatusMenuData {
layout_names,
@@ -241,6 +243,11 @@ impl StatusBar {
// when the width can't hold them all on one line.
let vis = |p: StatusPill| config.is_visible(p);
let mut pills: Vec> = Vec::new();
+ if vis(StatusPill::SpaceMouse) {
+ if let Some(pill) = spacemouse {
+ pills.push(pill);
+ }
+ }
// Not a `StatusPill` and not hideable: the scene is on a software
// rasterizer or not drawn at all, and the popup that said so has been
// dismissed. This stays for the session and reopens it. Many people
diff --git a/src/ui/statusbar/spacemouse.rs b/src/ui/statusbar/spacemouse.rs
new file mode 100644
index 000000000..b3b78025f
--- /dev/null
+++ b/src/ui/statusbar/spacemouse.rs
@@ -0,0 +1,78 @@
+use super::status_menu::{self, Entry};
+use crate::app::Message;
+use crate::input::spacemouse::{NavigationMode, Preferences, Status};
+use iced::widget::{button, row, text};
+use iced::{Element, Fill};
+
+pub(crate) fn view(
+ preferences: Preferences,
+ status: Status,
+ paused: bool,
+ label: String,
+ sheet: bool,
+) -> Element<'static, Message> {
+ let root = button(
+ row![
+ crate::ui::icons::themed(crate::ui::window::options::spacemouse::ICON, 15.),
+ text(label).size(11),
+ crate::ui::icons::themed_arrow_toggle(false, 8.)
+ ]
+ .spacing(5)
+ .align_y(iced::Center),
+ )
+ .padding([3, 6])
+ .style(button::subtle);
+ let mut entries = vec![Entry::stay(
+ text(format!("SpaceMouse · {}", crate::t!(status.label()))).size(11),
+ )];
+ for mode in NavigationMode::ALL {
+ let label = format!(
+ "{} {}",
+ if mode == preferences.mode {
+ "●"
+ } else {
+ "○"
+ },
+ crate::t!(mode.label())
+ );
+ entries.push(Entry::close(
+ button(text(label).size(12))
+ .width(Fill)
+ .padding([7, 10])
+ .style(button::text)
+ .on_press(Message::SpaceMouseMode(mode)),
+ ));
+ }
+ if sheet {
+ entries.push(Entry::stay(
+ text(crate::t!("Paper sheets keep rotation locked.")).size(11),
+ ));
+ }
+ entries.push(Entry::close(
+ button(
+ text(crate::t!(if paused {
+ "Resume SpaceMouse"
+ } else {
+ "Pause SpaceMouse"
+ }))
+ .size(12),
+ )
+ .on_press(Message::SpaceMousePause)
+ .style(button::text)
+ .width(Fill)
+ .padding([7, 10]),
+ ));
+ for (label, message) in [
+ ("SpaceMouse preferences…", Message::SpaceMousePreferences),
+ ("3Dconnexion settings…", Message::SpaceMouseDriverSettings),
+ ] {
+ entries.push(Entry::close(
+ button(text(crate::t!(label)).size(12))
+ .on_press(message)
+ .style(button::text)
+ .width(Fill)
+ .padding([7, 10]),
+ ));
+ }
+ status_menu::menu_bar(root, entries, 260.)
+}
diff --git a/src/ui/statusbar/statusbar_config.rs b/src/ui/statusbar/statusbar_config.rs
index 84dee3ca8..b0faadbf9 100644
--- a/src/ui/statusbar/statusbar_config.rs
+++ b/src/ui/statusbar/statusbar_config.rs
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
/// Identifies a toggleable status-bar pill.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
pub enum StatusPill {
+ SpaceMouse,
Coords,
Ortho,
Lwt,
@@ -37,6 +38,7 @@ impl StatusPill {
/// Every pill, in status-bar display order. Drives both the bar layout and
/// the customization menu.
pub const ALL: &'static [StatusPill] = &[
+ StatusPill::SpaceMouse,
StatusPill::Coords,
StatusPill::Ortho,
StatusPill::Lwt,
@@ -62,6 +64,7 @@ impl StatusPill {
/// Stable identifier used for persistence.
pub fn id(self) -> &'static str {
match self {
+ StatusPill::SpaceMouse => "spacemouse",
StatusPill::Coords => "coords",
StatusPill::Ortho => "ortho",
StatusPill::Lwt => "lwt",
@@ -88,6 +91,7 @@ impl StatusPill {
/// Label shown in the customization menu.
pub fn label(self) -> &'static str {
match self {
+ StatusPill::SpaceMouse => "SpaceMouse",
StatusPill::Coords => "Coordinates",
StatusPill::Ortho => "Ortho Mode",
StatusPill::Lwt => "Show Lineweight",
diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs
index 3c7f168c3..c3e1c0d8e 100644
--- a/src/ui/window/options.rs
+++ b/src/ui/window/options.rs
@@ -1,3 +1,4 @@
+pub(crate) mod spacemouse;
use crate::app::config::UiThemeConfig;
use crate::app::settings::CursorType;
use crate::app::Message;
@@ -153,6 +154,7 @@ pub fn view_window<'a>(
cursor_size: i32,
selection: SelectionPrefs,
prefs: AppPrefs,
+ spacemouse: Element<'a, Message>,
snap_angle_input: &'a str,
drawing_prefs: DrawingPrefs,
folders: Folders,
@@ -1135,6 +1137,8 @@ pub fn view_window<'a>(
let user_prefs = column![
text(crate::t!("User Preferences")).size(15),
Space::new().height(10),
+ spacemouse,
+ Space::new().height(12),
text(crate::t!("Zoom")).size(15),
Space::new().height(10),
row![
diff --git a/src/ui/window/options/spacemouse.rs b/src/ui/window/options/spacemouse.rs
new file mode 100644
index 000000000..dea7fca9d
--- /dev/null
+++ b/src/ui/window/options/spacemouse.rs
@@ -0,0 +1,135 @@
+use crate::app::Message;
+use crate::input::spacemouse::{NavigationMode, Preferences, Status};
+use iced::widget::{button, checkbox, column, container, pick_list, row, slider, text, Space};
+use iced::{Element, Fill};
+
+pub(crate) const ICON: &[u8] = include_bytes!("../../../../assets/icons/ui/spacemouse.svg");
+
+pub(crate) fn view(
+ preferences: Preferences,
+ status: Status,
+ paused: bool,
+ details: bool,
+) -> Element<'static, Message> {
+ let supported = cfg!(windows);
+ let mut enabled = checkbox(preferences.enabled).size(15);
+ if supported {
+ enabled = enabled.on_toggle(Message::SpaceMouseEnabled);
+ }
+ let mode = pick_list(Some(preferences.mode), NavigationMode::ALL, |mode| {
+ crate::t!(mode.label()).into_owned()
+ })
+ .width(220);
+ let mode = if supported {
+ mode.on_select(Message::SpaceMouseMode)
+ } else {
+ mode
+ };
+ let status_text = if !preferences.enabled {
+ crate::t!("Disabled").into_owned()
+ } else if paused {
+ crate::t!("Paused").into_owned()
+ } else {
+ crate::t!(status.label()).into_owned()
+ };
+ let mut content = column![
+ row![
+ crate::ui::icons::themed(ICON, 22.),
+ text(crate::t!("SpaceMouse")).size(15)
+ ]
+ .spacing(8)
+ .align_y(iced::Center),
+ row![enabled, text(crate::t!("Enable SpaceMouse")).size(12)]
+ .spacing(8)
+ .align_y(iced::Center),
+ row![
+ text(crate::t!("Status")).size(12).width(150),
+ text(status_text).size(12)
+ ]
+ .spacing(10),
+ row![text(crate::t!("Navigation")).size(12).width(150), mode]
+ .spacing(10)
+ .align_y(iced::Center),
+ text(crate::t!(preferences.mode.description()).into_owned())
+ .size(11)
+ .width(Fill),
+ ]
+ .spacing(12);
+ if preferences.mode != NavigationMode::Full3D && supported {
+ content = content.push(
+ row![
+ checkbox(preferences.pan_reversed)
+ .size(15)
+ .on_toggle(Message::SpaceMousePanReversed),
+ text(crate::t!("Reverse pan direction")).size(12),
+ ]
+ .spacing(8)
+ .align_y(iced::Center),
+ );
+ content = content.push(
+ row![
+ text(crate::t!("Pan speed")).size(12).width(150),
+ slider(
+ 10..=300,
+ preferences.pan_speed.clamp(10, 300),
+ Message::SpaceMousePanSpeed
+ )
+ .step(10)
+ .width(160),
+ text(format!("{}%", preferences.pan_speed.clamp(10, 300))).size(11),
+ ]
+ .spacing(10)
+ .align_y(iced::Center),
+ );
+ }
+ if paused {
+ content = content.push(
+ button(text(crate::t!("Resume SpaceMouse")).size(11))
+ .on_press(Message::SpaceMousePause)
+ .style(button::secondary),
+ );
+ }
+ content = content.push(
+ button(
+ text(crate::t!(if supported {
+ "Open 3Dconnexion settings…"
+ } else {
+ "SpaceMouse information…"
+ }))
+ .size(11),
+ )
+ .on_press(Message::SpaceMouseDriverSettings)
+ .style(button::secondary)
+ .padding([5, 10]),
+ );
+ content = content.push(
+ text(crate::t!(
+ "Configure 3D navigation, buttons, and radial menus in 3Dconnexion settings. Pan only and Pan and zoom share the push/tilt mapping and pan settings shown here."
+ ))
+ .size(11)
+ .width(Fill),
+ );
+ content = content.push(
+ button(
+ row![
+ crate::ui::icons::themed_arrow_toggle(details, 9.),
+ text(crate::t!("Connection details")).size(11)
+ ]
+ .spacing(6),
+ )
+ .on_press(Message::SpaceMouseDetails)
+ .style(button::text),
+ );
+ if details {
+ let detail = match status {
+ Status::Unavailable(reason) => reason,
+ Status::Unsupported => "This version supports SpaceMouse through 3DxWare on Windows. Other platforms do not yet have a device adapter.".into(),
+ Status::Disconnected => "3DxWare is running. Connect a SpaceMouse; Open CAD Studio reconnects automatically.".into(),
+ _ => "Uses the installed 3DxWare driver. Navigation pauses while a dialog is open. Your selected navigation mode is saved across drawings and sessions.".into(),
+ };
+ content = content.push(text(crate::t!(&detail).into_owned()).size(11).width(Fill));
+ }
+ container(content.push(Space::new().height(8)))
+ .width(Fill)
+ .into()
+}