Add Windows SpaceMouse navigation through 3DxWare

This commit is contained in:
Christopher Overbeck committed 2026-09-15 13:20:19 -05:00
1 parent 612124b84b
commit 6a63726eff
32 files changed
+3510 -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.
@@ -1882,6 +1891,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>),
@@ -3650,6 +3670,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
@@ -354,6 +354,7 @@ impl OpenCADStudio {
self.block_palette.placing = None;
}
self.control_settle();
self.sync_spacemouse();
task
}
@@ -369,6 +370,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
@@ -5324,7 +5324,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
@@ -1200,6 +1200,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.
@@ -2199,6 +2202,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)
@@ -2658,6 +2673,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
+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!(windows) {
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 entities;
pub mod i18n;
pub(crate) mod input;
pub mod io;
#[cfg(not(target_arch = "wasm32"))]
pub mod mcp;
+13 -3
View File
@@ -10855,11 +10855,21 @@ vis_index={:.1} visible_probe={:.1}",
/// when something is selected. `None` keeps the current camera target as
/// the orbit centre. (#229)
pub fn orbit_pivot(&self) -> Option<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() {
return None;
}
let block = self.current_layout_block_handle();
let scale = if self.current_layout == "Model" {
let model_view = self.current_layout == "Model" || self.active_viewport.is_some();
let block = if self.active_viewport.is_some() {
self.model_space_block_handle()
} else {
self.current_layout_block_handle()
};
let scale = if model_view {
crate::scene::annotative::scale_handle_by_name(
&self.document,
&self.document.header.current_annotation_scale,
@@ -10916,7 +10926,7 @@ vis_index={:.1} visible_probe={:.1}",
}
}
if any {
Some((min + max) * 0.5)
Some((min, max))
} else {
None
}
+39
View File
@@ -446,6 +446,45 @@ impl Scene {
self.camera.borrow().rotation
}
/// Camera owning navigation, including an entered paper-space viewport.
pub(crate) fn navigation_camera(&self) -> view::camera::Camera {
self.active_viewport
.and_then(|h| self.camera_for_viewport(h))
.unwrap_or_else(|| self.camera.borrow().clone())
}
pub(crate) fn navigation_locked(&self) -> bool {
self.active_viewport.is_some_and(|h| {
!matches!(self.document.get_entity(h), Some(acadrust::EntityType::Viewport(v)) if !v.status.locked)
})
}
/// Commit an externally navigated camera through the same floating-view
/// encoding as the ViewCube. Camera coordinates already include view_center.
pub(crate) fn apply_navigation_camera(&mut self, camera: view::camera::Camera) -> bool {
if self.navigation_locked() {
return false;
}
if let Some(handle) = self.active_viewport {
if !self.mutate_active_viewport_camera(|c| *c = camera.clone()) {
return false;
}
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) {
vp.view_target.x = camera.target.x;
vp.view_target.y = camera.target.y;
vp.view_target.z = camera.target.z;
vp.view_center.x = 0.;
vp.view_center.y = 0.;
vp.view_height = camera.ortho_size() as f64 * 2.;
vp.custom_scale = vp.height / vp.view_height;
}
} else {
*self.camera.borrow_mut() = camera;
}
self.camera_generation += 1;
true
}
pub fn active_camera_projection(&self) -> view::camera::Projection {
if let Some(handle) = self.active_viewport {
if let Some(camera) = self.camera_for_viewport(handle) {
+1 -1
View File
@@ -761,7 +761,7 @@ impl Camera {
// ── Internal helpers ───────────────────────────────────────────────────
/// Derive yaw and pitch from the current quaternion.
fn sync_yaw_pitch(&mut self) {
pub(crate) fn sync_yaw_pitch(&mut self) {
let eye_dir = self.rotation * Vec3::Z;
self.pitch = eye_dir.z.clamp(-1.0, 1.0).asin();
self.yaw = if eye_dir.x.abs() < 1e-6 && eye_dir.y.abs() < 1e-6 {
+7
View File
@@ -1,6 +1,7 @@
//! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info
pub mod status_menu;
pub(crate) mod spacemouse;
pub mod statusbar_config;
pub mod statusbar_menu;
@@ -135,6 +136,7 @@ impl StatusBar {
parametric_conflicts: usize,
// What is drawing the scene. Only a degraded verdict shows anything.
gpu_status: &'a crate::scene::pipeline::GpuStatus,
spacemouse: Option<Element<'a, Message>>,
) -> Element<'a, Message> {
let StatusMenuData {
layout_names,
@@ -241,6 +243,11 @@ impl StatusBar {
// when the width can't hold them all on one line.
let vis = |p: StatusPill| config.is_visible(p);
let mut pills: Vec<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
// 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
+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.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
pub enum StatusPill {
SpaceMouse,
Coords,
Ortho,
Lwt,
@@ -37,6 +38,7 @@ impl StatusPill {
/// Every pill, in status-bar display order. Drives both the bar layout and
/// the customization menu.
pub const ALL: &'static [StatusPill] = &[
StatusPill::SpaceMouse,
StatusPill::Coords,
StatusPill::Ortho,
StatusPill::Lwt,
@@ -62,6 +64,7 @@ impl StatusPill {
/// Stable identifier used for persistence.
pub fn id(self) -> &'static str {
match self {
StatusPill::SpaceMouse => "spacemouse",
StatusPill::Coords => "coords",
StatusPill::Ortho => "ortho",
StatusPill::Lwt => "lwt",
@@ -88,6 +91,7 @@ impl StatusPill {
/// Label shown in the customization menu.
pub fn label(self) -> &'static str {
match self {
StatusPill::SpaceMouse => "SpaceMouse",
StatusPill::Coords => "Coordinates",
StatusPill::Ortho => "Ortho Mode",
StatusPill::Lwt => "Show Lineweight",
+4
View File
@@ -1,3 +1,4 @@
pub(crate) mod spacemouse;
use crate::app::config::UiThemeConfig;
use crate::app::settings::CursorType;
use crate::app::Message;
@@ -153,6 +154,7 @@ pub fn view_window<'a>(
cursor_size: i32,
selection: SelectionPrefs,
prefs: AppPrefs,
spacemouse: Element<'a, Message>,
snap_angle_input: &'a str,
drawing_prefs: DrawingPrefs,
folders: Folders,
@@ -1135,6 +1137,8 @@ pub fn view_window<'a>(
let user_prefs = column![
text(crate::t!("User Preferences")).size(15),
Space::new().height(10),
spacemouse,
Space::new().height(12),
text(crate::t!("Zoom")).size(15),
Space::new().height(10),
row![
+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()
}