forked from 1b-agent/opencadstudio-upstream
Merge pull request #1299: Add Windows SpaceMouse navigation
This commit is contained in:
commit
af847ad92e
32 files changed
+3508
-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.
|
||||
@@ -1895,6 +1904,17 @@ pub enum ArrowKey {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
SpaceMouseWake,
|
||||
SpaceMouseFrame(iced::time::Instant),
|
||||
SpaceMouseFocus(iced::window::Id, bool),
|
||||
SpaceMouseEnabled(bool),
|
||||
SpaceMouseMode(crate::input::spacemouse::NavigationMode),
|
||||
SpaceMousePanSpeed(u16),
|
||||
SpaceMousePanReversed(bool),
|
||||
SpaceMousePause,
|
||||
SpaceMousePreferences,
|
||||
SpaceMouseDriverSettings,
|
||||
SpaceMouseDetails,
|
||||
ControlRequest(control::Envelope),
|
||||
PollWebControl,
|
||||
ControlStep(String, Box<Message>),
|
||||
@@ -3718,6 +3738,18 @@ impl OpenCADStudio {
|
||||
show_grid: false,
|
||||
dyn_input: true,
|
||||
options_tab: crate::ui::window::options::OptionsTab::General,
|
||||
spacemouse: {
|
||||
let service = crate::input::spacemouse::Service::default();
|
||||
service.set_actions(navigation::actions());
|
||||
service
|
||||
},
|
||||
spacemouse_preferences: crate::input::spacemouse::Preferences::default(),
|
||||
spacemouse_paused: false,
|
||||
spacemouse_focused: false,
|
||||
spacemouse_details: false,
|
||||
spacemouse_was_moving: false,
|
||||
spacemouse_pivot: None,
|
||||
spacemouse_selection: navigation::SelectionCache::default(),
|
||||
texteditmode: false,
|
||||
quick_dimension_snap_priority: 0,
|
||||
dimension_continue_mode: 1,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -357,6 +357,7 @@ impl OpenCADStudio {
|
||||
self.block_palette.placing = None;
|
||||
}
|
||||
self.control_settle();
|
||||
self.sync_spacemouse();
|
||||
task
|
||||
}
|
||||
|
||||
@@ -372,6 +373,48 @@ impl OpenCADStudio {
|
||||
|
||||
fn update_inner(&mut self, msg: Message) -> Task<Message> {
|
||||
match msg {
|
||||
Message::SpaceMouseWake => self.on_spacemouse_wake(),
|
||||
Message::SpaceMouseFrame(time) => {
|
||||
self.spacemouse.frame(
|
||||
time.saturating_duration_since(self.start).as_secs_f64() * 1000.,
|
||||
);
|
||||
Task::none()
|
||||
}
|
||||
Message::SpaceMouseFocus(id, focused) => {
|
||||
if Some(id) == self.main_window {
|
||||
self.spacemouse_focused = focused;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::SpaceMouseEnabled(enabled) => {
|
||||
self.spacemouse_preferences.enabled = enabled;
|
||||
Task::none()
|
||||
}
|
||||
Message::SpaceMouseMode(mode) => {
|
||||
self.spacemouse_preferences.mode = mode;
|
||||
Task::none()
|
||||
}
|
||||
Message::SpaceMousePanSpeed(speed) => {
|
||||
self.spacemouse_preferences.pan_speed = speed.clamp(10, 300);
|
||||
Task::none()
|
||||
}
|
||||
Message::SpaceMousePanReversed(reversed) => {
|
||||
self.spacemouse_preferences.pan_reversed = reversed;
|
||||
Task::none()
|
||||
}
|
||||
Message::SpaceMousePause => {
|
||||
self.spacemouse_paused = !self.spacemouse_paused;
|
||||
Task::none()
|
||||
}
|
||||
Message::SpaceMousePreferences => {
|
||||
self.open_spacemouse_preferences();
|
||||
Task::none()
|
||||
}
|
||||
Message::SpaceMouseDriverSettings => self.open_spacemouse_driver_settings(),
|
||||
Message::SpaceMouseDetails => {
|
||||
self.spacemouse_details = !self.spacemouse_details;
|
||||
Task::none()
|
||||
}
|
||||
Message::ControlRequest(envelope) => {
|
||||
let (response, task) = self.control_request(envelope.request);
|
||||
envelope.reply.send(response);
|
||||
|
||||
@@ -922,7 +922,7 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_viewport_move(&mut self, p: Point) -> Task<Message> {
|
||||
pub(in crate::app) fn on_viewport_move(&mut self, p: Point) -> Task<Message> {
|
||||
// A ribbon dropdown is open over the viewport. Its backdrop
|
||||
// cannot swallow cursor motion — in iced 0.14 mouse_area/opaque
|
||||
// capture only button presses, never CursorMoved — so the move
|
||||
@@ -5284,7 +5284,7 @@ properties={:.1}ms picked={}",
|
||||
/// model-space view moves (zoom/pan), so the selection rectangle tracks the
|
||||
/// drawing instead of staying frozen at its original pixel. No-op when no
|
||||
/// box is in progress. (#234)
|
||||
pub(super) fn reproject_box_anchor(&mut self, i: usize, vw: f32, vh: f32) {
|
||||
pub(in crate::app) fn reproject_box_anchor(&mut self, i: usize, vw: f32, vh: f32) {
|
||||
let world = self.tabs[i].scene.selection.borrow().box_anchor_world;
|
||||
let Some(world) = world else { return };
|
||||
let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
|
||||
|
||||
@@ -1202,6 +1202,9 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
};
|
||||
|
||||
if !thumbnail_capture_clean && !self.layout_settling {
|
||||
if let Some(pivot) = self.spacemouse_pivot_overlay() {
|
||||
viewport_stack = viewport_stack.push(pivot);
|
||||
}
|
||||
// Per-pane input pane_grid goes ABOVE the crosshair overlay so it
|
||||
// receives mouse events (the overlay's `Hidden` cursor would otherwise
|
||||
// starve any layer beneath it). The controls bar is pushed on top of it.
|
||||
@@ -2203,6 +2206,18 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
.map(|s| s.conflicts.len())
|
||||
.unwrap_or(0),
|
||||
&self.gpu_status,
|
||||
(self.spacemouse.visible()
|
||||
|| self.spacemouse_preferences.mode
|
||||
!= crate::input::spacemouse::NavigationMode::Auto)
|
||||
.then(|| {
|
||||
crate::ui::statusbar::spacemouse::view(
|
||||
self.spacemouse_preferences,
|
||||
self.spacemouse.status(),
|
||||
self.spacemouse_paused,
|
||||
self.spacemouse_label(),
|
||||
self.spacemouse_sheet(),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.width(Fill)
|
||||
@@ -2662,6 +2677,21 @@ impl OpenCADStudio {
|
||||
let control = iced::time::every(std::time::Duration::from_millis(50))
|
||||
.map(|_| Message::PollWebControl);
|
||||
iced::Subscription::batch([
|
||||
if self.spacemouse.moving() && self.spacemouse_focused && !self.spacemouse_paused {
|
||||
window::frames().map(Message::SpaceMouseFrame)
|
||||
} else {
|
||||
Subscription::none()
|
||||
},
|
||||
self.spacemouse.subscription().map(|_| Message::SpaceMouseWake),
|
||||
event::listen_with(|event, _, id| match event {
|
||||
iced::Event::Window(window::Event::Focused) => {
|
||||
Some(Message::SpaceMouseFocus(id, true))
|
||||
}
|
||||
iced::Event::Window(window::Event::Unfocused) => {
|
||||
Some(Message::SpaceMouseFocus(id, false))
|
||||
}
|
||||
_ => None,
|
||||
}),
|
||||
control,
|
||||
frames,
|
||||
history_tick,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user