forked from 1b-agent/opencadstudio-upstream
Add Windows SpaceMouse navigation through 3DxWare
This commit is contained in:
1 parent
612124b84b
commit
6a63726eff
32 files changed
+3510
-12
No files matched your search
+18
-2
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
//! Device integrations. Camera ownership and CAD commands stay in the app.
|
||||
pub(crate) mod spacemouse;
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user