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. + +![SpaceMouse menu with Pan only selected](images/spacemouse-menu.png) + +![SpaceMouse settings in User Preferences](images/spacemouse-preferences.png) + +## 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() +}