From 4bc5bdc791c9e7d1ae2aad6029e71c63edadddcf Mon Sep 17 00:00:00 2001 From: Frank <58932224+plexi32@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:23:12 +0000 Subject: [PATCH] Reverse zoom direction no longer flips 3D mouse zoom (#8993) (#8994) * claim: #8993 * Reverse zoom direction no longer flips 3D mouse zoom (#8993) The checkbox sits under Wheel and trackpad and now reverses the wheel (and the navigation bridge's wheel-style steps) only. The 3D mouse cap zooms by its own per-axis invert switch alone: device_camera no longer takes a reverse_zoom argument, nor does the test model in Preferences. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/navigation.rs | 20 +++------ crates/ui-egui/src/prefs.rs | 10 ++--- crates/ui-egui/src/space_mouse.rs | 64 ++++++++++++++++++--------- crates/ui-egui/src/test_boat.rs | 24 ++-------- crates/ui-egui/src/test_boat_tests.rs | 12 ++--- docs/3d-mouse.md | 3 +- 6 files changed, 65 insertions(+), 68 deletions(-) diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index a339e53047..ef25cd0f69 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -851,17 +851,7 @@ pub(crate) fn device(app: &mut SolveApp, rect: Rect, caps: NavCaps) { } }); if let Some(queued) = app.viewport.device_motion.take() { - device_camera( - &mut app.cam, - queued.motion, - queued.seconds, - height, - turn, - app.preferences.zoom_reverse, - app.preferences.free_orbit, - anchor, - pivot, - ); + device_camera(&mut app.cam, queued.motion, queued.seconds, height, turn, app.preferences.free_orbit, anchor, pivot); } // A device app's motion (the navigation bridge), on the same terms. if let Some(queued) = app.viewport.bridge_motion.take() { @@ -920,13 +910,14 @@ pub(crate) fn bridge_camera( /// the frame rate. With `turn` off the view keeps its orientation. `anchor` is the model point the /// view holds while it zooms, as the wheel's cursor point (none zooms through the model, #7435). /// `pivot` is the point the view turns around and keeps still on screen (none: the view's centre). +/// Reverse zoom direction is a wheel setting and leaves the cap alone: the cap's own direction is +/// its per-axis invert, already in `motion` (#8993). pub(crate) fn device_camera( cam: &mut Camera, motion: Motion, seconds: f64, height: f64, turn: bool, - reverse_zoom: bool, free_orbit: bool, anchor: Option, pivot: Option, @@ -939,8 +930,7 @@ pub(crate) fn device_camera( // 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(), anchor); + cam.zoom_at((-y * 1.5 * seconds).exp(), anchor); 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 @@ -1710,7 +1700,7 @@ mod tests { } // Other presets keep the existing upright camera behavior. let mut cam = Camera { yaw: 0.0, pitch: 1.5, ..Default::default() }; - device_camera(&mut cam, Motion { translation: [0.0; 3], rotation: [1.0, 0.0, 0.0] }, 0.1, 600.0, true, false, false, None, None); + device_camera(&mut cam, Motion { translation: [0.0; 3], rotation: [1.0, 0.0, 0.0] }, 0.1, 600.0, true, false, None, None); assert!(cam.pitch < std::f64::consts::FRAC_PI_2 && cam.pitch > 1.5); } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index b53a7f07f3..c3c3ac3974 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -2729,7 +2729,7 @@ fn space_mouse_rows(app: &mut SolveApp, ui: &mut egui::Ui) { }); // The calibrated device moving as it does, with the test model beside it, then its dead zones. let (device, raw) = (app.space_mouse.device.clone(), app.space_mouse.raw.into()); - let (boat, motion, reverse) = (&mut app.space_mouse.boat, app.space_mouse.motion, (app.preferences.zoom_reverse, app.preferences.free_orbit)); + let (boat, motion, free_orbit) = (&mut app.space_mouse.boat, app.space_mouse.motion, app.preferences.free_orbit); if let Some(calibration) = device.as_ref().and_then(|device| app.preferences.space_mouse_calibrations.get_mut(device)) { ui_kit::divider(ui, "space_mouse_picture"); // Under each circle's axes, what the view does with them: the rows' own labels. @@ -2739,11 +2739,11 @@ fn space_mouse_rows(app: &mut SolveApp, ui: &mut egui::Ui) { crate::space_mouse_calibration::dead_zone_pads(ui, calibration, raw, false, &stories); ui_kit::text(ui, &help, ui_kit::Text::Hint("space_mouse_circles_help")); }; - picture_and_boat(ui, boat, motion, reverse, true, |ui| crate::space_mouse_model::live(ui, calibration, raw), pads); + picture_and_boat(ui, boat, motion, free_orbit, true, |ui| crate::space_mouse_model::live(ui, calibration, raw), pads); } else if device.is_some() { // Not calibrated: the test model alone. ui_kit::divider(ui, "space_mouse_picture"); - picture_and_boat(ui, boat, motion, reverse, false, |_| {}, |_| {}); + picture_and_boat(ui, boat, motion, free_orbit, false, |_| {}, |_| {}); } ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| { hint_under_controls(ui, crate::i18n::tr_label("Move and rotate the view together; sketches use pan and zoom only."), "space_mouse"); @@ -2817,7 +2817,7 @@ fn picture_and_boat( ui: &mut egui::Ui, boat: &mut Option, motion: Option, - (reverse_zoom, free_orbit): (bool, bool), + free_orbit: bool, has_picture: bool, picture: impl FnOnce(&mut egui::Ui), pads: impl FnOnce(&mut egui::Ui), @@ -2832,7 +2832,7 @@ fn picture_and_boat( let width = if beside { (room - PICTURE_WIDTH - gap).min(BOAT_MAX) } else { room.min(BOAT_MAX) }; let mut boat_and_reset = |ui: &mut egui::Ui| { ui.vertical(|ui| { - crate::test_boat::show(ui, boat, motion, reverse_zoom, free_orbit, egui::vec2(width.max(0.0), BOAT_HEIGHT)); + crate::test_boat::show(ui, boat, motion, free_orbit, egui::vec2(width.max(0.0), BOAT_HEIGHT)); if ui_kit::button(ui, crate::i18n::tr("ui.3d_mouse_reset_view"), ui_kit::Button::Normal).clicked() { crate::test_boat::reset(boat); } diff --git a/crates/ui-egui/src/space_mouse.rs b/crates/ui-egui/src/space_mouse.rs index b3992e3bdf..3e8552fde6 100644 --- a/crates/ui-egui/src/space_mouse.rs +++ b/crates/ui-egui/src/space_mouse.rs @@ -693,8 +693,8 @@ mod tests { /// 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, false, None, None); + fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool) { + crate::navigation::device_camera(cam, motion, seconds, height, !sketch, false, None, None); } /// A window that has run a frame, with or without focus. A context that has never run one @@ -1174,14 +1174,14 @@ mod tests { 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); + navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, 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); + navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true); assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation); assert!(cam.is_valid()); } @@ -1191,57 +1191,79 @@ mod tests { 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); + navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, 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); + navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, 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); + navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, 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); + navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, 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); + navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, 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); + navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false); assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch)); assert_ne!(twist.roll, before.roll); } + /// Reverse zoom direction is a wheel and trackpad setting: it turns the wheel's zoom around + /// and leaves the cap's, which has its own invert switch (#8993). #[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); + fn reverse_zoom_setting_flips_the_wheel_and_leaves_the_cap() { + let wheel_zooms_in = |reverse: bool| { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.zoom_reverse = reverse; + h.app.preferences.smooth_wheel_zoom = false; + h.call("ui.move", serde_json::json!({"x":800,"y":500})); + let before = h.app.cam.half_height(); + h.call("ui.scroll", serde_json::json!({"x":800,"y":500,"delta":120})); + h.app.cam.half_height() < before + }; + assert!(wheel_zooms_in(false)); + assert!(!wheel_zooms_in(true), "the wheel turns around"); + // The cap's zoom axis pushed forward with the wheel reversed or not, and with the cap's own + // invert switch for that axis on or off. + let cap_distance = |wheel_reversed: bool, cap_inverted: bool| { + let mut app = app(); + app.preferences.zoom_reverse = wheel_reversed; + app.preferences.space_mouse_reverse[1] = cap_inverted; + let ctx = window(true); + app.space_mouse.device = Some("hid 046d:c62b".into()); + let before = app.cam.distance; + frame(&mut app, &ctx, DriverMapping::default(), Axes { translation: [0, 350, 0], rotation: [0; 3] }, 0.02); + (before, app.cam.distance) + }; + let (before, normal) = cap_distance(false, false); + assert!(normal < before, "pushing the cap forward zooms in"); + assert_eq!(cap_distance(true, false).1, normal, "the cap zooms the same way with the wheel reversed"); + assert!(cap_distance(false, true).1 > before, "the cap's own invert switch turns it around"); + assert_eq!(cap_distance(true, true).1, cap_distance(false, true).1); } #[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); + navigate(&mut tilt, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, 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); + navigate(&mut twist, DriverMapping::object_mode().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, 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 diff --git a/crates/ui-egui/src/test_boat.rs b/crates/ui-egui/src/test_boat.rs index 74eaf0f614..4683f20e9d 100644 --- a/crates/ui-egui/src/test_boat.rs +++ b/crates/ui-egui/src/test_boat.rs @@ -109,17 +109,8 @@ impl Boat { /// `motion` for `seconds` on the boat's camera in a box `aspect` wide for its height of `height` /// points, with pan and zoom held so a sphere of `radius` stays inside it. -pub(crate) fn step( - cam: &mut Camera, - motion: crate::space_mouse::Motion, - seconds: f64, - height: f64, - radius: f64, - aspect: f64, - reverse_zoom: bool, - free_orbit: bool, -) { - crate::navigation::device_camera(cam, motion, seconds, height, true, reverse_zoom, free_orbit, None, None); +pub(crate) fn step(cam: &mut Camera, motion: crate::space_mouse::Motion, seconds: f64, height: f64, radius: f64, aspect: f64, free_orbit: bool) { + crate::navigation::device_camera(cam, motion, seconds, height, true, free_orbit, None, None); cam.fov = FOV; // Zoom within limits: the nearest still shows the whole boat in the shorter side of the box. let shorter = aspect.min(1.0); @@ -171,14 +162,7 @@ pub(crate) fn project(model: &Model, cam: &Camera, rect: Rect) -> Frame { } /// Draws the boat in a box of `size` and moves it by this frame's motion. `boat` is the page's state. -pub(crate) fn show( - ui: &mut egui::Ui, - boat: &mut Option, - motion: Option, - reverse_zoom: bool, - free_orbit: bool, - size: egui::Vec2, -) -> Option { +pub(crate) fn show(ui: &mut egui::Ui, boat: &mut Option, motion: Option, free_orbit: bool, size: egui::Vec2) -> Option { let model = model()?; let t = Tokens::get(); let now = ui.input(|i| i.time); @@ -188,7 +172,7 @@ pub(crate) fn show( state.seen = now; if let Some(motion) = motion { let aspect = f64::from(rect.width()) / f64::from(rect.height().max(1.0)); - step(&mut state.cam, motion.motion, motion.seconds, f64::from(rect.height()), model.radius, aspect, reverse_zoom, free_orbit); + step(&mut state.cam, motion.motion, motion.seconds, f64::from(rect.height()), model.radius, aspect, free_orbit); } let painter = ui.painter_at(rect); painter.rect(rect, t.radius_sm, t.dialog_body, egui::Stroke::new(1.0, t.dialog_rule), egui::StrokeKind::Inside); diff --git a/crates/ui-egui/src/test_boat_tests.rs b/crates/ui-egui/src/test_boat_tests.rs index 4c8521d7dc..2ef7c969e5 100644 --- a/crates/ui-egui/src/test_boat_tests.rs +++ b/crates/ui-egui/src/test_boat_tests.rs @@ -51,7 +51,7 @@ fn test_boat_step_orbits_pans_zooms_rolls_and_holds_the_boat_in_its_box() { let aspect = 1.4; let run = |m: Motion, seconds: f64| { let mut cam = start; - step(&mut cam, m, seconds, 150.0, radius, aspect, false, false); + step(&mut cam, m, seconds, 150.0, radius, aspect, false); cam }; // Nothing moves nothing. @@ -70,17 +70,17 @@ fn test_boat_step_orbits_pans_zooms_rolls_and_holds_the_boat_in_its_box() { // Held down for a long time, the limits hold: nearest, farthest, and the pan room. let mut cam = start; for _ in 0..400 { - step(&mut cam, motion([1.0, 1.0, 1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false, false); + step(&mut cam, motion([1.0, 1.0, 1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false); } assert!(cam.distance >= radius * NEAREST / 1.0 - 1e-6 && cam.target.len() <= cam.distance * (FOV / 2.0).tan() + 1e-6); let mut cam = start; for _ in 0..400 { - step(&mut cam, motion([-1.0, -1.0, -1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false, false); + step(&mut cam, motion([-1.0, -1.0, -1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false); } assert!(cam.distance <= radius * FARTHEST + 1e-6); // Hostile numbers leave a valid camera. let mut cam = start; - step(&mut cam, motion([f64::NAN, f64::INFINITY, 1e300], [f64::NAN; 3]), 0.05, 150.0, radius, aspect, false, false); + step(&mut cam, motion([f64::NAN, f64::INFINITY, 1e300], [f64::NAN; 3]), 0.05, 150.0, radius, aspect, false); assert!(cam.is_valid()); } @@ -94,7 +94,7 @@ fn test_boat_draws_inside_its_box_at_every_size_and_pose() { // Through the extremes of what the 3D mouse can do. for (translation, rotation) in [([0.0; 3], [0.0; 3]), ([1.0, -1.0, 1.0], [1.0, 0.0, 1.0]), ([-1.0, 1.0, -1.0], [0.0, 1.0, -1.0])] { for _ in 0..200 { - step(&mut cam, motion(translation, rotation), 0.05, f64::from(h), m.radius, aspect, false, false); + step(&mut cam, motion(translation, rotation), 0.05, f64::from(h), m.radius, aspect, false); } let frame = project(m, &cam, rect.shrink(1.0)); assert!(!frame.indices.is_empty(), "{w}x{h}: something is drawn"); @@ -141,7 +141,7 @@ fn test_boat_far_faces_come_first_and_a_page_opened_again_starts_afresh() { let moved = Some(DeviceMotion { motion: motion([0.0; 3], [1.0, 0.0, 0.0]), seconds: 0.05 }); let draw = |time: f64, motion: Option, state: &mut Option| { let mut output = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { - show(ui, state, motion, false, false, vec2(200.0, 150.0)); + show(ui, state, motion, false, vec2(200.0, 150.0)); }); output.textures_delta.clear(); }; diff --git a/docs/3d-mouse.md b/docs/3d-mouse.md index 9b7eecb10c..f221806533 100644 --- a/docs/3d-mouse.md +++ b/docs/3d-mouse.md @@ -19,7 +19,8 @@ versions. Two preferences opt into holding the model: While editing a sketch, the cap pans and zooms without changing the sketch's initial view. After you adjust the view with the navigation cube, the cap can rotate it too. The Navigation 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 +invert switch and a speed for the 3D mouse. The cap's zoom direction comes from its own invert +switch; **Reverse zoom direction** under Wheel and trackpad reverses the wheel only (#8993). The web app reads it too, through the browser; Firefox needs a 3DxWare setting first (see [Getting started › 3D mouse in the browser](book/src/guide/getting-started.md#3d-mouse-in-the-browser)).