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:
18 files changed
+922
-243
No files matched your search
@@ -10,6 +10,8 @@
|
||||
mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod install;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod space_mouse;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
@@ -21,6 +23,7 @@ mod web {
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::files::{self, Files, Intent};
|
||||
use crate::space_mouse::SpaceMouse;
|
||||
|
||||
const CANVAS_ID: &str = "solidcraft_canvas";
|
||||
const LOADING_ID: &str = "solidcraft_loading";
|
||||
@@ -67,6 +70,7 @@ mod web {
|
||||
/// On-demand CJK font: fetched only when a CJK language is selected.
|
||||
font: solidcraft_ui_egui::fonts::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
space_mouse: SpaceMouse,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
@@ -382,11 +386,14 @@ mod web {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.tick_font(ctx);
|
||||
self.app.logic(ctx);
|
||||
self.space_mouse.logic(&mut self.app, ctx);
|
||||
self.files_tick(ctx);
|
||||
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
files::take_drops(&self.files, raw);
|
||||
// Before the app's own hook, which adds the control channel's events.
|
||||
self.space_mouse.raw_input_hook(ctx, self.app.preferences.space_mouse_enabled, raw);
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
@@ -516,6 +523,7 @@ mod web {
|
||||
recents_synced: false,
|
||||
font: Default::default(),
|
||||
font_result: Rc::new(std::cell::RefCell::new(None)),
|
||||
space_mouse: SpaceMouse::default(),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! The 3D mouse in the browser (#1336), through the Gamepad API.
|
||||
//!
|
||||
//! Chromium browsers list a 3Dconnexion device itself, with its six axes scaled to -1..1 from the
|
||||
//! device's ±350. Firefox lists only joysticks and gamepads (a 3D mouse is a HID multi-axis
|
||||
//! controller), but 3DxWare's virtual joystick carries the same six axes once 3DxWare's profile
|
||||
//! for Firefox sends them there. Both come in the device's own order and signs, so the shared
|
||||
//! navigation takes them with 3DxWare's object-mode mapping, as the Linux app does without a
|
||||
//! profile.
|
||||
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE};
|
||||
|
||||
/// 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.
|
||||
const WHEEL_QUIET_S: f64 = 0.15;
|
||||
|
||||
/// Logitech's vendor id, which older 3Dconnexion devices carry, and those devices' product ids
|
||||
/// (usb.ids). Logitech's own gamepads and wheels carry the same vendor id.
|
||||
const LOGITECH: u16 = 0x046d;
|
||||
const LOGITECH_3D_MICE: [u16; 11] = [0xc603, 0xc605, 0xc606, 0xc621, 0xc623, 0xc625, 0xc626, 0xc627, 0xc628, 0xc629, 0xc62b];
|
||||
/// 3Dconnexion's own vendor id. The browser lists only its 3D mice as gamepads: its mice, and the
|
||||
/// mouse side of its receivers, are not gamepads.
|
||||
const THREEDCONNEXION: u16 = 0x256f;
|
||||
/// 3DxWare's virtual joystick.
|
||||
const VIRTUAL_JOYSTICK: (u16, u16) = (0xbeef, 0x046d);
|
||||
|
||||
/// What a gamepad the browser lists is, when it is a 3D mouse. A device is read before the
|
||||
/// joystick: in a Chromium browser both are listed, and the joystick stays at rest.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Source {
|
||||
Device,
|
||||
VirtualJoystick,
|
||||
}
|
||||
|
||||
/// The vendor and product ids in a gamepad's id: `Name (Vendor: 046d Product: c62b)` in Chromium
|
||||
/// browsers, `046d-c62b-Name` in Firefox.
|
||||
fn vendor_product(id: &str) -> Option<(u16, u16)> {
|
||||
fn hex(digits: &str) -> Option<u16> {
|
||||
if (1..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_hexdigit()) { u16::from_str_radix(digits, 16).ok() } else { None }
|
||||
}
|
||||
if let Some((_, ids)) = id.rsplit_once("Vendor: ") {
|
||||
let (vendor, rest) = ids.split_once(" Product: ")?;
|
||||
let product = rest.split(|c: char| !c.is_ascii_hexdigit()).next()?;
|
||||
return Some((hex(vendor)?, hex(product)?));
|
||||
}
|
||||
let mut parts = id.splitn(3, '-');
|
||||
let (vendor, product, _name) = (parts.next()?, parts.next()?, parts.next()?);
|
||||
Some((hex(vendor)?, hex(product)?))
|
||||
}
|
||||
|
||||
/// Whether a gamepad is a 3D mouse, from the id the browser gives it.
|
||||
pub fn source(id: &str) -> Option<Source> {
|
||||
match vendor_product(id)? {
|
||||
VIRTUAL_JOYSTICK => Some(Source::VirtualJoystick),
|
||||
(THREEDCONNEXION, _) => Some(Source::Device),
|
||||
(LOGITECH, product) if LOGITECH_3D_MICE.contains(&product) => Some(Source::Device),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The six axes in the device's raw units, from a gamepad's axes (-1..1). Fewer than six: not a
|
||||
/// 3D mouse. A value that isn't a number reads as rest.
|
||||
pub fn axes(values: &[f64]) -> Option<Axes> {
|
||||
let raw = |i: usize| values.get(i).map(|&v| if v.is_finite() { (v.clamp(-1.0, 1.0) * f64::from(FULL_SCALE)).round() as i16 } else { 0 });
|
||||
Some(Axes { translation: [raw(0)?, raw(1)?, raw(2)?], rotation: [raw(3)?, raw(4)?, raw(5)?] })
|
||||
}
|
||||
|
||||
/// The 3D mouse to read among the ones listed: the first device, else the first joystick.
|
||||
pub fn choose(found: impl IntoIterator<Item = (Source, Axes)>) -> Option<Axes> {
|
||||
found.into_iter().min_by_key(|(source, _)| *source).map(|(_, axes)| axes)
|
||||
}
|
||||
|
||||
/// 3DxWare turns the cap's motion into mouse-wheel turns in a browser (its zoom emulation). The
|
||||
/// app reads the cap itself, 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(_)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use browser::SpaceMouse;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod browser {
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, DriverMapping, navigate_frame};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
use super::{WheelGuard, axes, choose, source};
|
||||
|
||||
/// How often the page looks for the cap starting to move while nothing else redraws.
|
||||
const WAKE_MS: i32 = 50;
|
||||
|
||||
/// The 3D mouse the browser lists, if any.
|
||||
fn read() -> Option<Axes> {
|
||||
let pads = web_sys::window()?.navigator().get_gamepads().ok()?;
|
||||
choose(pads.iter().filter_map(|pad| {
|
||||
let pad = pad.dyn_into::<web_sys::Gamepad>().ok().filter(web_sys::Gamepad::connected)?;
|
||||
let source = source(&pad.id())?;
|
||||
let values: Vec<f64> = pad.axes().iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect();
|
||||
Some((source, axes(&values)?))
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reads the 3D mouse once per frame and moves the view with it.
|
||||
#[derive(Default)]
|
||||
pub struct SpaceMouse {
|
||||
axes: Axes,
|
||||
/// The previous frame's time and the step since it (seconds).
|
||||
last: Option<f64>,
|
||||
seconds: f64,
|
||||
wheel: WheelGuard,
|
||||
/// Listening for gamepads (from the first frame with navigation on).
|
||||
listening: bool,
|
||||
/// The timer that wakes the app when the cap starts moving runs (from when a 3D mouse
|
||||
/// is first listed).
|
||||
waking: bool,
|
||||
/// Navigation is on (the waker reads it).
|
||||
enabled: Rc<Cell<bool>>,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
/// At the start of a frame: reads the cap and drops 3DxWare's emulated wheel from the
|
||||
/// frame's input, before the app sees it.
|
||||
pub fn raw_input_hook(&mut self, ctx: &egui::Context, enabled: bool, raw: &mut egui::RawInput) {
|
||||
let now = raw.time.unwrap_or_else(|| js_sys::Date::now() / 1000.0);
|
||||
self.seconds = self.last.map_or(0.0, |last| now - last);
|
||||
self.last = Some(now);
|
||||
self.enabled.set(enabled);
|
||||
self.axes = Axes::default();
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
if !self.listening {
|
||||
self.listening = true;
|
||||
listen(ctx);
|
||||
}
|
||||
let reading = read();
|
||||
if reading.is_some() && !self.waking {
|
||||
self.waking = wake(ctx, &self.enabled);
|
||||
}
|
||||
// Inside the dead zone is rest: a cap resting just off zero doesn't keep redrawing.
|
||||
self.axes = reading.filter(|axes| axes.moving()).unwrap_or_default();
|
||||
self.wheel.filter(self.axes.moving(), now, &mut raw.events);
|
||||
}
|
||||
|
||||
pub fn logic(&self, app: &mut SolveApp, ctx: &egui::Context) {
|
||||
navigate_frame(app, ctx, DriverMapping::object_mode(), self.axes, self.seconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3D mouse is listed only once it has been moved on the page: redraw then, so the frame
|
||||
/// finds it without waiting for the pointer.
|
||||
fn listen(ctx: &egui::Context) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let ctx = ctx.clone();
|
||||
let on_connect = Closure::<dyn FnMut()>::new(move || ctx.request_repaint());
|
||||
let _ = window.add_event_listener_with_callback("gamepadconnected", on_connect.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
on_connect.forget();
|
||||
}
|
||||
|
||||
/// The browser sends no event when a gamepad moves, so a timer looks while the app is idle and
|
||||
/// redraws when the cap starts moving; the frames that follow keep going on their own.
|
||||
fn wake(ctx: &egui::Context, enabled: &Rc<Cell<bool>>) -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let (ctx, enabled) = (ctx.clone(), Rc::clone(enabled));
|
||||
let tick = Closure::<dyn FnMut()>::new(move || {
|
||||
if enabled.get() && read().is_some_and(Axes::moving) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
let started = window.set_interval_with_callback_and_timeout_and_arguments_0(tick.as_ref().unchecked_ref(), WAKE_MS).is_ok();
|
||||
// The timer lives as long as the page.
|
||||
tick.forget();
|
||||
started
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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 recognises_3d_mice_in_each_browsers_ids() {
|
||||
// As Edge and Firefox list them (Windows 11, SpaceMouse Pro, 3DxWare 10).
|
||||
assert_eq!(source("SpaceMouse Pro (Vendor: 046d Product: c62b)"), Some(Source::Device));
|
||||
assert_eq!(source("Unknown Gamepad (Vendor: beef Product: 046d)"), Some(Source::VirtualJoystick));
|
||||
assert_eq!(source("beef-046d-Unknown Gamepad"), Some(Source::VirtualJoystick));
|
||||
// 3Dconnexion's own vendor id, any product; the older devices under Logitech's.
|
||||
assert_eq!(source("SpaceMouse Compact (Vendor: 256f Product: c635)"), Some(Source::Device));
|
||||
assert_eq!(source("256f-c652-3Dconnexion Universal Receiver"), Some(Source::Device));
|
||||
assert_eq!(source("46d-c626-SpaceNavigator"), Some(Source::Device));
|
||||
assert_eq!(source("SpaceNavigator (STANDARD GAMEPAD Vendor: 046d Product: c626)"), Some(Source::Device));
|
||||
for product in LOGITECH_3D_MICE {
|
||||
assert_eq!(source(&format!("x (Vendor: 046d Product: {product:04x})")), Some(Source::Device));
|
||||
assert_eq!(source(&format!("046d-{product:04X}-x")), Some(Source::Device));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepads_and_other_logitech_devices_are_not_3d_mice() {
|
||||
for id in [
|
||||
"Logitech Dual Action (STANDARD GAMEPAD Vendor: 046d Product: c216)",
|
||||
"046d-c21d-Logitech Gamepad F310",
|
||||
"G29 Driving Force Racing Wheel (Vendor: 046d Product: c24f)",
|
||||
"046d-c215-Logitech Extreme 3D",
|
||||
"NuLOOQ navigator (Vendor: 046d Product: c640)",
|
||||
"Xbox 360 Controller (XInput STANDARD GAMEPAD)",
|
||||
"45e-28e-Xbox 360 Wired Controller",
|
||||
"beef-046e-Unknown Gamepad",
|
||||
"beee-046d-Unknown Gamepad",
|
||||
"",
|
||||
"-",
|
||||
"--",
|
||||
"256f-",
|
||||
"256f-c635",
|
||||
"Vendor: 256f",
|
||||
"Vendor: 256f Product: ",
|
||||
"x (Vendor: 256fff Product: c635)",
|
||||
"x (Vendor: zz6f Product: c635)",
|
||||
"12345-c635-x",
|
||||
"256f--x",
|
||||
"SpaceMouse Pro",
|
||||
] {
|
||||
assert_eq!(source(id), None, "{id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_never_panic() {
|
||||
// Hostile and odd ids: multi-byte text around the markers, markers repeated or cut short.
|
||||
for id in [
|
||||
"Vendor: é Product: ü",
|
||||
"Vendor: Vendor: 256f Product: c635)",
|
||||
"é-ü-ö",
|
||||
"Ⅰ (Vendor: 256f Product: c6Ⅰ)",
|
||||
"\u{0}-\u{0}-\u{0}",
|
||||
"Vendor: 256f Product: c635 Vendor: ",
|
||||
] {
|
||||
let _ = source(id);
|
||||
}
|
||||
let mut seed = 0x2545_f491_4f6c_dd1d_u64;
|
||||
let alphabet: Vec<char> = "0123456789abcdefABCDEF- ():VendorProductéⅠ\u{0}".chars().collect();
|
||||
for _ in 0..20_000 {
|
||||
let len = (seed % 40) as usize;
|
||||
let id: String = (0..len)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
alphabet[(seed % alphabet.len() as u64) as usize]
|
||||
})
|
||||
.collect();
|
||||
if let Some(Source::Device) = source(&id) {
|
||||
let (vendor, product) = vendor_product(&id).expect("recognised ids have both");
|
||||
assert!(vendor == THREEDCONNEXION || (vendor == LOGITECH && LOGITECH_3D_MICE.contains(&product)), "{id:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_axes_are_the_devices_raw_units() {
|
||||
// Full deflection is the desktop's 350, and the browser's scale keeps the device's steps.
|
||||
let axes = axes(&[1.0, -1.0, 0.5, -0.5, 2.0 / 700.0, 0.0]).expect("six axes");
|
||||
assert_eq!(axes, Axes { translation: [350, -350, 175], rotation: [-175, 1, 0] });
|
||||
for raw in -350_i16..=350 {
|
||||
let value = f64::from(raw) / 350.0;
|
||||
assert_eq!(super::axes(&[value; 6]).map(|a| a.translation[0]), Some(raw));
|
||||
}
|
||||
// Past full deflection is full deflection; anything that isn't a number is rest.
|
||||
let hostile = super::axes(&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300, f64::MIN_POSITIVE]).expect("six axes");
|
||||
assert_eq!(hostile, Axes { translation: [0, 0, 0], rotation: [350, -350, 0] });
|
||||
// A gamepad with fewer than six axes is not a 3D mouse; extra axes are ignored.
|
||||
assert_eq!(super::axes(&[1.0; 5]), None);
|
||||
assert_eq!(super::axes(&[]), None);
|
||||
assert_eq!(super::axes(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]), Some(Axes::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_is_read_before_the_virtual_joystick() {
|
||||
let moving = Axes { translation: [100, 0, 0], rotation: [0; 3] };
|
||||
let other = Axes { translation: [0, 200, 0], rotation: [0; 3] };
|
||||
assert_eq!(choose([(Source::VirtualJoystick, other), (Source::Device, moving)]), Some(moving));
|
||||
assert_eq!(choose([(Source::Device, moving), (Source::VirtualJoystick, other)]), Some(moving));
|
||||
assert_eq!(choose([(Source::VirtualJoystick, other)]), Some(other));
|
||||
// Two devices: the first listed.
|
||||
assert_eq!(choose([(Source::Device, moving), (Source::Device, other)]), Some(moving));
|
||||
assert_eq!(choose([]), None);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user