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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-10-30 13:23:12 +00:00
committed by GitHub
parent 1baaebd3ef
commit 4bc5bdc791
6 changed files with 65 additions and 68 deletions
+5 -15
View File
@@ -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<Vec3>,
pivot: Option<Vec3>,
@@ -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);
}
+5 -5
View File
@@ -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<crate::test_boat::Boat>,
motion: Option<crate::space_mouse::DeviceMotion>,
(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);
}
+43 -21
View File
@@ -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
+4 -20
View File
@@ -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<Boat>,
motion: Option<DeviceMotion>,
reverse_zoom: bool,
free_orbit: bool,
size: egui::Vec2,
) -> Option<Rect> {
pub(crate) fn show(ui: &mut egui::Ui, boat: &mut Option<Boat>, motion: Option<DeviceMotion>, free_orbit: bool, size: egui::Vec2) -> Option<Rect> {
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);
+6 -6
View File
@@ -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<DeviceMotion>, state: &mut Option<Boat>| {
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();
};
+2 -1
View File
@@ -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)).