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

+18 -2
View File
@@ -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"
+32
View File
@@ -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<Message>),
@@ -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,
+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.
fn insunits_to_mm(code: i16) -> Option<f64> {
pub(super) fn insunits_to_mm(code: i16) -> Option<f64> {
Some(match code {
1 => 25.4, // Inches
2 => 304.8, // Feet
+2
View File
@@ -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,
+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
/// 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<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,
"COMMANDSPACE" => Message::CommandSpace,
"CANCEL" => Message::CommandEscape,
+2
View File
@@ -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.
+43
View File
@@ -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<Message> {
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);
+2 -2
View File
@@ -922,7 +922,7 @@ impl OpenCADStudio {
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
// 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);
+30
View File
@@ -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,
+4
View File
@@ -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