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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アクティブ化",
|
||||
|
||||
@@ -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": "Активировать",
|
||||
|
||||
@@ -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": "Активувати",
|
||||
|
||||
@@ -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": "激活",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in new issue
Block a user