//! Application policy for device navigation. All scene writes run on the UI //! thread, preserving active commands and the scene's viewport locks. use super::{Message, ModalKind, OpenCADStudio}; use crate::input::spacemouse::{Action, NavigationMode, Target, View}; use crate::scene::view::camera::Projection; use iced::Task; #[derive(Default)] pub(super) struct SelectionCache( std::cell::RefCell)>>, ); impl SelectionCache { fn bounds( &self, scene: &crate::scene::Scene, target: &Target, ) -> Option<(glam::DVec3, glam::DVec3)> { let mut cache = self.0.borrow_mut(); if let Some((stamp, geometry, selection, bounds)) = cache.as_ref() { if stamp.same_context(target) && *geometry == scene.geometry_epoch && *selection == scene.selection_generation { return *bounds; } } let bounds = scene.navigation_selection_bounds(); *cache = Some(( target.clone(), scene.geometry_epoch, scene.selection_generation, bounds, )); bounds } } inventory::submit!(crate::command::CommandRegistration { names: &[ "SPACEMOUSE", "SPACEMOUSEPAUSE", "SPACEMOUSEPAN", "SPACEMOUSEPANZOOM", "SPACEMOUSEAUTO", "SPACEMOUSE3D" ], }); impl OpenCADStudio { fn navigation_target(&self) -> Target { let tab = &self.tabs[self.active_tab]; Target { tab: tab.id, layout: format!("{}:{:?}", tab.scene.current_layout, tab.active_block_edit), viewport: tab.scene.active_viewport.map(|h| h.value()), tile: tab.scene.active_model_tile.get(), generation: tab.scene.camera_generation, } } pub(super) fn spacemouse_sheet(&self) -> bool { let scene = &self.tabs[self.active_tab].scene; scene.current_layout != "Model" && scene.active_viewport.is_none() } pub(super) fn spacemouse_label(&self) -> String { if !self.spacemouse_preferences.enabled { return crate::t!("Disabled").into_owned(); } if self.spacemouse_paused { return crate::t!("Paused").into_owned(); } if self.tabs[self.active_tab].scene.navigation_locked() { return crate::t!("Viewport locked").into_owned(); } if self.spacemouse.status() != crate::input::spacemouse::Status::Ready { return crate::t!(self.spacemouse.status().label()).into_owned(); } let mode = self.spacemouse_preferences.mode; let effective = if self.spacemouse_sheet() && mode.allows_zoom() { NavigationMode::PanZoom } else if mode == NavigationMode::Auto { NavigationMode::Full3D } else { mode }; crate::t!(effective.label()).into_owned() } pub(super) fn sync_spacemouse(&self) { let tab = &self.tabs[self.active_tab]; let scene = &tab.scene; let available = self.spacemouse_preferences.enabled && !tab.is_start && self.opening.is_none() && self.spacemouse.status() == crate::input::spacemouse::Status::Ready; if !available { self.spacemouse.sync(None, false); return; } // The driver's settings dialog must still be able to query this view // after it takes keyboard focus. Gate input separately from its metadata. let allowed = self.spacemouse_focused && self.active_modal.is_none(); let camera = scene.navigation_camera(); let (w, h) = scene.selection.borrow().vp_size; let bounds = scene.active_model_tile_bounds(w, h); let aspect = scene .active_viewport .and_then(|handle| match scene.document.get_entity(handle) { Some(acadrust::EntityType::Viewport(vp)) => Some(vp.width / vp.height.max(1e-6)), _ => None, }) .unwrap_or((bounds.width / bounds.height.max(1.)).max(0.01) as f64); let center = camera.target; let span = glam::DVec3::splat(camera.distance.max(1.) as f64); let (min, max) = camera .model_bounds .or_else(|| { scene .model_space_extents() .map(|(min, max)| (min.as_dvec3(), max.as_dvec3())) }) .unwrap_or((center - span, center + span)); let target = self.navigation_target(); let selected = self.spacemouse_selection.bounds(scene, &target); let pivot = selected .map(|(min, max)| (min + max) * 0.5) .or_else(|| { self.spacemouse_pivot .as_ref() .filter(|(stamp, _)| stamp.same_context(&target)) .map(|(_, point)| *point) }) .unwrap_or(center); let selection_bounds = selected.map(|(min, max)| [min.x, min.y, min.z, max.x, max.y, max.z]); self.spacemouse.sync( Some(View { target, eye: camera.eye(), rotation: camera.rotation.as_dquat().normalize(), distance: camera.distance as f64, fov: camera.fov_y as f64, perspective: camera.projection == Projection::Perspective, aspect, model_bounds: [min.x, min.y, min.z, max.x, max.y, max.z], selection_bounds, pointer: tab.last_cursor_world, pivot, pivot_visible: self.spacemouse_pivot.is_some(), rotate: self .spacemouse_preferences .mode .allows_rotation(self.spacemouse_sheet()), zoom: self.spacemouse_preferences.mode.allows_zoom(), motion_enabled: !self.spacemouse_paused && !scene.navigation_locked(), units_to_meters: super::properties::insunits_to_mm( scene.document.header.insertion_units, ) .unwrap_or(1000.) / 1000., pan_speed: self.spacemouse_preferences.pan_speed.clamp(10, 300) as f64 / 100. * if self.spacemouse_preferences.pan_reversed { -1. } else { 1. }, }), allowed, ); } pub(super) fn on_spacemouse_wake(&mut self) -> Task { let (view, commands) = self.spacemouse.drain(); if !self.spacemouse_focused || self.active_modal.is_some() || !self.spacemouse_preferences.enabled || self.tabs[self.active_tab].is_start { self.spacemouse_was_moving = false; self.spacemouse_pivot = None; return Task::none(); } let target = self.navigation_target(); let mut tasks = Vec::new(); let moving = self.spacemouse.moving() && !self.spacemouse_paused; if moving && !self.spacemouse_was_moving { self.tabs[self.active_tab].scene.remember_current_view(); self.clear_navigation_hover(self.active_tab); } if !moving && self.spacemouse_was_moving { self.arm_hover_after_navigation(self.active_tab); } self.spacemouse_was_moving = moving; if !moving { self.spacemouse_pivot = None; } if let Some(view) = view.filter(|view| view.target == target && !self.spacemouse_paused) { let i = self.active_tab; let mut camera = self.tabs[i].scene.navigation_camera(); if view.rotate { camera.rotation = view.rotation.as_quat(); camera.sync_yaw_pitch(); } if view.zoom { camera.distance = view.distance as f32; } // Use the same f32 basis/distance as Camera::eye so a callback // that only updates the pivot cannot introduce camera drift. camera.target = view.eye - (camera.rotation * glam::Vec3::Z).as_dvec3() * camera.distance as f64; let started = iced::time::Instant::now(); if self.tabs[i].scene.apply_navigation_camera(camera) { self.spacemouse .acknowledge(&view.target, &self.navigation_target()); self.spacemouse_pivot = (view.pivot_visible && moving) .then_some((self.navigation_target(), view.pivot)); let (w, h) = self.tabs[i].scene.selection.borrow().vp_size; self.reproject_box_anchor(i, w, h); // The pointer may be stationary while the left hand navigates. // Refresh the active command's snap/preview against the new // camera, using the existing pointer path without a click. let cursor = { let selection = self.tabs[i].scene.selection.borrow(); (!selection.left_down && !selection.middle_down && self.tabs[i].active_cmd.is_some()) .then_some(selection.last_move_pos) .flatten() }; if let Some(cursor) = cursor { tasks.push(self.on_viewport_move(cursor)); } self.tabs[i] .scene .record_nav_perf(crate::scene::NavPerfOp::Pan, started); } } for (stamp, command) in commands { if stamp.same_context(&self.navigation_target()) && self.active_modal.is_none() { tasks.push(self.run_action(&command)); } } Task::batch(tasks) } pub(super) fn spacemouse_pivot_overlay(&self) -> Option> { let (target, point) = self.spacemouse_pivot.as_ref()?; if target != &self.navigation_target() || self.active_modal.is_some() { return None; } let scene = &self.tabs[self.active_tab].scene; let (w, h) = scene.selection.borrow().vp_size; let rect = scene .active_viewport .and_then(|handle| scene.viewport_screen_rect(handle, (w, h))) .unwrap_or_else(|| scene.active_model_tile_bounds(w, h)); let p = scene.navigation_camera().project(*point, rect)?; if p.x < 0. || p.y < 0. || p.x > rect.width || p.y > rect.height { return None; } let marker = iced::widget::container(iced::widget::Space::new()) .width(10) .height(10) .style(|theme: &iced::Theme| iced::widget::container::Style { border: iced::Border { color: theme.palette().primary.base.color, width: 2., radius: 5.0.into(), }, ..Default::default() }); Some( iced::widget::pin(marker) .position(iced::Point::new(rect.x + p.x - 5., rect.y + p.y - 5.)) .into(), ) } pub(super) fn open_spacemouse_preferences(&mut self) { self.options_tab = crate::ui::window::options::OptionsTab::UserPreferences; self.active_modal = Some(ModalKind::Options); } pub(super) fn open_spacemouse_driver_settings(&self) -> Task { #[cfg(windows)] { if let Some(program_files) = std::env::var_os("ProgramW6432").or_else(|| std::env::var_os("ProgramFiles")) { let path = std::path::PathBuf::from(program_files) .join("3Dconnexion/3DxWare/3DxWinCore/3DxSmartUi.exe"); if path.is_file() { return Task::perform( async move { let _ = std::process::Command::new(path).spawn(); }, |_| Message::SpaceMouseWake, ); } } } crate::sys::open_url("https://3dconnexion.com/drivers/", self.main_window) } } pub(super) fn actions() -> Vec { use crate::modules::{IconKind, ModuleEvent, RibbonItem, ToolDef}; use std::collections::BTreeMap; let mut names = crate::command::all_registered_command_names(); names.extend([ "UNDO", "REDO", "CANCEL", "QSAVE", "OPEN", "ZOOM", "PLAN", "SPACEMOUSEFIT", "SPACEMOUSETOP", ]); names.sort_unstable(); names.dedup(); let mut actions: BTreeMap = names .into_iter() .map(|name| { let label = match name { "SPACEMOUSE" => "SpaceMouse preferences", "SPACEMOUSEPAUSE" => "Pause / resume SpaceMouse", "SPACEMOUSEPAN" => "SpaceMouse: pan only", "SPACEMOUSEPANZOOM" => "SpaceMouse: pan and zoom", "SPACEMOUSEAUTO" => "SpaceMouse: follow context", "SPACEMOUSE3D" => "SpaceMouse: 3D navigation", "SPACEMOUSEFIT" => "Fit view", "SPACEMOUSETOP" => "Top view", _ => name, }; ( name.into(), Action { id: name.into(), label: crate::t!(label).into_owned(), description: format!("{} ({name})", crate::t!(label)), icon: name .starts_with("SPACEMOUSE") .then_some(crate::ui::window::options::spacemouse::ICON), }, ) }) .collect(); fn describe( actions: &mut BTreeMap, command: &str, label: &str, icon: IconKind, ) { if let Some(action) = actions.get_mut(command) { action.label = crate::t!(label).into_owned(); action.description = format!("{} ({command})", action.label); action.icon = match icon { IconKind::Svg(bytes) => Some(bytes), _ => None, }; } } fn tool(actions: &mut BTreeMap, tool: &ToolDef) { if let ModuleEvent::Command(command) = &tool.event { describe(actions, command, tool.label, tool.icon); } } for module in crate::modules::registry::all_modules() { for group in module.ribbon_groups() { for item in &group.tools { match item { RibbonItem::Tool(t) | RibbonItem::LabeledTool(t) | RibbonItem::LargeTool(t) => { tool(&mut actions, t) } RibbonItem::Dropdown { items, .. } | RibbonItem::LabeledDropdown { items, .. } | RibbonItem::LargeDropdown { items, .. } => { for (label, command, icon) in items { describe(&mut actions, command, label, *icon); } } RibbonItem::ToolGrid { columns } | RibbonItem::StyleComboGroup { rows: columns, .. } => { for t in columns.iter().flatten() { tool(&mut actions, t); } } RibbonItem::LayerComboGroup { row2, row3 } => { for t in row2.iter().chain(row3) { tool(&mut actions, t); } } RibbonItem::PropertiesGroup { match_prop } => tool(&mut actions, match_prop), } } } } actions.into_values().collect() } #[cfg(test)] mod tests { use super::*; use glam::DVec3; fn app() -> OpenCADStudio { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app.tabs[app.active_tab] .scene .selection .borrow_mut() .vp_size = (800., 600.); app.spacemouse_focused = true; app.spacemouse.test_connect(); app.sync_spacemouse(); app } #[test] fn spacemouse_navigation_and_buttons_preserve_a_live_polyline() { let mut app = app(); let _ = app.dispatch_command("PLINE"); for point in [ DVec3::ZERO, DVec3::new(10., 0., 0.), DVec3::new(10., 10., 0.), ] { let _ = app.feed_command(crate::command::StepInput::Point(point)); } let i = app.active_tab; let before = app.tabs[i].scene.navigation_camera(); let epoch = app.tabs[i].scene.geometry_epoch; app.sync_spacemouse(); app.spacemouse.test_propose(|v| v.eye.x += 3.); let _ = app.on_spacemouse_wake(); assert_eq!( app.tabs[i].scene.navigation_camera().target.x, before.target.x + 3. ); assert_eq!(app.tabs[i].scene.geometry_epoch, epoch); assert!(app.tabs[i].active_cmd.is_some()); let _ = app.run_action("UNDO"); assert!(app.tabs[i].active_cmd.is_some()); // Finish after Undo: the last point was removed, leaving one segment. let _ = app.feed_command(crate::command::StepInput::Enter); let polyline = app.tabs[i] .scene .document .entities() .find_map(|e| { if let acadrust::EntityType::LwPolyline(p) = e { Some(p) } else { None } }) .expect("finished polyline"); assert_eq!(polyline.vertices.len(), 2); } #[test] fn spacemouse_refreshes_a_live_command_at_a_stationary_pointer() { let mut app = app(); app.snapper.snap_enabled = false; app.snapper.grid_snap_on = false; app.snapper.otrack_enabled = false; app.ortho_mode = false; app.polar_mode = false; let _ = app.dispatch_command("LINE"); let _ = app.feed_command(crate::command::StepInput::Point(DVec3::ZERO)); let _ = app.on_viewport_move(iced::Point::new(480., 300.)); let i = app.active_tab; let before = app.tabs[i].last_cursor_world; let count = app.tabs[i].scene.document.entities().count(); app.sync_spacemouse(); app.spacemouse.test_propose(|v| v.eye.x += 3.); let _ = app.on_spacemouse_wake(); assert!((app.tabs[i].last_cursor_world.x - before.x - 3.).abs() < 1e-4); assert!(app.tabs[i].active_cmd.is_some()); assert_eq!(app.tabs[i].scene.document.entities().count(), count); } #[test] fn spacemouse_stale_camera_modal_and_pause_block_pending_motion() { for guard in ["camera", "modal", "pause", "focus"] { let mut app = app(); let before = app.tabs[app.active_tab].scene.navigation_camera().target; app.spacemouse.test_propose(|v| v.eye.x += 30.); match guard { "camera" => app.tabs[app.active_tab].scene.camera_generation += 1, "modal" => app.active_modal = Some(ModalKind::Options), "pause" => app.spacemouse_paused = true, _ => app.spacemouse_focused = false, } let _ = app.on_spacemouse_wake(); assert_eq!( app.tabs[app.active_tab].scene.navigation_camera().target, before, "{guard}" ); } } #[test] fn spacemouse_settings_dialog_retains_view_metadata_without_accepting_motion() { for modal in [false, true] { let mut app = app(); app.spacemouse_preferences.mode = NavigationMode::Full3D; app.sync_spacemouse(); let before = app.tabs[app.active_tab].scene.navigation_camera(); if modal { app.active_modal = Some(ModalKind::Options); } else { app.spacemouse_focused = false; } app.sync_spacemouse(); let snapshot = app .spacemouse .test_view() .expect("driver can describe the inactive view"); assert!(snapshot.rotate); assert_eq!(snapshot.distance, before.distance as f64); app.spacemouse.test_propose(|v| v.eye.x += 10.); let _ = app.on_spacemouse_wake(); assert_eq!( app.tabs[app.active_tab].scene.navigation_camera().target, before.target ); } } #[test] fn spacemouse_floating_camera_round_trip_and_lock() { let mut app = app(); let scene = &mut app.tabs[app.active_tab].scene; let paper_camera = scene.camera.borrow().clone(); let mut vp = acadrust::entities::Viewport::default(); vp.width = 200.; vp.height = 100.; vp.view_height = 50.; let handle = scene.add_entity(acadrust::EntityType::Viewport(vp)); scene.active_viewport = Some(handle); let mut camera = scene.navigation_camera(); camera.target = DVec3::new(120., 30., 8.); camera.rotation = glam::Quat::from_euler(glam::EulerRot::XYZ, 0.7, 0.3, 0.2); camera.distance = 80.; assert!(scene.apply_navigation_camera(camera.clone())); let round_trip = scene.navigation_camera(); assert!(round_trip.target.distance(camera.target) < 1e-6); assert!(round_trip.rotation.abs_diff_eq(camera.rotation, 1e-5)); assert!((round_trip.distance - camera.distance).abs() < 1e-4); assert_eq!(scene.camera.borrow().target, paper_camera.target); assert_eq!(scene.camera.borrow().distance, paper_camera.distance); if let Some(acadrust::EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) { vp.status.locked = true; } camera.target.x += 100.; assert!(!scene.apply_navigation_camera(camera)); assert_eq!(scene.navigation_camera().target, round_trip.target); } #[test] fn spacemouse_push_and_tilt_move_the_drawing_in_screen_space_continuously() { for mode in [NavigationMode::PanOnly, NavigationMode::PanZoom] { for projection in [Projection::Orthographic, Projection::Perspective] { for reversed in [false, true] { for (axes, direction) in [ ([350, 0, 0, 0, 0, 0], glam::Vec2::X), ([0, 0, 0, 0, -350, 0], glam::Vec2::X), ([-350, 0, 0, 0, 0, 0], -glam::Vec2::X), ([0, 0, 0, 0, 350, 0], -glam::Vec2::X), ([0, -350, 0, 0, 0, 0], -glam::Vec2::Y), ([0, 0, 0, -350, 0, 0], -glam::Vec2::Y), ([0, 350, 0, 0, 0, 0], glam::Vec2::Y), ([0, 0, 0, 350, 0, 0], glam::Vec2::Y), ] { let mut app = app(); app.spacemouse_preferences.mode = mode; app.spacemouse_preferences.pan_reversed = reversed; let i = app.active_tab; { let mut camera = app.tabs[i].scene.camera.borrow_mut(); camera.rotation = glam::Quat::from_euler(glam::EulerRot::XYZ, 0.7, 0.4, 0.2); camera.projection = projection; } app.sync_spacemouse(); let before = app.tabs[i].scene.navigation_camera(); let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.)); let point = before.target; let screen = before.project(point, bounds).unwrap(); let direction = direction * if reversed { -1. } else { 1. }; let mut last = glam::Vec2::new(screen.x, screen.y); for frame in 0..20 { app.spacemouse.test_pan_report(axes); app.spacemouse.frame(frame as f64 * 16.); let _ = app.on_spacemouse_wake(); app.sync_spacemouse(); let camera = app.tabs[i].scene.navigation_camera(); let moved = camera.project(point, bounds).unwrap(); let moved = glam::Vec2::new(moved.x, moved.y); let displacement = moved - last; assert!(displacement.dot(direction) > 0., "{mode:?}: {axes:?}"); assert!(displacement.perp_dot(direction).abs() < 0.001); assert_eq!(camera.rotation, before.rotation); assert_eq!(camera.distance, before.distance); last = moved; } let stopped = app.tabs[i].scene.navigation_camera().target; app.spacemouse.test_pan_report([0, 0, 0, 0, 0, 350]); app.spacemouse.frame(400.); let _ = app.on_spacemouse_wake(); assert_eq!(app.tabs[i].scene.navigation_camera().target, stopped); } } } } } #[test] fn spacemouse_panzoom_lift_and_pressure_zoom_about_the_center_and_release_stops() { for projection in [Projection::Orthographic, Projection::Perspective] { for mode in [NavigationMode::PanOnly, NavigationMode::PanZoom] { for pressure in [-350, 350] { let mut app = app(); app.spacemouse_preferences.mode = mode; let i = app.active_tab; { let mut camera = app.tabs[i].scene.camera.borrow_mut(); camera.rotation = glam::Quat::from_euler(glam::EulerRot::XYZ, 0.7, 0.4, 0.2); camera.projection = projection; } app.sync_spacemouse(); let before = app.tabs[i].scene.navigation_camera(); let mut distance = before.distance; for frame in 0..20 { app.spacemouse.test_pan_report([0, 0, pressure, 0, 0, 0]); app.spacemouse.frame(frame as f64 * 16.); let _ = app.on_spacemouse_wake(); app.sync_spacemouse(); let camera = app.tabs[i].scene.navigation_camera(); assert!(camera.target.distance(before.target) < 1e-4); assert_eq!(camera.rotation, before.rotation); if mode == NavigationMode::PanOnly { assert_eq!(camera.distance, distance); } else { assert!((camera.distance - distance) * pressure as f32 > 0.); } distance = camera.distance; } app.spacemouse.test_pan_report([0; 6]); app.spacemouse.frame(400.); let _ = app.on_spacemouse_wake(); assert_eq!(app.tabs[i].scene.navigation_camera().distance, distance); assert!(!app.spacemouse.moving()); } } } } #[test] fn spacemouse_preferences_round_trip_and_old_settings_default() { let old: super::super::settings::UserSettings = serde_json::from_str("{}").unwrap(); assert_eq!( old.spacemouse, crate::input::spacemouse::Preferences::default() ); let mut app = app(); app.spacemouse_preferences.mode = NavigationMode::PanOnly; app.spacemouse_preferences.pan_speed = 170; app.spacemouse_preferences.pan_reversed = true; let encoded = serde_json::to_string(&app.current_settings()).unwrap(); let restored: super::super::settings::UserSettings = serde_json::from_str(&encoded).unwrap(); app.spacemouse_preferences.mode = NavigationMode::Auto; app.apply_settings(&restored); assert_eq!(app.spacemouse_preferences.mode, NavigationMode::PanOnly); assert_eq!(app.spacemouse_preferences.pan_speed, 170); assert!(app.spacemouse_preferences.pan_reversed); } #[test] fn spacemouse_action_catalog_has_stable_unique_ids_and_ui_icons() { let actions = actions(); let ids: std::collections::HashSet<_> = actions.iter().map(|a| &a.id).collect(); assert_eq!(ids.len(), actions.len()); for id in [ "LINE", "UNDO", "SPACEMOUSEPAN", "SPACEMOUSEPAUSE", "SPACEMOUSEFIT", ] { assert!(actions.iter().any(|a| a.id == id && !a.label.is_empty())); } assert!(actions.iter().any(|a| a.id == "LINE" && a.icon.is_some())); assert!(crate::app::commands::is_transparent("SPACEMOUSEPAN")); assert!(crate::app::commands::start_allowed("SPACEMOUSE")); } }