diff --git a/README.md b/README.md index 41871847..4e2fb0bd 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,8 @@ 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. The web app reads +page in Preferences has an on/off switch, a speed control and, for each of the cap's six axes, an +invert switch and a speed 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)). diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 453ce12b..704b9cc8 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -273,6 +273,11 @@ pub struct Prefs { pub space_mouse_enabled: bool, /// Speed multiplier for 3D mouse navigation. pub space_mouse_speed: f32, + /// Per physical axis of the cap (push X, Y, Z, then tilt X, Y, Z), whether it moves the view + /// the other way. + pub space_mouse_reverse: [bool; 6], + /// Per physical axis of the cap, a multiplier on top of `space_mouse_speed`; 0 switches it off. + pub space_mouse_axis_speed: [f32; 6], /// Anti-aliasing samples (applies on restart). pub msaa: u32, /// How finely the viewport tessellates curved faces and edges: `coarse`, `normal`, `fine` or @@ -402,6 +407,8 @@ impl Default for Prefs { orbit_cursor: false, space_mouse_enabled: true, space_mouse_speed: 1.0, + space_mouse_reverse: [false; 6], + space_mouse_axis_speed: [1.0; 6], msaa: 4, display_quality: "normal".into(), supersampling: 1.0, @@ -618,6 +625,8 @@ fn defaults(app: &mut SolveApp, page: usize) { p.orbit_cursor = d.orbit_cursor; p.space_mouse_enabled = d.space_mouse_enabled; p.space_mouse_speed = d.space_mouse_speed; + p.space_mouse_reverse = d.space_mouse_reverse; + p.space_mouse_axis_speed = d.space_mouse_axis_speed; p.look_at_anim = d.look_at_anim; p.look_at_speed = d.look_at_speed; p.middle_double_click = d.middle_double_click; @@ -1214,6 +1223,20 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { egui::Slider::new(&mut app.preferences.space_mouse_speed, 0.25..=3.0).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×")), ); ui.end_row(); + for (axis, name) in SPACE_MOUSE_AXES.iter().enumerate() { + ui.label(crate::i18n::tr_label(name)); + ui.horizontal(|ui| { + ui.add_enabled_ui(app.preferences.space_mouse_enabled, |ui| { + if let Some(reverse) = app.preferences.space_mouse_reverse.get_mut(axis) { + ui.checkbox(reverse, crate::i18n::tr_label("Invert")); + } + if let Some(speed) = app.preferences.space_mouse_axis_speed.get_mut(axis) { + ui.add(egui::Slider::new(speed, 0.0..=3.0).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×"))); + } + }); + }); + ui.end_row(); + } note(ui, crate::i18n::tr_label("Move and rotate the view together; sketches use pan and zoom only.")); } PAGE_DESIGN => { @@ -1453,6 +1476,9 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result Prefs { if p.orbit_cursor { p.orbit_around = OrbitAround::Cursor; diff --git a/crates/ui-egui/src/space_mouse.rs b/crates/ui-egui/src/space_mouse.rs index bb9d76e0..4b4ef6fe 100644 --- a/crates/ui-egui/src/space_mouse.rs +++ b/crates/ui-egui/src/space_mouse.rs @@ -87,6 +87,16 @@ impl DriverMapping { mapping } + /// The mapping with the user's per-axis choices folded in: `reverse` flips and `speed` scales + /// each physical axis of the cap. A speed that is not a finite number counts as 1. + pub fn with_user_axes(mut self, reverse: [bool; 6], speed: [f32; 6]) -> Self { + for ((rule, reverse), speed) in self.axes.iter_mut().zip(reverse).zip(speed) { + rule.reversed ^= reverse; + rule.scale *= if speed.is_finite() { f64::from(speed).clamp(0.0, 3.0) } else { 1.0 }; + } + self + } + pub fn apply(self, axes: Axes) -> Motion { let raw = [ normalized(axes.translation[0]), @@ -139,7 +149,10 @@ pub fn navigate_frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMa 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) }); + app.viewport.device_motion = Some(DeviceMotion { + motion: mapping.with_user_axes(app.preferences.space_mouse_reverse, app.preferences.space_mouse_axis_speed).apply(axes), + seconds: step * speed.clamp(0.1, 5.0), + }); ctx.request_repaint_after(Duration::from_millis(16)); true } @@ -182,6 +195,22 @@ mod tests { app } + #[test] + fn per_axis_choices_flip_and_scale_one_physical_axis() { + let mapping = DriverMapping::default().with_user_axes([false, true, false, false, false, false], [1.0, 1.0, 1.0, 0.5, f32::NAN, 1.0]); + let still = Motion::default(); + assert_eq!(mapping.apply(Axes { translation: [0, 350, 0], rotation: [0; 3] }).translation[1], -raw(PUSH).translation[1]); + assert_eq!( + mapping.apply(Axes { translation: [350, 0, 0], rotation: [0; 3] }).translation[0], + raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }).translation[0] + ); + assert_eq!(mapping.apply(Axes { translation: [0; 3], rotation: [350, 0, 0] }).rotation[0], 0.5); + assert_eq!(mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }).rotation[1], 1.0); + assert_eq!(mapping.apply(Axes::default()), still); + let off = DriverMapping::default().with_user_axes([false; 6], [0.0; 6]); + assert_eq!(off.apply(PUSH), still); + } + const PUSH: Axes = Axes { translation: [0, 350, 0], rotation: [0; 3] }; fn wheel(dy: f32) -> egui::Event {