forked from 1b-agent/opencadstudio-upstream
The codec and kernel repositories and crates are renamed opencadcodec and opencadkernel. Inside OCS they are depended on under short keys, so code reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node graph engine). File-format and protocol spellings stay as they were: the ACIS product id `acadrust`, the ACADRUST_* switches and the plugin version keys `acadrust_version` / `acadrust_source`. A button left of MCP on the command line opens a translucent node graph over the viewport, with a palette of object, input, math, logic, list, text and geometry nodes added by click or drag. An object node owns drawing entities and its rows are the Properties panel's own rows for them: the single-object page is extracted from refresh_properties as entity_property_sections, values use the control protocol's property_json, and writes go through set_property_value with a property target override, so the panel, MCP and the graph share one read/write path. A list reaching an object row makes one entity per element. Each graph action is one undo step; the canvas pans and zooms. Co-Authored-By: Claude Opus 5.5 <[email protected]>
660 lines
27 KiB
Rust
660 lines
27 KiB
Rust
//! 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(codec::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;
|
|
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();
|
|
for (command, (label, icon)) in crate::modules::registry::ribbon_commands() {
|
|
if let Some(action) = actions.get_mut(command.as_str()) {
|
|
action.label = crate::t!(*label).into_owned();
|
|
action.description = format!("{} ({command})", action.label);
|
|
action.icon = match icon {
|
|
IconKind::Svg(bytes) => Some(*bytes),
|
|
IconKind::Glyph(_) => None,
|
|
};
|
|
}
|
|
}
|
|
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 codec::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 = codec::entities::Viewport::default();
|
|
vp.width = 200.;
|
|
vp.height = 100.;
|
|
vp.view_height = 50.;
|
|
let handle = scene.add_entity(codec::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(codec::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"));
|
|
}
|
|
}
|