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>
This commit is contained in:
authored and GitHub committed 2026-10-15 18:42:44 -04:00
1 parent b44ada89e5
commit a519f3cf74
18 files changed
+922 -243

No files matched your search

+351
View File
@@ -0,0 +1,351 @@
//! 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);
}
}