Merge pull request #1299: Add Windows SpaceMouse navigation

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

No files matched your search

Generated
+1
View File
@@ -35,6 +35,7 @@ dependencies = [
"image", "image",
"inventory", "inventory",
"js-sys", "js-sys",
"libloading",
"log", "log",
"lyon_tessellation", "lyon_tessellation",
"meshopt", "meshopt",
+3 -2
View File
@@ -32,6 +32,7 @@ glam = { version = "0.33", features = ["bytemuck"] }
rfd = "0.17" rfd = "0.17"
clap = { version = "4", features = ["derive", "string"] } clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11" env_logger = "0.11"
log = "0.4"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } 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 = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840", features = ["acis", "offset"] }
cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840" } 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"] } naga = { version = "27", features = ["wgsl-in"] }
[target.'cfg(target_os = "windows")'.dependencies] [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] [target.'cfg(target_os = "linux")'.dependencies]
ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } 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"] } i18n-embed = { version = "0.16", features = ["web-sys-requester"] }
rust-embed = { version = "8", features = ["debug-embed"] } rust-embed = { version = "8", features = ["debug-embed"] }
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
log = "0.4"
getrandom = { version = "0.3", features = ["wasm_js"] } getrandom = { version = "0.3", features = ["wasm_js"] }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="8" rx="6" ry="3"/><path d="M6 8v6c0 1.7 2.7 3 6 3s6-1.3 6-3V8M5 13l-3 5c0 2 4.5 4 10 4s10-2 10-4l-3-5M12 2v2M2 8h2M20 8h2"/></svg>

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+145
View File
@@ -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
```
+18 -2
View File
@@ -65,6 +65,9 @@ impl OpenCADStudio {
// the first space are left untouched. A non-alias passes through as-is. // the first space are left untouched. A non-alias passes through as-is.
let resolved = self.resolve_alias(cmd); let resolved = self.resolve_alias(cmd);
let cmd = resolved.as_deref().unwrap_or(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 // A drafting aid only flips a flag, so it must not disturb whatever is
// already running: pressing F8 partway through a LINE means "constrain // already running: pressing F8 partway through a LINE means "constrain
// the rest of this line", not "abandon it". Everything below tears the // 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, /// vanish. Nothing here starts a command, opens a document or reads geometry,
/// so there is nothing for the teardown to protect. (#677) /// so there is nothing for the teardown to protect. (#677)
pub fn is_transparent(cmd: &str) -> bool { 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!( matches!(
cmd, 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 /// source of truth: the dispatch gate refuses everything else, and the ribbon
/// dims the tools this rejects. /// dims the tools this rejects.
pub fn start_allowed(cmd: &str) -> bool { pub fn start_allowed(cmd: &str) -> bool {
matches!( is_spacemouse_command(cmd) || matches!(
cmd, cmd,
"NEW" "NEW"
| "OPEN" | "OPEN"
+32
View File
@@ -18,6 +18,7 @@ pub(crate) mod helpers;
mod history; mod history;
mod layers; mod layers;
mod model_ops; mod model_ops;
mod navigation;
mod mtext_editor; mod mtext_editor;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub mod plugin_host; pub mod plugin_host;
@@ -540,6 +541,14 @@ pub(super) struct OpenCADStudio {
dyn_input: bool, dyn_input: bool,
/// Currently visible page in the application Options dialog. /// Currently visible page in the application Options dialog.
options_tab: crate::ui::window::options::OptionsTab, 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). /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single).
pub texteditmode: bool, pub texteditmode: bool,
/// QDIM extension-origin priority: 0 = endpoints, 1 = intersections. /// QDIM extension-origin priority: 0 = endpoints, 1 = intersections.
@@ -1895,6 +1904,17 @@ pub enum ArrowKey {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { 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), ControlRequest(control::Envelope),
PollWebControl, PollWebControl,
ControlStep(String, Box<Message>), ControlStep(String, Box<Message>),
@@ -3718,6 +3738,18 @@ impl OpenCADStudio {
show_grid: false, show_grid: false,
dyn_input: true, dyn_input: true,
options_tab: crate::ui::window::options::OptionsTab::General, 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, texteditmode: false,
quick_dimension_snap_priority: 0, quick_dimension_snap_priority: 0,
dimension_continue_mode: 1, dimension_continue_mode: 1,
+699
View File
@@ -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<Option<(Target, u64, u64, Option<(glam::DVec3, glam::DVec3)>)>>,
);
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<Message> {
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<iced::Element<'_, Message>> {
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<Message> {
#[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<Action> {
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<String, Action> = 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<String, Action>,
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<String, Action>, 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"));
}
}
+1 -1
View File
@@ -3806,7 +3806,7 @@ fn format_unit_factor(factor: f64) -> String {
} }
/// Convert INSUNITS (DXF group 70) to millimetres. /// Convert INSUNITS (DXF group 70) to millimetres.
fn insunits_to_mm(code: i16) -> Option<f64> { pub(super) fn insunits_to_mm(code: i16) -> Option<f64> {
Some(match code { Some(match code {
1 => 25.4, // Inches 1 => 25.4, // Inches
2 => 304.8, // Feet 2 => 304.8, // Feet
+2
View File
@@ -289,6 +289,7 @@ pub const DEFAULT_GRIP_OBJECT_LIMIT: i32 = 100;
#[derive(Clone, PartialEq, Serialize, Deserialize)] #[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct UserSettings { pub struct UserSettings {
pub spacemouse: crate::input::spacemouse::Preferences,
pub dyn_input: bool, pub dyn_input: bool,
pub polar: bool, pub polar: bool,
pub polar_increment_deg: f32, pub polar_increment_deg: f32,
@@ -497,6 +498,7 @@ pub fn clamp_clipromptlines(v: i32) -> i32 {
impl Default for UserSettings { impl Default for UserSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
spacemouse: crate::input::spacemouse::Preferences::default(),
dyn_input: true, dyn_input: true,
polar: false, polar: false,
polar_increment_deg: 45.0, polar_increment_deg: 45.0,
+32 -1
View File
@@ -145,6 +145,8 @@ fn is_named_key(key: &str) -> bool {
/// the command dispatcher — valid shortcut commands that never appear in the /// the command dispatcher — valid shortcut commands that never appear in the
/// command registry. Used to validate the shortcut editor's command column. /// command registry. Used to validate the shortcut editor's command column.
pub(super) const INPUT_ACTIONS: &[&str] = &[ pub(super) const INPUT_ACTIONS: &[&str] = &[
"SPACEMOUSEFIT",
"SPACEMOUSETOP",
"FINALIZE", "FINALIZE",
"COMMANDSPACE", "COMMANDSPACE",
"CANCEL", "CANCEL",
@@ -270,7 +272,36 @@ impl OpenCADStudio {
let Some(action) = action.cloned() else { let Some(action) = action.cloned() else {
return Task::none(); 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<Message> {
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, "FINALIZE" => Message::CommandFinalize,
"COMMANDSPACE" => Message::CommandSpace, "COMMANDSPACE" => Message::CommandSpace,
"CANCEL" => Message::CommandEscape, "CANCEL" => Message::CommandEscape,
+2
View File
@@ -382,6 +382,7 @@ impl OpenCADStudio {
/// Snapshot the persisted UI preferences from live state. /// Snapshot the persisted UI preferences from live state.
pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings { pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings {
crate::app::settings::UserSettings { crate::app::settings::UserSettings {
spacemouse: self.spacemouse_preferences,
dyn_input: self.dyn_input, dyn_input: self.dyn_input,
polar: self.polar_mode, polar: self.polar_mode,
polar_increment_deg: self.polar_increment_deg, polar_increment_deg: self.polar_increment_deg,
@@ -457,6 +458,7 @@ impl OpenCADStudio {
self.cursor_size = s.cursor_size.clamp(1, 100); self.cursor_size = s.cursor_size.clamp(1, 100);
self.pick_box = s.pick_box.clamp(0, 50); self.pick_box = s.pick_box.clamp(0, 50);
self.options_tab = s.options_tab; self.options_tab = s.options_tab;
self.spacemouse_preferences = s.spacemouse;
// These four drive real features with commands and status-bar pills, // These four drive real features with commands and status-bar pills,
// but they lived only on the app struct: turning the ViewCube off and // but they lived only on the app struct: turning the ViewCube off and
// restarting brought it straight back. // restarting brought it straight back.
+43
View File
@@ -357,6 +357,7 @@ impl OpenCADStudio {
self.block_palette.placing = None; self.block_palette.placing = None;
} }
self.control_settle(); self.control_settle();
self.sync_spacemouse();
task task
} }
@@ -372,6 +373,48 @@ impl OpenCADStudio {
fn update_inner(&mut self, msg: Message) -> Task<Message> { fn update_inner(&mut self, msg: Message) -> Task<Message> {
match msg { 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) => { Message::ControlRequest(envelope) => {
let (response, task) = self.control_request(envelope.request); let (response, task) = self.control_request(envelope.request);
envelope.reply.send(response); envelope.reply.send(response);
+2 -2
View File
@@ -922,7 +922,7 @@ impl OpenCADStudio {
Task::none() Task::none()
} }
pub(super) fn on_viewport_move(&mut self, p: Point) -> Task<Message> { pub(in crate::app) fn on_viewport_move(&mut self, p: Point) -> Task<Message> {
// A ribbon dropdown is open over the viewport. Its backdrop // A ribbon dropdown is open over the viewport. Its backdrop
// cannot swallow cursor motion — in iced 0.14 mouse_area/opaque // cannot swallow cursor motion — in iced 0.14 mouse_area/opaque
// capture only button presses, never CursorMoved — so the move // 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 /// 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 /// drawing instead of staying frozen at its original pixel. No-op when no
/// box is in progress. (#234) /// 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 world = self.tabs[i].scene.selection.borrow().box_anchor_world;
let Some(world) = world else { return }; let Some(world) = world else { return };
let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh); let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
+30
View File
@@ -1202,6 +1202,9 @@ bg={bg_ms:.1}ms n={view_count}"
}; };
if !thumbnail_capture_clean && !self.layout_settling { 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 // Per-pane input pane_grid goes ABOVE the crosshair overlay so it
// receives mouse events (the overlay's `Hidden` cursor would otherwise // receives mouse events (the overlay's `Hidden` cursor would otherwise
// starve any layer beneath it). The controls bar is pushed on top of it. // 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()) .map(|s| s.conflicts.len())
.unwrap_or(0), .unwrap_or(0),
&self.gpu_status, &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) .width(Fill)
@@ -2662,6 +2677,21 @@ impl OpenCADStudio {
let control = iced::time::every(std::time::Duration::from_millis(50)) let control = iced::time::every(std::time::Duration::from_millis(50))
.map(|_| Message::PollWebControl); .map(|_| Message::PollWebControl);
iced::Subscription::batch([ 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, control,
frames, frames,
history_tick, history_tick,
+4
View File
@@ -232,6 +232,10 @@ impl OpenCADStudio {
show_ucs_icon: self.show_ucs_icon, show_ucs_icon: self.show_ucs_icon,
ucs_icon_at_origin: self.ucs_icon_at_origin, 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, &self.snap_angle_input,
{ {
let header = self let header = self
+2
View File
@@ -0,0 +1,2 @@
//! Device integrations. Camera ownership and CAD commands stay in the app.
pub(crate) mod spacemouse;
+593
View File
@@ -0,0 +1,593 @@
//! SpaceMouse navigation data and the thread-safe bridge to the platform driver.
//!
//! No SDK types escape this module. Driver callbacks operate on a snapshot;
//! only the app thread applies a proposed view to a drawing.
// Snapshot/ABI support is intentionally dormant until another platform has an adapter.
#![cfg_attr(not(windows), allow(dead_code))]
use glam::{DMat4, DQuat, DVec3};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
#[cfg(windows)]
mod navlib;
mod pan;
#[cfg(windows)]
mod raw_input;
#[cfg(windows)]
mod worker;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum NavigationMode {
#[default]
Auto,
PanOnly,
PanZoom,
Full3D,
}
impl NavigationMode {
pub const ALL: [Self; 4] = [Self::Auto, Self::PanOnly, Self::PanZoom, Self::Full3D];
pub fn label(self) -> &'static str {
match self {
Self::Auto => "Follow context",
Self::PanOnly => "Pan only",
Self::PanZoom => "Pan and zoom",
Self::Full3D => "3D navigation",
}
}
pub fn description(self) -> &'static str {
match self {
Self::Auto => "Pan and zoom on paper sheets; full navigation in model views.",
Self::PanOnly => {
"Push or tilt to pan. Twist and vertical pressure are ignored; zoom and rotation stay fixed."
}
Self::PanZoom => {
"Push or tilt to pan; lift or press to zoom. Twist is ignored and rotation stays fixed."
}
Self::Full3D => "Pan, zoom, and orbit model views. Paper sheets keep rotation locked.",
}
}
pub fn allows_rotation(self, sheet: bool) -> bool {
!sheet && matches!(self, Self::Auto | Self::Full3D)
}
pub fn allows_zoom(self) -> bool {
self != Self::PanOnly
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Preferences {
pub enabled: bool,
pub mode: NavigationMode,
pub pan_speed: u16,
pub pan_reversed: bool,
}
impl Default for Preferences {
fn default() -> Self {
Self {
enabled: true,
mode: NavigationMode::Auto,
pan_speed: 100,
pan_reversed: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Status {
Starting,
Ready,
Disconnected,
Unavailable(String),
Unsupported,
}
impl Status {
pub fn label(&self) -> &str {
match self {
Self::Starting => "Connecting…",
Self::Ready => "Ready",
Self::Disconnected => "No SpaceMouse connected",
Self::Unavailable(_) => "3DxWare unavailable",
Self::Unsupported => "Available in the Windows desktop app",
}
}
}
/// Stable target plus camera revision. Late callbacks must never move another
/// document, pane, layout, or a camera already changed by another input source.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Target {
pub tab: u64,
pub layout: String,
pub viewport: Option<u64>,
pub tile: usize,
pub generation: u64,
}
impl Target {
pub fn same_context(&self, other: &Self) -> bool {
self.tab == other.tab
&& self.layout == other.layout
&& self.viewport == other.viewport
&& self.tile == other.tile
}
}
#[derive(Clone, Debug)]
pub struct View {
pub target: Target,
pub eye: DVec3,
pub rotation: DQuat,
pub distance: f64,
pub fov: f64,
pub perspective: bool,
pub aspect: f64,
pub model_bounds: [f64; 6],
pub selection_bounds: Option<[f64; 6]>,
pub pointer: DVec3,
pub pivot: DVec3,
pub pivot_visible: bool,
pub rotate: bool,
pub zoom: bool,
pub motion_enabled: bool,
pub units_to_meters: f64,
pub pan_speed: f64,
}
impl View {
fn planar(&self) -> bool {
!self.rotate
}
pub fn affine(&self) -> [f64; 16] {
DMat4::from_rotation_translation(self.rotation, self.eye).to_cols_array()
}
pub fn half_height(&self) -> f64 {
self.distance * (self.fov * 0.5).tan()
}
pub fn extents(&self) -> [f64; 6] {
let h = self.half_height();
let w = h * self.aspect;
[
-w,
-h,
-self.distance * 1000.0,
w,
h,
self.distance * 1000.0,
]
}
/// Validate before accepting driver data. A pan-only view projects the
/// translation onto its original screen plane, including in perspective.
pub fn set_affine(&mut self, values: [f64; 16]) -> bool {
if !values.iter().all(|v| v.is_finite()) {
return false;
}
let matrix = DMat4::from_cols_array(&values);
let x = matrix.x_axis.truncate();
let y = matrix.y_axis.truncate();
let z = matrix.z_axis.truncate();
if (x.length_squared() - 1.0).abs() > 0.01
|| (y.length_squared() - 1.0).abs() > 0.01
|| (z.length_squared() - 1.0).abs() > 0.01
|| x.dot(y).abs() > 0.01
|| x.cross(y).dot(z) < 0.99
|| matrix.w_axis.w != 1.0
|| matrix.x_axis.w != 0.0
|| matrix.y_axis.w != 0.0
|| matrix.z_axis.w != 0.0
{
return false;
}
let proposed_eye = matrix.w_axis.truncate();
if self.zoom {
self.eye = proposed_eye;
} else {
let normal = self.rotation * DVec3::Z;
let delta = proposed_eye - self.eye;
self.eye += delta - normal * delta.dot(normal);
}
if self.rotate {
self.rotation = DQuat::from_mat4(&matrix).normalize();
}
true
}
pub fn set_extents(&mut self, extents: [f64; 6]) -> bool {
if !extents.iter().all(|v| v.is_finite()) {
return false;
}
let distance = (extents[4] - extents[1]) * 0.5 / (self.fov * 0.5).tan();
if !(1e-6..=1e15).contains(&distance) {
return false;
}
if self.zoom {
// Orthographic zoom changes scale while retaining the view center.
self.eye += self.rotation * DVec3::Z * (distance - self.distance);
self.distance = distance;
}
true
}
/// Honor the driver's Lock Horizon option in our Z-up world. Keep the
/// proposed eye and viewing direction away from the poles. A pitch through
/// a pole must stop there: rebuilding world-up on the far side would reverse
/// screen-right and turn the view through 180 degrees.
fn lock_horizon(&mut self, previous: DQuat, orbit_about_pivot: bool) {
if !self.rotate {
return;
}
// Leave enough room for the app camera's f32 quaternion round-trip.
const POLE_MARGIN: f64 = 1e-4;
let proposed_normal = self.rotation * DVec3::Z;
let mut normal = proposed_normal;
let previous_right = (previous * DVec3::X)
.truncate()
.extend(0.)
.try_normalize()
.unwrap_or(DVec3::X);
let right = DVec3::Z.cross(normal);
let crossed_pole =
right.dot(previous_right) < 0. && (self.rotation * DVec3::X).dot(previous_right) > 0.;
let right = if crossed_pole || right.length_squared() < POLE_MARGIN.powi(2) {
let horizontal_normal = previous_right.cross(DVec3::Z);
normal = horizontal_normal * POLE_MARGIN
+ DVec3::Z * normal.z.signum() * (1. - POLE_MARGIN.powi(2)).sqrt();
// Apply the rejected pitch to the eye around the SDK's orbit pivot
// too. Correcting orientation alone lets a held orbit carry the eye
// over the pole and shifts the object across the screen.
if orbit_about_pivot {
let correction = DQuat::from_rotation_arc(proposed_normal, normal);
self.eye = self.pivot + correction * (self.eye - self.pivot);
}
previous_right
} else {
right.normalize()
};
self.rotation =
DQuat::from_mat3(&glam::DMat3::from_cols(right, normal.cross(right), normal))
.normalize();
}
}
#[derive(Clone, Debug)]
pub struct Action {
pub id: String,
pub label: String,
pub description: String,
pub icon: Option<&'static [u8]>,
}
#[derive(Default)]
struct Bridge {
view: Option<View>,
pending_view: Option<View>,
actions: Vec<Action>,
commands: VecDeque<(Target, String)>,
focus: bool,
moving: bool,
in_transaction: bool,
frame_time: Option<f64>,
reset_motion: bool,
driver_settings_changed: bool,
lock_horizon: bool,
orbit_about_pivot: bool,
pan: pan::Pan,
status: Option<Status>,
ever_connected: bool,
wake: Option<iced::futures::channel::mpsc::Sender<()>>,
#[cfg(windows)]
control: Option<worker::Wake>,
}
impl Bridge {
fn wake(&mut self) {
if let Some(wake) = &mut self.wake {
let _ = wake.try_send(());
}
}
}
#[derive(Clone, Default)]
pub struct Service(Arc<Mutex<Bridge>>);
impl Hash for Service {
fn hash<H: Hasher>(&self, state: &mut H) {
Arc::as_ptr(&self.0).hash(state);
}
}
impl Service {
pub fn status(&self) -> Status {
if !cfg!(any(windows, test)) {
return Status::Unsupported;
}
self.0
.lock()
.unwrap()
.status
.clone()
.unwrap_or(Status::Starting)
}
pub fn visible(&self) -> bool {
self.0.lock().unwrap().ever_connected
}
pub fn moving(&self) -> bool {
let bridge = self.0.lock().unwrap();
if let Some(view) = bridge.view.as_ref().filter(|v| v.planar()) {
bridge.pan.moving(view.zoom)
} else {
bridge.moving
}
}
pub fn frame(&self, milliseconds: f64) {
let mut bridge = self.0.lock().unwrap();
if bridge.focus
&& bridge
.view
.as_ref()
.is_some_and(|v| v.planar() && v.motion_enabled)
{
let zoom = bridge.view.as_ref().unwrap().zoom;
let delta = bridge.pan.frame(milliseconds, zoom);
if delta != DVec3::ZERO {
let view = bridge.view.as_mut().unwrap();
view.eye -= view.rotation
* delta.truncate().extend(0.)
* view.half_height()
* 2.
* view.pan_speed;
// Lift/pressure owns zoom in both projections. Keep the center
// fixed and leave push/tilt exclusively responsible for pan.
let distance = (view.distance * (delta.z * 2.).exp()).clamp(1e-6, 1e15);
view.eye += view.rotation * DVec3::Z * (distance - view.distance);
view.distance = distance;
bridge.pending_view = bridge.view.clone();
}
// Also wake after a stale report stops motion, so rendering can idle.
bridge.wake();
return;
}
if bridge.focus && bridge.moving {
bridge.frame_time = Some(milliseconds);
#[cfg(windows)]
if let Some(wake) = &bridge.control {
wake.signal();
}
}
}
#[cfg(test)]
pub fn test_connect(&self) {
self.0.lock().unwrap().status = Some(Status::Ready);
}
#[cfg(test)]
pub fn test_view(&self) -> Option<View> {
self.0.lock().unwrap().view.clone()
}
#[cfg(test)]
pub fn test_pan_report(&self, axes: [i16; 6]) {
let report: Vec<_> = std::iter::once(1)
.chain(axes.into_iter().flat_map(i16::to_le_bytes))
.collect();
self.0.lock().unwrap().pan.report(&report);
}
#[cfg(test)]
pub fn test_propose(&self, change: impl FnOnce(&mut View)) {
let mut bridge = self.0.lock().unwrap();
let mut view = bridge.view.clone().expect("focused drawing snapshot");
change(&mut view);
bridge.pending_view = Some(view);
bridge.moving = true;
}
pub fn set_actions(&self, actions: Vec<Action>) {
self.0.lock().unwrap().actions = actions;
}
/// Rebase callbacks that arrived while the UI applied the previous frame.
/// They still belong to the same gesture, despite the scene revision bump.
pub fn acknowledge(&self, previous: &Target, applied: &Target) {
let mut bridge = self.0.lock().unwrap();
if previous.same_context(applied) {
let Bridge {
view, pending_view, ..
} = &mut *bridge;
for view in [view, pending_view].into_iter().flatten() {
if &view.target == previous {
view.target = applied.clone();
}
}
}
}
pub fn sync(&self, view: Option<View>, focus: bool) {
let mut bridge = self.0.lock().unwrap();
let context_changed = match (&bridge.view, &view) {
(Some(old), Some(new)) => !old.target.same_context(&new.target),
(None, None) => false,
_ => true,
};
let changed = bridge.view.as_ref().map(|v| &v.target) != view.as_ref().map(|v| &v.target)
|| bridge.focus != focus
|| bridge
.view
.as_ref()
.map(|v| (v.rotate, v.zoom, v.motion_enabled))
!= view.as_ref().map(|v| (v.rotate, v.zoom, v.motion_enabled));
if changed {
bridge.reset_motion = true;
bridge.frame_time = None;
bridge.pan.clear();
bridge.pending_view = None;
if context_changed || bridge.focus != focus {
bridge.commands.clear();
bridge.moving = false;
}
bridge.in_transaction = false;
bridge.view = view;
} else if bridge.pending_view.is_none() {
bridge.view = view;
}
bridge.focus = focus;
if changed {
#[cfg(windows)]
if let Some(wake) = &bridge.control {
wake.signal();
}
}
}
pub fn drain(&self) -> (Option<View>, Vec<(Target, String)>) {
let mut bridge = self.0.lock().unwrap();
let view = if bridge.in_transaction {
None
} else {
bridge.pending_view.take()
};
(view, bridge.commands.drain(..).collect())
}
pub fn subscription(&self) -> iced::Subscription<()> {
#[cfg(windows)]
{
iced::Subscription::run_with(self.clone(), navlib::subscription)
}
#[cfg(not(windows))]
{
iced::Subscription::none()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(super) fn view() -> View {
View {
target: Target {
tab: 1,
layout: "Model".into(),
viewport: None,
tile: 0,
generation: 0,
},
eye: DVec3::new(0., 0., 10.),
rotation: DQuat::IDENTITY,
distance: 10.,
fov: 1.,
perspective: false,
aspect: 1.,
model_bounds: [-1., -1., -1., 1., 1., 1.],
selection_bounds: None,
pointer: DVec3::ZERO,
pivot: DVec3::ZERO,
pivot_visible: false,
rotate: false,
zoom: false,
motion_enabled: true,
units_to_meters: 1.,
pan_speed: 1.,
}
}
#[test]
fn pan_only_preserves_depth_rotation_and_zoom() {
let mut v = view();
v.rotation = DQuat::from_rotation_x(0.7);
let initial = v.clone();
let normal = initial.rotation * DVec3::Z;
let proposed = DMat4::from_rotation_translation(
DQuat::from_rotation_y(0.4),
v.eye + DVec3::new(4., 5., 6.),
);
assert!(v.set_affine(proposed.to_cols_array()));
assert!((v.eye - initial.eye).dot(normal).abs() < 1e-10);
assert_eq!(v.rotation, initial.rotation);
assert!(v.set_extents([-20., -20., -20., 20., 20., 20.]));
assert_eq!(v.distance, initial.distance);
}
#[test]
fn invalid_driver_matrices_do_not_corrupt_the_camera() {
let mut v = view();
let eye = v.eye;
assert!(!v.set_affine([f64::NAN; 16]));
assert!(!v.set_affine([0.; 16]));
assert!(!v.set_extents([0.; 6]));
assert_eq!(v.eye, eye);
}
#[test]
fn context_and_focus_changes_discard_queued_input() {
let service = Service::default();
let v = view();
service.sync(Some(v.clone()), true);
service.0.lock().unwrap().pending_view = Some(v.clone());
service.sync(Some(v.clone()), false);
assert!(service.drain().0.is_none());
service.0.lock().unwrap().pending_view = Some(v.clone());
let mut other = v;
other.target.tab = 2;
service.sync(Some(other), true);
assert!(service.drain().0.is_none());
}
#[test]
fn paper_cannot_rotate_in_any_mode() {
for mode in NavigationMode::ALL {
assert!(!mode.allows_rotation(true));
}
assert!(NavigationMode::Auto.allows_rotation(false));
assert!(!NavigationMode::PanOnly.allows_rotation(false));
}
#[test]
fn navigation_frames_are_drained_atomically() {
let service = Service::default();
service.sync(Some(view()), true);
service.test_propose(|v| v.eye.x += 2.);
service.0.lock().unwrap().in_transaction = true;
assert!(service.drain().0.is_none());
service.0.lock().unwrap().in_transaction = false;
assert_eq!(service.drain().0.unwrap().eye.x, 2.);
assert!(service.drain().0.is_none());
}
#[test]
fn camera_updates_retain_buttons_but_document_changes_drop_them() {
let service = Service::default();
let mut v = view();
service.sync(Some(v.clone()), true);
service
.0
.lock()
.unwrap()
.commands
.push_back((v.target.clone(), "UNDO".into()));
v.target.generation += 1;
service.sync(Some(v.clone()), true);
assert_eq!(service.drain().1.len(), 1);
service
.0
.lock()
.unwrap()
.commands
.push_back((v.target.clone(), "UNDO".into()));
v.target.viewport = Some(123);
service.sync(Some(v), true);
assert!(service.drain().1.is_empty());
}
#[test]
fn applying_a_frame_retains_newer_callbacks_in_the_same_gesture() {
let service = Service::default();
let mut applied = view();
service.sync(Some(applied.clone()), true);
service.0.lock().unwrap().reset_motion = false;
service.test_propose(|v| v.eye.x = 1.);
let first = service.drain().0.unwrap();
service.test_propose(|v| v.eye.x = 2.);
service.0.lock().unwrap().in_transaction = true;
applied.eye = first.eye;
applied.target.generation += 1;
service.acknowledge(&first.target, &applied.target);
service.sync(Some(applied.clone()), true);
assert!(service.drain().0.is_none());
assert!(!service.0.lock().unwrap().reset_motion);
service.0.lock().unwrap().in_transaction = false;
let second = service.drain().0.unwrap();
assert_eq!(second.eye.x, 2.);
assert_eq!(second.target, applied.target);
// A subsequent wheel/mouse camera revision must reset the SDK model.
applied.target.generation += 1;
service.sync(Some(applied), true);
assert!(service.0.lock().unwrap().reset_motion);
}
}
File diff suppressed because it is too large. Load diff
+138
View File
@@ -0,0 +1,138 @@
//! Planar input in device space: pushes and tilts have the same meaning.
//! The SDK's camera matrices have already combined orbit, pan, and zoom, so
//! they cannot reliably recover these independent physical inputs.
use glam::{DVec2, DVec3};
use std::time::{Duration, Instant};
#[derive(Default)]
pub(super) struct Pan {
axes: [i16; 6],
received: Option<Instant>,
frame_time: Option<f64>,
}
impl Pan {
pub fn clear(&mut self) {
*self = Self::default();
}
/// SpaceMouse USB uses split translation/rotation reports; Wireless also
/// sends a combined report. Ignore buttons, battery, and truncated reports.
pub fn report(&mut self, report: &[u8]) -> bool {
let (start, count) = match (report.first(), report.len()) {
(Some(1), 13..) => (0, 6),
(Some(1), 7) => (0, 3),
(Some(2), 7..) => (3, 3),
_ => return false,
};
for i in 0..count {
self.axes[start + i] = i16::from_le_bytes([report[1 + i * 2], report[2 + i * 2]]);
}
self.received = Some(Instant::now());
if !self.moving(true) {
self.frame_time = None;
}
true
}
pub fn moving(&self, zoom: bool) -> bool {
direction(self.axes) != DVec2::ZERO || (zoom && response(self.axes[2] as f64) != 0.)
}
pub fn frame(&mut self, milliseconds: f64, zoom: bool) -> DVec3 {
if !milliseconds.is_finite()
|| self
.received
.is_none_or(|t| t.elapsed() > Duration::from_millis(350))
{
self.clear();
return DVec3::ZERO;
}
let dt = self.frame_time.map_or(1. / 60., |old| {
((milliseconds - old) / 1000.).clamp(0., 0.05)
});
self.frame_time = Some(milliseconds);
direction(self.axes).extend(if zoom {
response(self.axes[2] as f64)
} else {
0.
}) * dt
}
}
fn response(v: f64) -> f64 {
let n = (v.abs() / 350.).min(1.);
if n <= 0.08 {
0.
} else {
v.signum() * ((n - 0.08) / 0.92).powf(1.5)
}
}
fn direction(axes: [i16; 6]) -> DVec2 {
fn strongest(push: f64, tilt: f64) -> f64 {
// A hand often pushes and tilts together. Do not double the speed.
if push.abs() >= tilt.abs() {
push
} else {
tilt
}
}
let right = strongest(response(axes[0] as f64), response(-(axes[4] as f64)));
let up = strongest(response(-(axes[1] as f64)), response(-(axes[3] as f64)));
DVec2::new(right, up).clamp_length_max(1.)
}
#[cfg(test)]
mod tests {
use super::*;
fn report(axes: [i16; 6]) -> Vec<u8> {
std::iter::once(1)
.chain(axes.into_iter().flat_map(i16::to_le_bytes))
.collect()
}
#[test]
fn pushes_and_tilts_pan_in_the_same_screen_directions() {
for (push, tilt, expected) in [
([350, 0, 0, 0, 0, 0], [0, 0, 0, 0, -350, 0], DVec2::X),
([-350, 0, 0, 0, 0, 0], [0, 0, 0, 0, 350, 0], -DVec2::X),
([0, -350, 0, 0, 0, 0], [0, 0, 0, -350, 0, 0], DVec2::Y),
([0, 350, 0, 0, 0, 0], [0, 0, 0, 350, 0, 0], -DVec2::Y),
] {
assert_eq!(direction(push), expected);
assert_eq!(direction(tilt), expected);
}
assert_eq!(direction([350, 0, 0, 0, -350, 0]), DVec2::X);
}
#[test]
fn twist_vertical_pressure_and_sensor_noise_do_not_pan() {
assert_eq!(direction([0, 0, 350, 0, 0, -350]), DVec2::ZERO);
assert_eq!(direction([10, -10, 350, 20, -20, 350]), DVec2::ZERO);
}
#[test]
fn held_input_continues_and_release_or_disconnect_stops_it() {
let mut pan = Pan::default();
assert!(pan.report(&report([350, 0, 0, 0, 0, 0])));
for frame in 0..20 {
assert!(pan.frame(frame as f64 * 16., false).x > 0.);
}
assert!(pan.report(&report([0; 6])));
assert!(!pan.moving(false));
assert_eq!(pan.frame(400., false), DVec3::ZERO);
pan.report(&report([350, 0, 0, 0, 0, 0]));
pan.received = Some(Instant::now() - Duration::from_secs(1));
assert_eq!(pan.frame(500., false), DVec3::ZERO);
assert!(!pan.moving(false));
}
#[test]
fn split_and_combined_reports_agree_and_other_reports_are_ignored() {
let mut pan = Pan::default();
let full = report([1, 2, 3, 4, 5, 6]);
assert!(pan.report(&full[..7]));
let mut rotation = vec![2];
rotation.extend_from_slice(&full[7..]);
assert!(pan.report(&rotation));
assert_eq!(pan.axes, [1, 2, 3, 4, 5, 6]);
assert!(!pan.report(&[1, 2, 3]));
assert!(!pan.report(&[23; 13]));
assert_eq!(pan.axes, [1, 2, 3, 4, 5, 6]);
}
}
+235
View File
@@ -0,0 +1,235 @@
//! Windows multi-axis HID receiver for push-or-tilt planar navigation.
//! Registers only Generic Desktop / Multi-axis Controller, never a keyboard
//! or ordinary mouse. The driver continues to own buttons and 3D navigation.
use super::*;
use std::sync::atomic::{AtomicIsize, Ordering};
use windows_sys::Win32::{
Foundation::{HANDLE, HWND, LPARAM, LRESULT, WPARAM},
System::LibraryLoader::GetModuleHandleW,
UI::{Input::*, WindowsAndMessaging::*},
};
pub(super) struct Listener {
window: Arc<AtomicIsize>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Listener {
pub fn start(shared: Arc<Mutex<Bridge>>) -> Self {
let window = Arc::new(AtomicIsize::new(0));
let worker_window = window.clone();
let thread = std::thread::Builder::new()
.name("spacemouse-pan".into())
.spawn(move || {
if let Err(error) = run(shared, &worker_window) {
eprintln!("SpaceMouse pan input: {error}");
}
})
.ok();
Self { window, thread }
}
}
impl Drop for Listener {
fn drop(&mut self) {
let hwnd = self.window.swap(-1, Ordering::AcqRel);
if hwnd > 0 {
unsafe {
PostMessageW(hwnd as HWND, WM_CLOSE, 0, 0);
}
}
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn run(shared: Arc<Mutex<Bridge>>, window: &AtomicIsize) -> Result<(), String> {
let class: Vec<u16> = "OpenCADStudio.SpaceMouse.Pan\0".encode_utf16().collect();
unsafe {
let instance = GetModuleHandleW(std::ptr::null());
let definition = WNDCLASSW {
lpfnWndProc: Some(procedure),
hInstance: instance,
lpszClassName: class.as_ptr(),
..Default::default()
};
// Another subscription may have registered the class earlier.
if RegisterClassW(&definition) == 0 {
let mut existing = WNDCLASSW::default();
if GetClassInfoW(instance, class.as_ptr(), &mut existing) == 0 {
return Err(std::io::Error::last_os_error().to_string());
}
}
let hwnd = CreateWindowExW(
0,
class.as_ptr(),
std::ptr::null(),
0,
0,
0,
0,
0,
HWND_MESSAGE,
std::ptr::null_mut(),
instance,
std::ptr::null(),
);
if hwnd.is_null() {
return Err(std::io::Error::last_os_error().to_string());
}
SetWindowLongPtrW(hwnd, GWLP_USERDATA, Arc::as_ptr(&shared) as isize);
if window
.compare_exchange(0, hwnd as isize, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
DestroyWindow(hwnd);
return Ok(());
}
let device = RAWINPUTDEVICE {
usUsagePage: 1,
usUsage: 8,
dwFlags: RIDEV_INPUTSINK | RIDEV_DEVNOTIFY,
hwndTarget: hwnd,
};
if RegisterRawInputDevices(&device, 1, std::mem::size_of::<RAWINPUTDEVICE>() as u32) == 0 {
window.store(0, Ordering::Release);
DestroyWindow(hwnd);
return Err(std::io::Error::last_os_error().to_string());
}
let mut message = MSG::default();
while GetMessageW(&mut message, std::ptr::null_mut(), 0, 0) > 0 {
TranslateMessage(&message);
DispatchMessageW(&message);
}
let remove = RAWINPUTDEVICE {
dwFlags: RIDEV_REMOVE,
hwndTarget: std::ptr::null_mut(),
..device
};
RegisterRawInputDevices(&remove, 1, std::mem::size_of::<RAWINPUTDEVICE>() as u32);
window.store(0, Ordering::Release);
}
Ok(())
}
unsafe extern "system" fn procedure(hwnd: HWND, message: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
if message == WM_DESTROY {
unsafe {
PostQuitMessage(0);
}
return 0;
}
let pointer = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const Mutex<Bridge>;
if !pointer.is_null() {
let shared = unsafe { &*pointer };
if message == WM_INPUT {
input(shared, lp as HRAWINPUT);
} else if message == WM_INPUT_DEVICE_CHANGE && wp == GIDC_REMOVAL as usize {
if let Ok(mut bridge) = shared.lock() {
bridge.pan.clear();
bridge.wake();
}
}
}
unsafe { DefWindowProcW(hwnd, message, wp, lp) }
}
fn is_spacemouse(device: HANDLE) -> bool {
let mut info = RID_DEVICE_INFO {
cbSize: std::mem::size_of::<RID_DEVICE_INFO>() as u32,
..Default::default()
};
let mut size = info.cbSize;
unsafe {
if GetRawInputDeviceInfoW(
device,
RIDI_DEVICEINFO,
(&mut info as *mut RID_DEVICE_INFO).cast(),
&mut size,
) == u32::MAX
|| info.dwType != RIM_TYPEHID
{
return false;
}
let hid = info.Anonymous.hid;
// Logitech's legacy SpaceMouse IDs and current 3Dconnexion vendor ID.
hid.usUsagePage == 1
&& hid.usUsage == 8
&& (hid.dwVendorId == 0x256f
|| (hid.dwVendorId == 0x046d && (0xc603..=0xc631).contains(&hid.dwProductId)))
}
}
/// Older NavLib versions report device.present as always true. Enumerate the
/// registered multi-axis HID devices as well, so availability reflects hardware.
pub(super) fn connected() -> Result<bool, String> {
let element_size = std::mem::size_of::<RAWINPUTDEVICELIST>() as u32;
let mut count = 0;
unsafe {
if GetRawInputDeviceList(std::ptr::null_mut(), &mut count, element_size) == u32::MAX {
return Err(std::io::Error::last_os_error().to_string());
}
let mut devices = vec![RAWINPUTDEVICELIST::default(); count as usize];
let read = GetRawInputDeviceList(devices.as_mut_ptr(), &mut count, element_size);
if read == u32::MAX {
return Err(std::io::Error::last_os_error().to_string());
}
Ok(devices
.iter()
.take(read as usize)
.any(|device| device.dwType == RIM_TYPEHID && is_spacemouse(device.hDevice)))
}
}
fn input(shared: &Mutex<Bridge>, handle: HRAWINPUT) {
// u64 storage provides the alignment required by RAWINPUTHEADER.
let mut buffer = [0_u64; 128];
let mut size = std::mem::size_of_val(&buffer) as u32;
let header_size = std::mem::size_of::<RAWINPUTHEADER>();
let read = unsafe {
GetRawInputData(
handle,
RID_INPUT,
buffer.as_mut_ptr().cast(),
&mut size,
header_size as u32,
)
};
if read == u32::MAX
|| read < (header_size + 8) as u32
|| read as usize > std::mem::size_of_val(&buffer)
{
return;
}
let header = unsafe { &*buffer.as_ptr().cast::<RAWINPUTHEADER>() };
if header.dwType != RIM_TYPEHID || !is_spacemouse(header.hDevice) {
return;
}
let bytes = unsafe { std::slice::from_raw_parts(buffer.as_ptr().cast::<u8>(), read as usize) };
let width =
u32::from_le_bytes(bytes[header_size..header_size + 4].try_into().unwrap()) as usize;
let count =
u32::from_le_bytes(bytes[header_size + 4..header_size + 8].try_into().unwrap()) as usize;
if width == 0 || count > (bytes.len() - header_size - 8) / width {
return;
}
let Ok(mut bridge) = shared.lock() else {
return;
};
if !bridge.focus
|| !bridge
.view
.as_ref()
.is_some_and(|v| v.planar() && v.motion_enabled)
{
return;
}
let zoom = bridge.view.as_ref().unwrap().zoom;
let was_moving = bridge.pan.moving(zoom);
let mut accepted = false;
for report in bytes[header_size + 8..].chunks_exact(width).take(count) {
accepted |= bridge.pan.report(report);
}
if accepted && (was_moving || bridge.pan.moving(zoom)) {
bridge.wake();
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Wait for app work and dispatch messages to windows owned by the SDK thread.
//! A channel-only wait leaves Windows-delivered driver settings unprocessed.
use std::{sync::Arc, time::Duration};
use windows_sys::Win32::{
Foundation::{CloseHandle, HANDLE},
System::Threading::{CreateEventW, SetEvent},
UI::WindowsAndMessaging::*,
};
struct Event(isize);
impl Drop for Event {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0 as HANDLE);
}
}
}
#[derive(Clone)]
pub(super) struct Wake(Arc<Event>);
impl Wake {
pub fn new() -> Result<Self, std::io::Error> {
let event = unsafe { CreateEventW(std::ptr::null(), 0, 0, std::ptr::null()) };
if event.is_null() {
Err(std::io::Error::last_os_error())
} else {
Ok(Self(Arc::new(Event(event as isize))))
}
}
pub fn signal(&self) {
unsafe {
SetEvent(self.0 .0 as HANDLE);
}
}
pub fn wait(&self, timeout: Duration) {
let handle = self.0 .0 as HANDLE;
unsafe {
MsgWaitForMultipleObjectsEx(
1,
&handle,
timeout.as_millis().min(u32::MAX as u128) as u32,
QS_ALLINPUT,
MWMO_INPUTAVAILABLE,
);
let mut message = MSG::default();
// Bound each batch so an SDK message burst cannot starve shutdown
// or app frames. The next wait returns immediately if more remain.
for _ in 0..256 {
if PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) == 0 {
break;
}
TranslateMessage(&message);
DispatchMessageW(&message);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use windows_sys::Win32::{Foundation::*, System::LibraryLoader::GetModuleHandleW};
static DELIVERED: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn procedure(
window: HWND,
message: u32,
wp: WPARAM,
lp: LPARAM,
) -> LRESULT {
if message == WM_APP {
DELIVERED.fetch_add(1, Ordering::SeqCst);
return 0;
}
unsafe { DefWindowProcW(window, message, wp, lp) }
}
#[test]
fn sdk_worker_dispatches_window_messages_and_app_wakes() {
std::thread::spawn(|| unsafe {
let wake = Wake::new().unwrap();
let class: Vec<u16> = "OpenCADStudio.SpaceMouse.Worker.Test\0"
.encode_utf16()
.collect();
let instance = GetModuleHandleW(std::ptr::null());
let definition = WNDCLASSW {
lpfnWndProc: Some(procedure),
hInstance: instance,
lpszClassName: class.as_ptr(),
..Default::default()
};
assert_ne!(RegisterClassW(&definition), 0);
let window = CreateWindowExW(
0,
class.as_ptr(),
std::ptr::null(),
0,
0,
0,
0,
0,
HWND_MESSAGE,
std::ptr::null_mut(),
instance,
std::ptr::null(),
);
assert!(!window.is_null());
assert_ne!(PostMessageW(window, WM_APP, 0, 0), 0);
wake.wait(Duration::from_secs(1));
assert_eq!(DELIVERED.load(Ordering::SeqCst), 1);
let other = wake.clone();
std::thread::spawn(move || other.signal()).join().unwrap();
let start = std::time::Instant::now();
wake.wait(Duration::from_secs(5));
assert!(start.elapsed() < Duration::from_secs(1));
DestroyWindow(window);
UnregisterClassW(class.as_ptr(), instance);
})
.join()
.unwrap();
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod config;
pub mod discussions; pub mod discussions;
pub mod entities; pub mod entities;
pub mod i18n; pub mod i18n;
pub(crate) mod input;
pub mod io; pub mod io;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub mod mcp; pub mod mcp;
+13 -3
View File
@@ -10921,11 +10921,21 @@ vis_index={:.1} visible_probe={:.1}",
/// when something is selected. `None` keeps the current camera target as /// when something is selected. `None` keeps the current camera target as
/// the orbit centre. (#229) /// the orbit centre. (#229)
pub fn orbit_pivot(&self) -> Option<glam::DVec3> { pub fn orbit_pivot(&self) -> Option<glam::DVec3> {
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() { if self.selected.is_empty() {
return None; return None;
} }
let block = self.current_layout_block_handle(); let model_view = self.current_layout == "Model" || self.active_viewport.is_some();
let scale = if self.current_layout == "Model" { 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( crate::scene::annotative::scale_handle_by_name(
&self.document, &self.document,
&self.document.header.current_annotation_scale, &self.document.header.current_annotation_scale,
@@ -10982,7 +10992,7 @@ vis_index={:.1} visible_probe={:.1}",
} }
} }
if any { if any {
Some((min + max) * 0.5) Some((min, max))
} else { } else {
None None
} }
+39
View File
@@ -446,6 +446,45 @@ impl Scene {
self.camera.borrow().rotation 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 { pub fn active_camera_projection(&self) -> view::camera::Projection {
if let Some(handle) = self.active_viewport { if let Some(handle) = self.active_viewport {
if let Some(camera) = self.camera_for_viewport(handle) { if let Some(camera) = self.camera_for_viewport(handle) {
+1 -1
View File
@@ -752,7 +752,7 @@ impl Camera {
// ── Internal helpers ─────────────────────────────────────────────────── // ── Internal helpers ───────────────────────────────────────────────────
/// Derive yaw and pitch from the current quaternion. /// 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; let eye_dir = self.rotation * Vec3::Z;
self.pitch = eye_dir.z.clamp(-1.0, 1.0).asin(); 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 { self.yaw = if eye_dir.x.abs() < 1e-6 && eye_dir.y.abs() < 1e-6 {
+7
View File
@@ -1,6 +1,7 @@
//! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info //! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info
pub mod status_menu; pub mod status_menu;
pub(crate) mod spacemouse;
pub mod statusbar_config; pub mod statusbar_config;
pub mod statusbar_menu; pub mod statusbar_menu;
@@ -135,6 +136,7 @@ impl StatusBar {
parametric_conflicts: usize, parametric_conflicts: usize,
// What is drawing the scene. Only a degraded verdict shows anything. // What is drawing the scene. Only a degraded verdict shows anything.
gpu_status: &'a crate::scene::pipeline::GpuStatus, gpu_status: &'a crate::scene::pipeline::GpuStatus,
spacemouse: Option<Element<'a, Message>>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let StatusMenuData { let StatusMenuData {
layout_names, layout_names,
@@ -241,6 +243,11 @@ impl StatusBar {
// when the width can't hold them all on one line. // when the width can't hold them all on one line.
let vis = |p: StatusPill| config.is_visible(p); let vis = |p: StatusPill| config.is_visible(p);
let mut pills: Vec<Element<'_, Message>> = Vec::new(); let mut pills: Vec<Element<'_, Message>> = 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 // 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 // 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 // dismissed. This stays for the session and reopens it. Many people
+78
View File
@@ -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.)
}
+4
View File
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
/// Identifies a toggleable status-bar pill. /// Identifies a toggleable status-bar pill.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
pub enum StatusPill { pub enum StatusPill {
SpaceMouse,
Coords, Coords,
Ortho, Ortho,
Lwt, Lwt,
@@ -37,6 +38,7 @@ impl StatusPill {
/// Every pill, in status-bar display order. Drives both the bar layout and /// Every pill, in status-bar display order. Drives both the bar layout and
/// the customization menu. /// the customization menu.
pub const ALL: &'static [StatusPill] = &[ pub const ALL: &'static [StatusPill] = &[
StatusPill::SpaceMouse,
StatusPill::Coords, StatusPill::Coords,
StatusPill::Ortho, StatusPill::Ortho,
StatusPill::Lwt, StatusPill::Lwt,
@@ -62,6 +64,7 @@ impl StatusPill {
/// Stable identifier used for persistence. /// Stable identifier used for persistence.
pub fn id(self) -> &'static str { pub fn id(self) -> &'static str {
match self { match self {
StatusPill::SpaceMouse => "spacemouse",
StatusPill::Coords => "coords", StatusPill::Coords => "coords",
StatusPill::Ortho => "ortho", StatusPill::Ortho => "ortho",
StatusPill::Lwt => "lwt", StatusPill::Lwt => "lwt",
@@ -88,6 +91,7 @@ impl StatusPill {
/// Label shown in the customization menu. /// Label shown in the customization menu.
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
StatusPill::SpaceMouse => "SpaceMouse",
StatusPill::Coords => "Coordinates", StatusPill::Coords => "Coordinates",
StatusPill::Ortho => "Ortho Mode", StatusPill::Ortho => "Ortho Mode",
StatusPill::Lwt => "Show Lineweight", StatusPill::Lwt => "Show Lineweight",
+4
View File
@@ -1,3 +1,4 @@
pub(crate) mod spacemouse;
use crate::app::config::UiThemeConfig; use crate::app::config::UiThemeConfig;
use crate::app::settings::CursorType; use crate::app::settings::CursorType;
use crate::app::Message; use crate::app::Message;
@@ -153,6 +154,7 @@ pub fn view_window<'a>(
cursor_size: i32, cursor_size: i32,
selection: SelectionPrefs, selection: SelectionPrefs,
prefs: AppPrefs, prefs: AppPrefs,
spacemouse: Element<'a, Message>,
snap_angle_input: &'a str, snap_angle_input: &'a str,
drawing_prefs: DrawingPrefs, drawing_prefs: DrawingPrefs,
folders: Folders, folders: Folders,
@@ -1135,6 +1137,8 @@ pub fn view_window<'a>(
let user_prefs = column![ let user_prefs = column![
text(crate::t!("User Preferences")).size(15), text(crate::t!("User Preferences")).size(15),
Space::new().height(10), Space::new().height(10),
spacemouse,
Space::new().height(12),
text(crate::t!("Zoom")).size(15), text(crate::t!("Zoom")).size(15),
Space::new().height(10), Space::new().height(10),
row![ row![
+135
View File
@@ -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()
}