Desktop 3D mouse on Windows without a 3DxWare profile: object-mode axes, no emulated wheel (#1380) (#1528)
Thanks @CamWight.
This commit is contained in:
1 parent
c01a7b9b37
commit
6220d8f8a6
5 files changed
+174
-84
No files matched your search
@@ -188,6 +188,10 @@ page in Preferences has an on/off switch and a speed control for the 3D mouse. T
|
||||
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)).
|
||||
|
||||
With 3DxWare on Windows, SolidCraft follows 3DxWare's profile for it when there is one, and
|
||||
3DxWare's object mode (tilt to roll, twist to spin) until there is. The mouse-wheel turns that
|
||||
3DxWare makes from the cap's motion are ignored, so the view doesn't zoom in steps as the cap tilts.
|
||||
|
||||
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
|
||||
`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id:
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
|
||||
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;
|
||||
@@ -69,29 +65,6 @@ 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;
|
||||
|
||||
@@ -105,7 +78,9 @@ mod browser {
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
use super::{WheelGuard, axes, choose, source};
|
||||
use solidcraft_ui_egui::space_mouse::WheelGuard;
|
||||
|
||||
use super::{axes, choose, source};
|
||||
|
||||
/// How often the page looks for the cap starting to move while nothing else redraws.
|
||||
const WAKE_MS: i32 = 50;
|
||||
@@ -200,19 +175,6 @@ mod browser {
|
||||
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).
|
||||
@@ -320,32 +282,4 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,9 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Before the app's own hook, which adds the control channel's events.
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw);
|
||||
self.solve.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
|
||||
@@ -16,17 +16,17 @@ use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, navigate_frame};
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, 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;
|
||||
|
||||
/// 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.
|
||||
/// The mapping used when no 3DxWare profile is found: what the driver applies by default in
|
||||
/// object mode. On Linux there is no 3DxWare (#1172); on Windows 3DxWare has no profile for
|
||||
/// SolidCraft until one is made, and the raw axes swap roll and spin (#1380).
|
||||
fn without_profile() -> DriverMapping {
|
||||
if cfg!(target_os = "linux") { DriverMapping::object_mode() } else { DriverMapping::default() }
|
||||
DriverMapping::object_mode()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -320,6 +320,9 @@ pub struct SpaceMouse {
|
||||
last_frame: Instant,
|
||||
driver_mapping: DriverMapping,
|
||||
mapping_checked_at: Instant,
|
||||
/// Drops 3DxWare's emulated wheel while the cap moves (#1380).
|
||||
wheel: WheelGuard,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
@@ -331,7 +334,25 @@ 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(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,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Before the app's own hook (which adds the control channel's events): with navigation on,
|
||||
/// the wheel events 3DxWare makes from the cap's motion are dropped.
|
||||
pub fn raw_input_hook(&mut self, enabled: bool, raw: &mut egui::RawInput) {
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let moving = self.shared.lock().map(|reading| reading.current(now).moving()).unwrap_or(false);
|
||||
self.wheel.filter(moving, now.duration_since(self.started).as_secs_f64(), &mut raw.events);
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
|
||||
@@ -428,7 +449,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_profile_linux_uses_the_drivers_object_mode_axes() {
|
||||
fn without_a_profile_the_drivers_object_mode_axes_are_used() {
|
||||
// 3DxWare's object-mode mapping, as a profile (#1172: confirmed on a SpaceMouse Compact).
|
||||
let object_mode = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
@@ -441,14 +462,11 @@ mod tests {
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let profile = parse_driver_mapping(object_mode).expect("valid 3DxWare profile");
|
||||
if cfg!(target_os = "linux") {
|
||||
assert_eq!(without_profile(), profile);
|
||||
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
|
||||
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!(without_profile(), DriverMapping::default());
|
||||
}
|
||||
// On every platform (#1172 on Linux, #1380 on Windows).
|
||||
assert_eq!(without_profile(), profile);
|
||||
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
|
||||
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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -476,6 +494,8 @@ mod tests {
|
||||
last_frame: now - Duration::from_millis(20),
|
||||
driver_mapping: DriverMapping::object_mode(),
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
};
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
|
||||
app.home.open = false;
|
||||
@@ -484,4 +504,64 @@ mod tests {
|
||||
assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] });
|
||||
assert!(seconds > 0.0);
|
||||
}
|
||||
|
||||
fn wheel() -> egui::Event {
|
||||
egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, -1.0),
|
||||
modifiers: Default::default(),
|
||||
phase: egui::TouchPhase::Move,
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse(reading: Reading) -> SpaceMouse {
|
||||
let now = Instant::now();
|
||||
SpaceMouse {
|
||||
shared: Arc::new(Mutex::new(reading)),
|
||||
last_frame: now,
|
||||
driver_mapping: DriverMapping::object_mode(),
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_3dxware_makes_from_the_cap_is_dropped() {
|
||||
// The cap tilted forward: 3DxWare also sends wheel turns (#1380). They are dropped.
|
||||
let now = Instant::now();
|
||||
let mut tilted = Reading::new(now);
|
||||
tilted.axes.rotation = [-200, 0, 0];
|
||||
let mut moving = mouse(tilted);
|
||||
let mut raw = egui::RawInput { events: vec![wheel(), egui::Event::Zoom(1.1), egui::Event::Text("a".into())], ..Default::default() };
|
||||
moving.raw_input_hook(true, &mut raw);
|
||||
assert_eq!(raw.events, [egui::Event::Text("a".into())]);
|
||||
// With navigation off, the wheel is the cap's only way to move the view: kept.
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
moving.raw_input_hook(false, &mut raw);
|
||||
assert_eq!(raw.events.len(), 1);
|
||||
// The cap at rest: the wheel is the user's.
|
||||
let mut resting = mouse(Reading::new(now));
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
resting.raw_input_hook(true, &mut raw);
|
||||
assert_eq!(raw.events.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_is_the_users_again_once_the_cap_has_been_still_a_moment() {
|
||||
let mut tilted = Reading::new(Instant::now());
|
||||
tilted.axes.rotation = [-200, 0, 0];
|
||||
let mut cap = mouse(tilted);
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
cap.raw_input_hook(true, &mut raw);
|
||||
assert!(raw.events.is_empty(), "dropped while the cap moves");
|
||||
// Let go, then wait out the quiet time.
|
||||
if let Ok(mut reading) = cap.shared.lock() {
|
||||
*reading = Reading::new(Instant::now());
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs_f64(solidcraft_ui_egui::space_mouse::WHEEL_QUIET_S) + Duration::from_millis(60));
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
cap.raw_input_hook(true, &mut raw);
|
||||
assert_eq!(raw.events.len(), 1, "kept once the cap has been still");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,34 @@ impl Axes {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub const WHEEL_QUIET_S: f64 = 0.15;
|
||||
|
||||
/// 3DxWare turns the cap's motion into mouse-wheel turns for programs it has no profile for (its
|
||||
/// keyboard and mouse emulation), browsers included (#1336, #1380). Both apps read the cap
|
||||
/// themselves, 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(_)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where one physical axis goes: its logical axis, and how.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AxisRule {
|
||||
@@ -156,6 +184,19 @@ mod tests {
|
||||
|
||||
const PUSH: Axes = Axes { translation: [0, 350, 0], rotation: [0; 3] };
|
||||
|
||||
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 six_axes_move_the_camera_and_sketches_keep_their_orientation() {
|
||||
let mut cam = Camera::default();
|
||||
@@ -348,4 +389,32 @@ mod tests {
|
||||
assert_eq!(distances[6], distances[5], "under 0.1 times is 0.1 times");
|
||||
assert!(distances[4] < distances[5] && distances[5] < app().cam.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_turns_are_dropped_while_the_cap_moves_and_kept_at_rest() {
|
||||
let mut guard = WheelGuard::default();
|
||||
// At rest, the wheel is the user's.
|
||||
let mut events = vec![wheel(100.0), other()];
|
||||
guard.filter(false, 10.0, &mut events);
|
||||
assert_eq!(events.len(), 2);
|
||||
// Moving: 3DxWare's wheel (and a pinch zoom) goes; everything else stays.
|
||||
let mut events = vec![wheel(-100.0), other(), egui::Event::Zoom(1.1), wheel(100.0)];
|
||||
guard.filter(true, 11.0, &mut events);
|
||||
assert_eq!(events, vec![other()]);
|
||||
// Just after the cap stops, a queued one still goes.
|
||||
let mut events = vec![wheel(100.0)];
|
||||
guard.filter(false, 11.0 + WHEEL_QUIET_S - 0.01, &mut events);
|
||||
assert!(events.is_empty());
|
||||
// Later, the wheel is the user's again.
|
||||
let mut events = vec![wheel(100.0)];
|
||||
guard.filter(false, 11.0 + WHEEL_QUIET_S + 0.01, &mut events);
|
||||
assert_eq!(events.len(), 1);
|
||||
// A clock that goes back never swallows the user's wheel.
|
||||
let mut events = vec![wheel(100.0)];
|
||||
guard.filter(false, 5.0, &mut events);
|
||||
assert_eq!(events.len(), 1);
|
||||
let mut events = vec![wheel(100.0)];
|
||||
guard.filter(false, f64::NAN, &mut events);
|
||||
assert_eq!(events.len(), 1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user