Files
solidcraft-upstream/crates/ui-egui/src/space_mouse.rs
T
a519f3cf74 Web app: 3D mouse navigation through the Gamepad API (#1473)
The web app reads a 3D mouse through the Gamepad API and drives the camera through navigation::device, so it follows each page's navigation capabilities (#1336).

Co-authored-by: CamWight <CamWight@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:42:44 -04:00

352 lines
15 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)
}
}
/// 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
}
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.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
}
const PUSH: Axes = Axes { translation: [0, 350, 0], rotation: [0; 3] };
#[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);
}
}