Files
OneCAD/crates/ui-egui/src/space_mouse.rs
T

450 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 3D mouse input, shared by the desktop and web apps. Each app reads the device its own way (the
//! desktop through HID, the web app through the browser's Gamepad API) and hands its six raw axes
//! to [`navigate_frame`] once per frame. This module maps them, applies the dead zone and drops
//! 3DxWare's emulated wheel; the page showing the camera moves it (`navigation::device`), so the
//! cap does what that page's camera may do.
use std::time::Duration;
use crate::SolveApp;
/// What a 3Dconnexion device reports at full deflection, in raw units, on every axis.
pub const FULL_SCALE: i16 = 350;
/// One reading of the six axes in the device's raw units: X right, Y towards the user, Z down,
/// and the rotations about those axes (right-handed).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Axes {
pub translation: [i16; 3],
pub rotation: [i16; 3],
}
impl Axes {
/// Some axis is past the dead zone: the cap is being moved.
pub fn moving(self) -> bool {
self.translation.iter().chain(&self.rotation).any(|&v| normalized(v) != 0.0)
}
}
/// Seconds after the cap last moved that wheel events are still taken for 3DxWare's: one can be
/// queued just before the cap stops and only reach the next frame.
pub const WHEEL_QUIET_S: f64 = 0.15;
/// 3DxWare turns the cap's motion into mouse-wheel turns for programs it has no profile for (its
/// keyboard and mouse emulation), browsers included (#1336, #1380). Both apps read the cap
/// themselves, so those are dropped: wheel events while the cap is moving and for
/// [`WHEEL_QUIET_S`] after. 3DxWare sends them only well past the dead zone (from about a third
/// of full deflection), never at rest, so a wheel turn with the cap at rest is the user's.
#[derive(Debug, Default)]
pub struct WheelGuard {
moved_at: Option<f64>,
}
impl WheelGuard {
/// Takes this frame's reading (`now` in seconds) and drops the frame's wheel events if they
/// are 3DxWare's.
pub fn filter(&mut self, moving: bool, now: f64, events: &mut Vec<egui::Event>) {
if moving {
self.moved_at = Some(now);
}
let recent = self.moved_at.is_some_and(|at| (0.0..=WHEEL_QUIET_S).contains(&(now - at)));
if recent {
events.retain(|e| !matches!(e, egui::Event::MouseWheel { .. } | egui::Event::Zoom(_)));
}
}
}
/// Where one physical axis goes: its logical axis, and how.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AxisRule {
pub output: usize,
pub enabled: bool,
pub reversed: bool,
pub scale: f64,
}
/// The physical-to-logical axis mapping 3DxWare applies (the desktop reads it from the driver's
/// profile; direct reads bypass the driver).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DriverMapping {
pub axes: [AxisRule; 6],
pub overall_scale: f64,
}
impl Default for DriverMapping {
fn default() -> Self {
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
}
}
impl DriverMapping {
/// What 3DxWare applies by default in object mode (#1172): the physical Ry and Rz swapped and
/// reversed, so a tilt left/right rolls and a twist spins.
pub fn object_mode() -> Self {
let mut mapping = Self::default();
mapping.axes[4] = AxisRule { output: 5, enabled: true, reversed: true, scale: 1.0 };
mapping.axes[5] = AxisRule { output: 4, enabled: true, reversed: true, scale: 1.0 };
mapping
}
/// The mapping with the user's per-axis choices folded in: `reverse` flips and `speed` scales
/// each physical axis of the cap. A speed that is not a finite number counts as 1.
pub fn with_user_axes(mut self, reverse: [bool; 6], speed: [f32; 6]) -> Self {
for ((rule, reverse), speed) in self.axes.iter_mut().zip(reverse).zip(speed) {
rule.reversed ^= reverse;
rule.scale *= if speed.is_finite() { f64::from(speed).clamp(0.0, 3.0) } else { 1.0 };
}
self
}
pub fn apply(self, axes: Axes) -> Motion {
let raw = [
normalized(axes.translation[0]),
normalized(axes.translation[1]),
normalized(axes.translation[2]),
normalized(axes.rotation[0]),
normalized(axes.rotation[1]),
normalized(axes.rotation[2]),
];
let mut logical = [0.0; 6];
for (input, rule) in self.axes.iter().enumerate() {
if rule.enabled {
let sign = if rule.reversed { -1.0 } else { 1.0 };
if let Some(slot) = logical.get_mut(rule.output) {
*slot = raw[input] * sign * rule.scale * self.overall_scale;
}
}
}
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
}
}
/// The logical axes after the mapping, each in -1..1.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Motion {
pub translation: [f64; 3],
pub rotation: [f64; 3],
}
/// One frame of 3D mouse motion, waiting for the page that shows the camera: the logical axes,
/// and how many seconds of motion they stand for (the frame's time, scaled by the speed).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct DeviceMotion {
pub motion: Motion,
pub seconds: f64,
}
/// Hands one frame of 3D mouse input, `seconds` after the previous frame, to the page showing
/// the camera. Nothing happens with navigation switched off, on the start page or while a file
/// picker is open. Returns whether there was motion to hand over; it then asks for the next frame
/// itself.
pub fn navigate_frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMapping, axes: Axes, seconds: f64) -> bool {
if !app.preferences.space_mouse_enabled || app.home.open || app.file_picker_busy() || axes == Axes::default() {
return false;
}
let speed = f64::from(app.preferences.space_mouse_speed);
if !speed.is_finite() {
return false;
}
app.begin_external_navigation();
// A long gap (the first frame after a pause) moves as far as a 50 ms one.
let step = if seconds.is_finite() { seconds.min(0.05) } else { 0.0 };
app.viewport.device_motion = Some(DeviceMotion {
motion: mapping.with_user_axes(app.preferences.space_mouse_reverse, app.preferences.space_mouse_axis_speed).apply(axes),
seconds: step * speed.clamp(0.1, 5.0),
});
ctx.request_repaint_after(Duration::from_millis(16));
true
}
/// A small dead zone avoids drift at rest. A full cap deflection takes roughly one viewport
/// per second to pan and about two radians per second to turn, independent of frame rate.
fn normalized(value: i16) -> f64 {
let v = f64::from(value);
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use solidcraft_engine::Session;
use solidcraft_engine::render::Camera;
/// The camera math the page applies the motion with (`navigation::device`): `sketch` keeps
/// the orientation, as editing a sketch does.
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
crate::navigation::device_camera(cam, motion, seconds, height, !sketch, reverse_zoom);
}
/// One frame: the 3D mouse hands its motion over, and the modelling viewport applies it.
fn frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMapping, axes: Axes, seconds: f64) -> bool {
let handed = navigate_frame(app, ctx, mapping, axes, seconds);
let page = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
crate::navigation::device(app, page, crate::navigation::NavCaps::MODEL);
handed
}
fn raw(axes: Axes) -> Motion {
DriverMapping::default().apply(axes)
}
fn app() -> SolveApp {
let mut app = SolveApp::new(Session::default(), Services::default());
app.home.open = false;
app
}
#[test]
fn per_axis_choices_flip_and_scale_one_physical_axis() {
let mapping = DriverMapping::default().with_user_axes([false, true, false, false, false, false], [1.0, 1.0, 1.0, 0.5, f32::NAN, 1.0]);
let still = Motion::default();
assert_eq!(mapping.apply(Axes { translation: [0, 350, 0], rotation: [0; 3] }).translation[1], -raw(PUSH).translation[1]);
assert_eq!(
mapping.apply(Axes { translation: [350, 0, 0], rotation: [0; 3] }).translation[0],
raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }).translation[0]
);
assert_eq!(mapping.apply(Axes { translation: [0; 3], rotation: [350, 0, 0] }).rotation[0], 0.5);
assert_eq!(mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }).rotation[1], 1.0);
assert_eq!(mapping.apply(Axes::default()), still);
let off = DriverMapping::default().with_user_axes([false; 6], [0.0; 6]);
assert_eq!(off.apply(PUSH), still);
}
const PUSH: Axes = Axes { translation: [0, 350, 0], rotation: [0; 3] };
fn wheel(dy: f32) -> egui::Event {
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, dy),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
}
}
fn other() -> egui::Event {
egui::Event::PointerMoved(egui::pos2(1.0, 2.0))
}
#[test]
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
let mut cam = Camera::default();
let before = cam;
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
assert_ne!(cam.target, before.target);
assert!(cam.distance < before.distance);
assert_ne!(cam.yaw, before.yaw);
assert_ne!(cam.pitch, before.pitch);
assert_ne!(cam.roll, before.roll);
let orientation = (cam.yaw, cam.pitch, cam.roll);
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
assert!(cam.is_valid());
}
#[test]
fn individual_axes_have_physical_spacemouse_meaning() {
let before = Camera::default();
let mut slide = before;
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_ne!(slide.target, before.target);
assert_eq!(slide.distance, before.distance);
let mut push = before;
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_eq!(push.target, before.target);
assert!(push.distance < before.distance);
let mut lift = before;
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_ne!(lift.target, before.target);
assert_eq!(lift.distance, before.distance);
let mut tilt = before;
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
assert_ne!(tilt.pitch, before.pitch);
let mut roll = before;
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
assert_ne!(roll.yaw, before.yaw);
let mut twist = before;
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
assert_ne!(twist.roll, before.roll);
}
#[test]
fn reverse_zoom_setting_applies_to_spacemouse() {
let before = Camera::default();
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
let mut normal = before;
navigate(&mut normal, motion, 0.02, 600.0, false, false);
let mut reversed = before;
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
assert!(normal.distance < before.distance);
assert!(reversed.distance > before.distance);
}
#[test]
fn object_mode_tilts_roll_and_twists_spin() {
let before = Camera::default();
let mut tilt = before;
navigate(&mut tilt, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((tilt.yaw, tilt.pitch), (before.yaw, before.pitch));
assert_ne!(tilt.roll, before.roll);
let mut twist = before;
navigate(&mut twist, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_ne!(twist.yaw, before.yaw);
assert_eq!((twist.pitch, twist.roll), (before.pitch, before.roll));
// The physical tilt right (Ry−) rolls one way and the clockwise twist (Rz+) spins the
// other, as 3DxWare's profile has them (#1172).
let tilt_right = DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, -350, 0] });
assert_eq!(tilt_right, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, 1.0] });
let twist_clockwise = DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] });
assert_eq!(twist_clockwise, Motion { translation: [0.0; 3], rotation: [0.0, -1.0, 0.0] });
// Translations and the forward tilt are the raw axes.
let all = Axes { translation: [100, -200, 300], rotation: [-150, 0, 0] };
assert_eq!(DriverMapping::object_mode().apply(all), DriverMapping::default().apply(all));
}
#[test]
fn the_dead_zone_is_rest() {
assert!(!Axes::default().moving());
assert!(!Axes { translation: [20, -20, 0], rotation: [0, 20, -20] }.moving());
for i in 0..6 {
let mut values = [0_i16; 6];
values[i] = 21;
let axes = Axes { translation: [values[0], values[1], values[2]], rotation: [values[3], values[4], values[5]] };
assert!(axes.moving(), "axis {i}");
values[i] = -21;
let axes = Axes { translation: [values[0], values[1], values[2]], rotation: [values[3], values[4], values[5]] };
assert!(axes.moving(), "axis {i} reversed");
}
assert!(Axes { translation: [i16::MIN, 0, 0], rotation: [0, 0, i16::MAX] }.moving());
}
#[test]
fn a_mapping_never_reaches_past_the_six_axes() {
let mut mapping = DriverMapping::default();
mapping.axes[0].output = 6;
mapping.axes[1].output = usize::MAX;
let motion = mapping.apply(Axes { translation: [350, 350, 350], rotation: [350; 3] });
assert_eq!(motion.translation, [0.0, 0.0, 1.0]);
assert_eq!(motion.rotation, [1.0; 3]);
}
#[test]
fn a_frame_moves_the_view_and_keeps_the_next_frame_coming() {
let ctx = egui::Context::default();
let mut app = app();
let before = app.cam;
assert!(frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02));
assert!(app.cam.distance < before.distance);
// At rest, or with no time passed, nothing moves.
let still = app.cam;
assert!(!frame(&mut app, &ctx, DriverMapping::default(), Axes::default(), 0.02));
assert!(frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.0));
assert_eq!(app.cam.distance, still.distance);
}
#[test]
fn an_open_file_picker_holds_the_view_until_it_closes() {
let ctx = egui::Context::default();
let mut app = app();
let slot: crate::picker_test_support::Slot = Default::default();
crate::picker_test_support::open(&mut app, &slot);
app.pick_open(|_, _| {});
assert!(app.file_picker_busy());
let before = app.cam;
assert!(!frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02));
assert_eq!(app.cam.distance, before.distance);
// Cancelled: the 3D mouse moves the view again.
*slot.borrow_mut() = Some(None);
crate::file_picker::tick(&mut app, &ctx);
assert!(!app.file_picker_busy());
assert!(frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02));
assert!(app.cam.distance < before.distance);
}
#[test]
fn a_long_gap_between_frames_moves_as_far_as_a_short_one() {
let ctx = egui::Context::default();
let (mut short, mut long) = (app(), app());
frame(&mut short, &ctx, DriverMapping::default(), PUSH, 0.05);
frame(&mut long, &ctx, DriverMapping::default(), PUSH, 30.0);
assert_eq!(short.cam.distance, long.cam.distance);
for seconds in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
let mut app = app();
let before = app.cam;
frame(&mut app, &ctx, DriverMapping::default(), PUSH, seconds);
assert_eq!(app.cam.distance, before.distance, "{seconds}");
}
}
#[test]
fn navigation_off_the_start_page_and_a_hostile_speed_leave_the_view_alone() {
let ctx = egui::Context::default();
let mut off = app();
off.preferences.space_mouse_enabled = false;
let mut home = app();
home.home.open = true;
let mut nan = app();
nan.preferences.space_mouse_speed = f32::NAN;
for mut app in [off, home, nan] {
let before = app.cam;
assert!(!frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02));
assert_eq!(app.cam.distance, before.distance);
}
// Speed follows the preference, within 0.1 to 5 times.
let speeds = [0.5, 2.0, 5.0, f32::MAX, 0.25, 0.0, -3.0];
let distances: Vec<f64> = speeds
.iter()
.map(|&speed| {
let mut app = app();
app.preferences.space_mouse_speed = speed;
frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02);
assert!(app.cam.is_valid(), "{speed}");
app.cam.distance
})
.collect();
assert!(distances[1] < distances[0]);
assert_eq!(distances[3], distances[2], "past 5 times is 5 times");
// Under 0.1 times is 0.1 times (the f32 preference can't hold 0.1 exactly): still a
// motion, less than at 0.25.
assert_eq!(distances[6], distances[5], "under 0.1 times is 0.1 times");
assert!(distances[4] < distances[5] && distances[5] < app().cam.distance);
}
#[test]
fn wheel_turns_are_dropped_while_the_cap_moves_and_kept_at_rest() {
let mut guard = WheelGuard::default();
// At rest, the wheel is the user's.
let mut events = vec![wheel(100.0), other()];
guard.filter(false, 10.0, &mut events);
assert_eq!(events.len(), 2);
// Moving: 3DxWare's wheel (and a pinch zoom) goes; everything else stays.
let mut events = vec![wheel(-100.0), other(), egui::Event::Zoom(1.1), wheel(100.0)];
guard.filter(true, 11.0, &mut events);
assert_eq!(events, vec![other()]);
// Just after the cap stops, a queued one still goes.
let mut events = vec![wheel(100.0)];
guard.filter(false, 11.0 + WHEEL_QUIET_S - 0.01, &mut events);
assert!(events.is_empty());
// Later, the wheel is the user's again.
let mut events = vec![wheel(100.0)];
guard.filter(false, 11.0 + WHEEL_QUIET_S + 0.01, &mut events);
assert_eq!(events.len(), 1);
// A clock that goes back never swallows the user's wheel.
let mut events = vec![wheel(100.0)];
guard.filter(false, 5.0, &mut events);
assert_eq!(events.len(), 1);
let mut events = vec![wheel(100.0)];
guard.filter(false, f64::NAN, &mut events);
assert_eq!(events.len(), 1);
}
}