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 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
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

+3 -1
View File
@@ -179,7 +179,9 @@ Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p s
On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
page in Preferences has an on/off switch and a speed control for the 3D mouse.
page in Preferences has an on/off switch and a speed control for the 3D mouse. The web app reads
it too, through the browser; Firefox needs a 3DxWare setting first (see
[Getting started › 3D mouse in the browser](docs/book/src/guide/getting-started.md#3d-mouse-in-the-browser)).
On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the
device. A udev rule that adds the `uaccess` tag does that, for example
+1 -1
View File
@@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList",
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad",
] }
+9 -1
View File
@@ -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(),
}))
}),
)
+351
View File
@@ -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);
}
}
+42 -224
View File
@@ -15,79 +15,18 @@ use hid_descriptor::Layout;
use hidapi::{DeviceInfo, HidApi};
use quick_xml::Reader;
use quick_xml::events::Event;
use solidcraft_engine::render::Camera;
use solidcraft_ui_egui::SolveApp;
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, navigate_frame};
const STALE_AFTER: Duration = Duration::from_millis(150);
const RETRY_AFTER: Duration = Duration::from_secs(2);
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Axes {
translation: [i16; 3],
rotation: [i16; 3],
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct AxisRule {
output: usize,
enabled: bool,
reversed: bool,
scale: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct DriverMapping {
axes: [AxisRule; 6],
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 {
/// The mapping used when no 3DxWare profile is found. On Linux there is no 3DxWare, so it is
/// what the driver 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. Elsewhere the driver writes a
/// profile, and the raw axes are used until it does.
fn without_profile() -> Self {
let mut mapping = Self::default();
if cfg!(target_os = "linux") {
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
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Motion {
translation: [f64; 3],
rotation: [f64; 3],
}
impl DriverMapping {
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 };
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
}
}
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
}
/// The mapping used when no 3DxWare profile is found. On Linux there is no 3DxWare, so it is what
/// the driver applies by default in object mode (#1172). Elsewhere the driver writes a profile,
/// and the raw axes are used until it does.
fn without_profile() -> DriverMapping {
if cfg!(target_os = "linux") { DriverMapping::object_mode() } else { DriverMapping::default() }
}
#[derive(Clone, Copy, Debug, Default)]
@@ -392,87 +331,38 @@ impl SpaceMouse {
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
log::warn!("3D mouse: reader could not start: {error}");
}
Self {
shared,
last_frame: now,
driver_mapping: load_driver_mapping().unwrap_or_else(DriverMapping::without_profile),
mapping_checked_at: now,
}
Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_else(without_profile), mapping_checked_at: now }
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
let now = Instant::now();
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
let seconds = now.duration_since(self.last_frame).as_secs_f64();
self.last_frame = now;
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
let updated = load_driver_mapping().unwrap_or_else(DriverMapping::without_profile);
let updated = load_driver_mapping().unwrap_or_else(without_profile);
if updated != self.driver_mapping {
log::info!("3D mouse: applying updated 3DxWare axis profile");
self.driver_mapping = updated;
}
self.mapping_checked_at = now;
}
if !app.preferences.space_mouse_enabled || app.home.open {
return;
}
let axes = match self.shared.lock() {
Ok(reading) => reading.current(now),
Err(_) => return,
};
if axes == Axes::default() {
return;
}
let speed = f64::from(app.preferences.space_mouse_speed);
if !speed.is_finite() {
return;
}
app.begin_external_navigation();
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
navigate(
&mut app.cam,
self.driver_mapping.apply(axes),
seconds * speed.clamp(0.1, 5.0),
height,
app.session.active_sketch.is_some(),
app.preferences.zoom_reverse,
);
ctx.request_repaint_after(Duration::from_millis(16));
}
}
/// 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) }
}
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
if !(seconds > 0.0) || !seconds.is_finite() {
return;
}
let [x, y, z] = motion.translation;
let [rx, ry, rz] = motion.rotation;
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
// profile above can swap the physical forward/back and up/down axes before they reach here.
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
if !sketch {
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
// navigation mode, so swapping them again here would make Roll behave like Spin.
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
cam.roll_by(rz * 2.0 * seconds);
navigate_frame(app, ctx, self.driver_mapping, axes, seconds);
}
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_ui_egui::space_mouse::{DeviceMotion, Motion};
fn raw(axes: Axes) -> Motion {
DriverMapping::default().apply(axes)
/// The logical axes a reading reaches. Logical Rz rolls the view and logical Ry turns it about
/// its vertical (`navigation::device_camera` in solidcraft-ui-egui, tested there).
fn rotation(mapping: DriverMapping, rotation: [i16; 3]) -> [f64; 3] {
mapping.apply(Axes { translation: [0; 3], rotation }).rotation
}
#[test]
@@ -519,69 +409,6 @@ mod tests {
assert_eq!(reading.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[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 applies_3dxware_axis_swaps_and_reversals() {
let profile = br#"
@@ -615,38 +442,17 @@ mod tests {
"#;
let profile = parse_driver_mapping(object_mode).expect("valid 3DxWare profile");
if cfg!(target_os = "linux") {
assert_eq!(DriverMapping::without_profile(), profile);
assert_eq!(without_profile(), profile);
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
let before = Camera::default();
let mut tilt = before;
navigate(
&mut tilt,
DriverMapping::without_profile().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::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }),
0.02,
600.0,
false,
false,
);
assert_ne!(twist.yaw, before.yaw);
assert_eq!(twist.roll, before.roll);
assert_eq!(rotation(without_profile(), [0, 350, 0]), [0.0, 0.0, -1.0]);
assert_eq!(rotation(without_profile(), [0, 0, 350]), [0.0, -1.0, 0.0]);
} else {
assert_eq!(DriverMapping::without_profile(), DriverMapping::default());
assert_eq!(without_profile(), DriverMapping::default());
}
}
#[test]
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
fn mapped_roll_and_spin_reach_the_roll_and_turn_axes() {
let profile = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
@@ -654,16 +460,28 @@ mod tests {
</AxisBank></Device></Devices></AppCfg>
"#;
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
let before = Camera::default();
// The physical roll reaches the roll axis only, and the physical spin the turn only.
assert_eq!(rotation(mapping, [0, 350, 0]), [0.0, 0.0, 1.0]);
assert_eq!(rotation(mapping, [0, 0, 350]), [0.0, -1.0, 0.0]);
}
let mut physical_roll = before;
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
assert_ne!(physical_roll.roll, before.roll);
let mut physical_spin = before;
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_ne!(physical_spin.yaw, before.yaw);
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
#[test]
fn each_frame_hands_the_view_its_motion_through_the_drivers_mapping() {
// The reader's latest tilt, and a profile that makes tilt roll: the frame's motion rolls.
let now = Instant::now();
let mut reading = Reading::new(now);
reading.axes.rotation = [0, 350, 0];
let mut mouse = SpaceMouse {
shared: Arc::new(Mutex::new(reading)),
last_frame: now - Duration::from_millis(20),
driver_mapping: DriverMapping::object_mode(),
mapping_checked_at: now,
};
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
app.home.open = false;
mouse.tick(&mut app, &Context::default());
let DeviceMotion { motion, seconds } = app.viewport.device_motion.expect("the frame hands the page its motion");
assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] });
assert!(seconds > 0.0);
}
}
+1 -1
View File
@@ -344,7 +344,7 @@
"sketch.make_selection": "Convert the selection to {linetype}",
"ui.1_degree_of_freedom": "1 degree of freedom",
"ui.3d_mouse_speed": "3D mouse speed",
"ui.3d_mouse_windows": "3D mouse (Windows)",
"ui.3d_mouse_windows": "3D mouse",
"ui.3d_sketch": "3D Sketch",
"ui.about": "About",
"ui.activate": "Activate",
+1 -1
View File
@@ -344,7 +344,7 @@
"sketch.make_selection": "Convertir la sélection en {linetype}",
"ui.1_degree_of_freedom": "1 degré de liberté",
"ui.3d_mouse_speed": "Vitesse de la souris 3D",
"ui.3d_mouse_windows": "Souris 3D (Windows)",
"ui.3d_mouse_windows": "Souris 3D",
"ui.3d_sketch": "Esquisse 3D",
"ui.about": "À propos",
"ui.activate": "Activer",
+1 -1
View File
@@ -344,7 +344,7 @@
"sketch.make_selection": "選択を{linetype}にする",
"ui.1_degree_of_freedom": "自由度 1",
"ui.3d_mouse_speed": "3D マウス速度",
"ui.3d_mouse_windows": "3D マウス (Windows)",
"ui.3d_mouse_windows": "3D マウス",
"ui.3d_sketch": "3D スケッチ",
"ui.about": "SolidCraft について",
"ui.activate": "アクティブ化",
+1 -1
View File
@@ -329,7 +329,7 @@
"sketch.make_selection": "Преобразовать выделение в {linetype}",
"ui.1_degree_of_freedom": "1 степень свободы",
"ui.3d_mouse_speed": "Скорость 3D-мыши",
"ui.3d_mouse_windows": "3D-мышь (Windows)",
"ui.3d_mouse_windows": "3D-мышь",
"ui.3d_sketch": "3D-эскиз",
"ui.about": "О программе",
"ui.activate": "Активировать",
+1 -1
View File
@@ -308,7 +308,7 @@
"sketch.make_selection": "Перетворити виділене на {linetype}",
"ui.1_degree_of_freedom": "1 ступінь вільності",
"ui.3d_mouse_speed": "Швидкість 3D-миші",
"ui.3d_mouse_windows": "3D-миша (Windows)",
"ui.3d_mouse_windows": "3D-миша",
"ui.3d_sketch": "3D-ескіз",
"ui.about": "Про програму",
"ui.activate": "Активувати",
+1 -1
View File
@@ -344,7 +344,7 @@
"sketch.make_selection": "将选择转换为 {linetype}",
"ui.1_degree_of_freedom": "1 个自由度",
"ui.3d_mouse_speed": "3D 鼠标速度",
"ui.3d_mouse_windows": "3D 鼠标(Windows)",
"ui.3d_mouse_windows": "3D 鼠标",
"ui.3d_sketch": "3D 草图",
"ui.about": "关于",
"ui.activate": "激活",
+1
View File
@@ -149,6 +149,7 @@ pub mod sketch_tools;
pub mod smart_extrude;
#[cfg(test)]
mod smart_extrude_ui_tests;
pub mod space_mouse;
pub mod stand_up;
#[cfg(test)]
mod sweep_dialog_tests;
+103 -8
View File
@@ -5,9 +5,10 @@
//! One entry point, [`handle`], reads a frame's pointer, wheel, trackpad and modifier input over a
//! view and moves `app.cam` the way Preferences › Navigation says: the navigation preset (Fusion,
//! SolidWorks, Blender, Touchpad…), Orbit around, zoom to the cursor, the middle double-click and
//! orbit snapping. The SpaceMouse, view keys and the navigators act on `app.cam` independently of
//! the page. New canvases must call `navigation::handle` rather than reading drags or the wheel
//! themselves; `hand_rolled_camera_input_is_rejected` fails when they do.
//! orbit snapping. The 3D mouse's motion goes through [`device`] too, with the page's
//! [`NavCaps`]. The view keys and the navigators act on `app.cam` independently of the page. New
//! canvases must call `navigation::handle` rather than reading drags or the wheel themselves;
//! `hand_rolled_camera_input_is_rejected` fails when they do.
use egui::{Pos2, Rect, pos2, vec2};
use serde_json::json;
@@ -16,6 +17,7 @@ use solidcraft_engine::render::Camera;
use crate::SolveApp;
use crate::prefs::{OrbitAround, pivot_under};
use crate::space_mouse::Motion;
use crate::viewport::{Hit, NavMode, axis_rect, nearest_quarter_turn, pick, pick_cached, projection};
/// What a page's camera may do.
@@ -244,6 +246,8 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
// Ctrl or Alt held: drawing and dragging don't snap.
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
middle_double_click(app, ui, inside);
// The 3D mouse's motion this frame, with what this page's camera may do.
device(app, rect, caps);
// ---- navigation ----
let trackpad_pan = app.preferences.trackpad_pans();
@@ -464,6 +468,40 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
CameraInput { hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, delta, inside, axes, pinch, pan, primary_nav, mode }
}
/// The 3D mouse's motion this frame (#1336), on a page `rect` whose camera may do `caps`: the cap
/// pans and zooms every page, and turns the view only where the page may orbit and no sketch is
/// being edited (a sketch keeps its view square to it). [`handle`] calls it for its page; the 3D
/// mouse only reads the device and queues the motion ([`crate::space_mouse::navigate_frame`]).
pub(crate) fn device(app: &mut SolveApp, rect: Rect, caps: NavCaps) {
let Some(queued) = app.viewport.device_motion.take() else { return };
let turn = caps.orbit && app.session.active_sketch.is_none();
let height = f64::from(rect.height().max(1.0));
device_camera(&mut app.cam, queued.motion, queued.seconds, height, turn, app.preferences.zoom_reverse);
}
/// `seconds` of 3D mouse `motion` on `cam`, in a view `height` points tall. A full deflection
/// pans about 0.6 of the view's height a second and turns about two radians a second, whatever
/// the frame rate. With `turn` off the view keeps its orientation.
pub(crate) fn device_camera(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, turn: bool, reverse_zoom: bool) {
if !(seconds > 0.0) || !seconds.is_finite() {
return;
}
let [x, y, z] = motion.translation;
let [rx, ry, rz] = motion.rotation;
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
// profile can swap the physical forward/back and up/down axes before they reach here.
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
if turn {
// These are logical 3DxWare coordinates after the profile mapping: Rx is Tilt, Ry is
// Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected navigation
// mode, so swapping them again here would make Roll behave like Spin.
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
cam.roll_by(rz * 2.0 * seconds);
}
}
/// Six principal views, twelve edge diagonals and eight isometric corner directions.
pub(crate) fn snap_directions() -> impl Iterator<Item = Vec3> {
[-1.0, 0.0, 1.0].into_iter().flat_map(|x| {
@@ -637,15 +675,15 @@ pub(crate) fn orbit_about(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64
mod tests {
/// Every page with a camera navigates through [`super::handle`] (#1454). A page that reads
/// the wheel or moves the view camera itself would drift from the user's navigation settings,
/// so outside this module (and the SpaceMouse driver, a device rather than a page) the camera
/// may only be moved through it, the view commands or the navigators.
/// so outside this module the camera may only be moved through it, the view commands or the
/// navigators. The 3D mouse in both apps hands its motion to [`super::device`].
#[test]
fn hand_rolled_camera_input_is_rejected() {
const PATTERNS: [&str; 7] =
["smooth_scroll_delta", "raw_scroll_delta", "zoom_delta()", "cam.orbit(", "cam.pan(", "cam.zoom_at(", "cam.roll_by("];
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let dirs = [root.join("src"), root.join("../../apps/solidcraft/src")];
let allowed = ["navigation.rs", "space_mouse.rs"];
let dirs = [root.join("src"), root.join("../../apps/solidcraft/src"), root.join("../../apps/solidcraft-web/src")];
let allowed = ["navigation.rs"];
let mut offenders = Vec::new();
let mut scanned = 0;
let mut stack: Vec<std::path::PathBuf> = dirs.iter().filter(|d| d.is_dir()).cloned().collect();
@@ -661,7 +699,8 @@ mod tests {
continue;
}
scanned += 1;
let text = std::fs::read_to_string(&path).unwrap();
// A Windows checkout's CRLF lines, read as written, so the split below finds the tests.
let text = std::fs::read_to_string(&path).unwrap().replace("\r\n", "\n");
// In-file unit tests set cameras up directly; only the code above them counts.
let code = text.split("\n#[cfg(test)]\nmod ").next().unwrap_or_default();
for (n, line) in code.lines().enumerate() {
@@ -739,4 +778,60 @@ mod tests {
assert_ne!(app.cam.distance, before.distance, "{preset}: the wheel zooms");
}
}
/// The 3D mouse pans and zooms a flat page with the cap and never turns it, as the mouse
/// does there; pages that orbit turn with it (#1336).
#[test]
fn the_3d_mouse_pans_and_zooms_a_flat_page_but_never_turns_it() {
use crate::space_mouse::{DeviceMotion, Motion};
let every_axis = DeviceMotion { motion: Motion { translation: [1.0, 1.0, 1.0], rotation: [1.0, 1.0, 1.0] }, seconds: 0.05 };
let flat = super::NavCaps { orbit: false, pick: false };
for (caps, turns) in [(flat, false), (super::NavCaps::MODEL, true), (super::NavCaps::RENDER, true)] {
let mut s = solidcraft_engine::Session::default();
s.execute("solid.box", &serde_json::json!({"length":40,"width":30,"height":20})).unwrap();
let mut app = crate::SolveApp::new(s, crate::Services::default());
let ctx = egui::Context::default();
let before = app.cam;
app.viewport.device_motion = Some(every_axis);
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))),
time: Some(0.0),
focused: true,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
let rect = ui.max_rect();
app.viewport.rect = Some(rect);
let resp = ui.interact(rect, ui.id().with("page"), egui::Sense::click_and_drag());
super::handle(&mut app, ui, rect, &resp, caps);
});
out.textures_delta.clear();
assert!(app.viewport.device_motion.is_none(), "{caps:?}: the page takes the motion");
assert_ne!(app.cam.target, before.target, "{caps:?}: the cap pans");
assert!(app.cam.distance < before.distance, "{caps:?}: the cap zooms");
let turned = (app.cam.yaw, app.cam.pitch, app.cam.roll) != (before.yaw, before.pitch, before.roll);
assert_eq!(turned, turns, "{caps:?}: the cap turns the view only where the page orbits");
}
}
/// While a sketch is edited the cap pans and zooms, and the view stays square to the sketch.
#[test]
fn the_3d_mouse_never_turns_a_sketch_being_edited() {
use crate::space_mouse::{DeviceMotion, Motion};
let mut s = solidcraft_engine::Session::default();
s.execute("sketch.create", &serde_json::json!({"plane": "XY"})).unwrap();
let mut app = crate::SolveApp::new(s, crate::Services::default());
assert!(app.session.active_sketch.is_some());
let before = app.cam;
app.viewport.device_motion = Some(DeviceMotion { motion: Motion { translation: [1.0; 3], rotation: [1.0; 3] }, seconds: 0.05 });
let page = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
super::device(&mut app, page, super::NavCaps::MODEL);
assert_eq!((app.cam.yaw, app.cam.pitch, app.cam.roll), (before.yaw, before.pitch, before.roll));
assert_ne!(app.cam.target, before.target);
assert!(app.cam.distance < before.distance);
// Nothing queued, nothing moves.
let still = app.cam;
super::device(&mut app, page, super::NavCaps::MODEL);
assert_eq!(app.cam, still);
}
}
+1 -1
View File
@@ -1188,7 +1188,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
egui::Slider::new(&mut app.preferences.look_at_speed, 0.5..=3.0).step_by(0.25).custom_formatter(|v, _| format!("{v:.2}×")),
);
ui.end_row();
ui.label(crate::i18n::tr_label("3D mouse (Windows)"));
ui.label(crate::i18n::tr_label("3D mouse"));
ui.checkbox(&mut app.preferences.space_mouse_enabled, crate::i18n::tr_label("Navigate with SpaceNavigator / SpaceMouse"));
ui.end_row();
ui.label(crate::i18n::tr_label("3D mouse speed"));
+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);
}
}
+3
View File
@@ -91,6 +91,9 @@ pub struct ViewportState {
pub orbit_pivot: Option<Vec3>,
/// Continuous orbit: the view turns about the vertical until the next click or key.
pub spin: bool,
/// The 3D mouse's motion this frame, for the page showing the camera to apply with what its
/// camera may do (`navigation::device`).
pub device_motion: Option<crate::space_mouse::DeviceMotion>,
/// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends.
pub(crate) wheel_zoom_raw_until_idle: bool,
/// While trackpad scrolling runs, ignore egui's smoothing tail for zoom so
+50
View File
@@ -110,6 +110,56 @@ cd apps/solidcraft-web && trunk serve --release --no-autoreload
Offline use needs `--no-autoreload`: Trunk's live reload changes the page as it serves it, so it
no longer matches the build the browser would keep. Debug builds (`trunk serve`) never keep a copy.
### 3D mouse in the browser
The web app reads a 3Dconnexion SpaceMouse or SpaceNavigator through the browser's Gamepad API,
with the same switch and speed in Preferences › Navigation as the desktop app. Edge and Chrome
read the device as it is. A browser shows a gamepad to a page only once it has been moved there,
so if nothing happens at first, push the cap firmly once. 3DxWare also turns the cap into
mouse-wheel zoom in browsers; while the cap moves, the web app ignores the wheel and reads the
cap itself. Before the browser shows the cap to the page, that zoom still gets through, so the
first push after opening the page may zoom a little.
Firefox doesn't list 3D mice ([Mozilla bug 1226575](https://bugzilla.mozilla.org/show_bug.cgi?id=1226575)).
On Windows it does list 3DxWare's virtual joystick, which can carry the cap instead. Save this
as `%APPDATA%\3Dconnexion\3DxWare\Cfg\Firefox.xml`, then restart Firefox (and 3DxWare, if the cap
still only zooms). It changes Firefox only: there 3DxWare sends the cap's six axes to the
joystick instead of the mouse wheel. Delete the file to undo it.
```xml
<?xml version="1.0" encoding="UTF-8"?>
<AppCfg Default="false" xmlns="" CfgFormatVersion="1.3" ThisFileVersion="1.1">
<AppInfo>
<Signature>
<Name>STR_FIREFOX</Name>
<ExecutableName>FIREFOX.EXE</ExecutableName>
<Transport>KMJ</Transport>
</Signature>
<Options/>
</AppInfo>
<CfgProperties>
<ID>ID_STR_FIREFOX</ID>
<Name>STR_FIREFOX</Name>
<InheritsFromID>ID_STR_FIREFOX</InheritsFromID>
</CfgProperties>
<Devices>
<Device>
<ID>ID_Standard_3D_Mouse</ID>
<AxisBank>
<Name>STR_DEFAULT_BANK</Name>
<ID>Default</ID>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID><Min>-512</Min><Max>511</Max><Deadband>0</Deadband></Input><Output><ActionID>HIDJoystick_X</ActionID></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID><Min>-512</Min><Max>511</Max><Deadband>0</Deadband></Input><Output><ActionID>HIDJoystick_Y</ActionID></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID><Min>-512</Min><Max>511</Max><Deadband>0</Deadband></Input><Output><ActionID>HIDJoystick_Z</ActionID></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID><Min>-512</Min><Max>511</Max><Deadband>0</Deadband></Input><Output><ActionID>HIDJoystick_Rx</ActionID></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID><Min>-512</Min><Max>511</Max><Deadband>0</Deadband></Input><Output><ActionID>HIDJoystick_Ry</ActionID></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID><Min>-512</Min><Max>511</Max><Deadband>0</Deadband></Input><Output><ActionID>HIDJoystick_Rz</ActionID></Output></Axis>
</AxisBank>
</Device>
</Devices>
</AppCfg>
```
## CLI
`solidcraft-cli` runs the same commands without a window:
+1 -1
View File
@@ -19,7 +19,7 @@
| 3-Point Rectangle | 3点指定長方形 | `cmd.sketch_rectangle_three_point` |
| 3-Tangent Circle | 3接線指定の円 | `cmd.sketch_circle_three_tangent` |
| 3D Line | 3D 線分 | `cmd.sketch_line3d` |
| 3D mouse (Windows) | 3D マウス (Windows) | `ui.3d_mouse_windows` |
| 3D mouse | 3D マウス | `ui.3d_mouse_windows` |
| 3D mouse speed | 3D マウス速度 | `ui.3d_mouse_speed` |
| 3D Point | 3D 点 | `cmd.sketch_point3d` |
| 3D Sketch | 3D スケッチ | `ui.3d_sketch` |