fix(view): hold ZOOMFACTOR to its own range, finish the exponential zoom

The exponential camera step is the right call. `0.9 ^ delta` is strictly
positive for every real delta, in and out cancel exactly where the
subtraction lost a percent of the view on every round trip, and the file's
own header has promised an "exponential feel" since it was written. Three
surfaces were left behind it, and the wider range exposed a fourth thing.

The range of the system variable. 3 to 100 is the range ZOOMFACTOR has, and
the User Preferences page was built against it; `SETVAR ZOOMFACTOR` accepts
that and nothing else again. A faster wheel is the Options window's business
instead: the slider covers the variable's own range, the field beside it
reaches 500, and a read-out says what a notch actually does ("10.0% a
notch") so the number being set is readable rather than implied. A value
above 100 therefore lives only in this application's own configuration file,
where it is nobody else's to read, and a bare `SETVAR ZOOMFACTOR` still
reports it. The bounds and the clamp live in one place now
(`settings::clamp_zoom_factor`): four literal copies of `3, 100` are what
let a surface be missed in the first place.

A floating viewport in MSPACE. It zooms its own view through
`zoom_active_viewport`, which kept subtracting: the factor reached zero at
6.67 steps, so from ZOOMFACTOR 360 up every notch inside a viewport bottomed
out on the clamp — a tenfold jump a notch, and the top third of the slider
doing nothing at all. Exponential there too, from a base of 0.85 so the
default notch still moves a viewport the 15% it always has. Each surface
keeps its own long-standing feel rather than inheriting the other's.

The trackpad pinch. `pinch_zoom_steps` was calibrated against the
subtraction (`1 - step / 10 = 1 / (1 + m)`) so that a pinch of m leaves the
view exactly 1 + m times closer — the property docs/trackpad.md documents
and the reason the gesture tracks the fingers at all. Inverting the zoom law
by hand is what let the two drift apart, so the step is asked of the camera
now (`Camera::zoom_steps_for_ratio`), which cannot drift by construction.
`pinch_is_one_to_one` recomputed the old formula rather than the camera's,
so it kept passing through all of this; it zooms a real camera and measures
the distance it lands at instead.

Also: `zoom` had a floor and no ceiling, and zooming out multiplies. At
ZOOMFACTOR 500 a notch out is x2.4, so about ninety notches — a couple of
seconds of scrolling — ran the distance past what an f32 far plane
(`distance * 1000`) can hold, and the viewport went black on an infinite
projection. A degenerate pinch magnification of -1 or beyond reached the
same place through the other side. Both are bounded now.

Also: two doc comments quoted the linear law (`zoom_view_at_cursor` and the
one on `pinch_zoom_steps`), and docs/trackpad.md stated the pinch identity
and claimed the tests covered it against the camera. All updated, with what
a notch does and where each surface's range comes from written down.

Tests, each confirmed against the behaviour it describes: the zoom law at
every factor the application can hold (positive, monotonic in the setting,
in and out cancelling), the ceiling under a long scroll out in both
projections, the published inverse and what it does with a nonsense ratio,
the viewport factor and a real viewport zoomed three notches at the top of
the range, the SETVAR range including a value the Options field set, the
Options field (above 100, above the ceiling, cleared, not a number), its
agreement with the slider, Close putting both the value and the field back,
and the read-out's percentages. 1882 lib tests pass.

The two new Options strings are not in the locale catalog yet, so they
render in English until it is regenerated.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-10-02 10:51:26 +03:00
1 parent 558b5f6526
commit 9fe2e4fa58
12 files changed
+549 -32

No files matched your search

+6 -1
View File
@@ -751,7 +751,12 @@ impl OpenCADStudio {
self.polar_mode = s.polar;
self.polar_increment_deg = s.polar_increment_deg;
self.zoom_wheel_reversed = s.zoom_wheel_reversed;
self.zoom_factor = s.zoom_factor.clamp(3, 500);
self.zoom_factor = crate::app::settings::clamp_zoom_factor(s.zoom_factor);
// Like the drafting-rotation field below, the Options field edits a
// buffer rather than the value, so it is reseeded whenever the value
// is restored from behind it — a start-up, or the Options window's
// own Close putting a change back.
self.zoom_factor_input = self.zoom_factor.to_string();
self.cursor_size = s.cursor_size.clamp(1, 100);
self.pick_box = s.pick_box.clamp(0, 50);
self.options_tab = s.options_tab;
+17 -1
View File
@@ -8247,11 +8247,27 @@ impl OpenCADStudio {
}
Message::ZoomFactorChanged(factor) => {
self.zoom_factor = factor.clamp(3, 500);
self.zoom_factor = crate::app::settings::clamp_zoom_factor(factor);
// The field beside the slider reads the same value, so the
// two never disagree about what the wheel is set to.
self.zoom_factor_input = self.zoom_factor.to_string();
self.persist_settings_if_changed();
Task::none()
}
// Typed into freely; only committed when it parses, so clearing
// the field to retype does not drop the wheel to its minimum.
// This is the surface that reaches past the range the system
// variable has.
Message::ZoomFactorInputChanged(value) => {
self.zoom_factor_input = value;
if let Ok(factor) = self.zoom_factor_input.trim().parse::<i32>() {
self.zoom_factor = crate::app::settings::clamp_zoom_factor(factor);
self.persist_settings_if_changed();
}
Task::none()
}
Message::RightClickModeChanged(mode) => {
self.right_click_mode = mode;
self.persist_settings_if_changed();
+52 -8
View File
@@ -76,9 +76,18 @@ fn scroll_intent(delta: mouse::ScrollDelta) -> ScrollIntent {
/// magnification delta (0.05 = the fingers moved 5% further apart).
///
/// AppKit reports the increment of a scale factor, and a 5% wider pinch has to
/// leave the view 5% closer, so the step solves `1 - step / 10 = 1 / (1 + m)`.
/// leave the view 5% closer, so the step is the one that scales the camera
/// distance by exactly `1 / (1 + m)`. The camera is asked for that step
/// instead of being told it: inverting the zoom law here by hand (it read
/// `1 - step / 10 = 1 / (1 + m)`) is what made the pinch stop tracking the
/// fingers the moment the law itself changed.
///
/// The ratio is bounded because a magnification of -1 or beyond — which the
/// gesture should never report — has no finite answer, and because no single
/// gesture event has any business moving the view a hundredfold.
fn pinch_zoom_steps(magnification: f32) -> f32 {
10.0 * magnification / (1.0 + magnification)
let ratio = (1.0 / (1.0 + magnification)).clamp(0.01, 100.0);
crate::scene::view::camera::Camera::zoom_steps_for_ratio(ratio)
}
/// Whether a command point keeps the elevation of its snap instead of being
@@ -5695,7 +5704,8 @@ properties={:.1}ms picked={}",
pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task<Message> {
match scroll_intent(delta) {
ScrollIntent::Zoom { notches } => {
let mut s = notches * self.zoom_factor as f32 / 60.0;
let mut s =
notches * crate::app::settings::zoom_notch_steps(self.zoom_factor);
if self.zoom_wheel_reversed {
s = -s;
}
@@ -5753,9 +5763,9 @@ properties={:.1}ms picked={}",
.record_nav_perf(crate::scene::NavPerfOp::Pan, started);
}
/// Zoom the active view by `s`, in the camera's own units
/// (`Camera::zoom`: `distance *= 1 - s / 10`). Shared by the wheel and
/// the pinch.
/// Zoom the active view by `s`, in the camera's own steps
/// (`Camera::zoom`: `distance *= ZOOM_STEP.powf(s)`). Shared by the
/// wheel and the pinch.
fn zoom_view_at_cursor(&mut self, s: f32) -> Task<Message> {
let nav_started = Instant::now();
let i = self.active_tab;
@@ -6717,6 +6727,7 @@ properties={:.1}ms picked={}",
#[cfg(test)]
mod scroll_intent_tests {
use super::{pinch_zoom_steps, scroll_intent, ScrollIntent};
use crate::scene::view::camera::Camera;
use iced::mouse::ScrollDelta;
#[test]
@@ -6740,13 +6751,46 @@ mod scroll_intent_tests {
/// A pinch is a scale factor, so the view has to end up exactly that much
/// closer: fingers 5% apart divide the camera distance by 1.05.
///
/// Asked of the camera, not of the formula the step was derived from. The
/// version that recomputed `1 - step / 10` here kept passing when the
/// camera moved to an exponential zoom and the pinch quietly stopped
/// being 1:1.
#[test]
fn pinch_is_one_to_one() {
let zoom_after = 1.0 - pinch_zoom_steps(0.05) / 10.0;
assert!((zoom_after - 1.0 / 1.05).abs() < 1e-6, "{zoom_after}");
for magnification in [0.01_f32, 0.05, 0.2, 0.5, 1.0] {
let mut camera = Camera::default();
let before = camera.distance;
camera.zoom(pinch_zoom_steps(magnification));
let want = before / (1.0 + magnification);
assert!(
(camera.distance - want).abs() < 1e-4 * want,
"m = {magnification}: {} wanted {want}",
camera.distance
);
}
assert!(pinch_zoom_steps(-0.05) < 0.0, "pinching in zooms out");
// No pinch, no step: a zero delta must not divide the view away.
assert_eq!(pinch_zoom_steps(0.0), 0.0);
let mut camera = Camera::default();
let before = camera.distance;
camera.zoom(pinch_zoom_steps(0.0));
assert_eq!(camera.distance, before);
}
/// A gesture the platform should never send must not be able to throw the
/// camera out of the world.
#[test]
fn a_degenerate_pinch_stays_finite() {
for magnification in [-1.0_f32, -2.0, f32::NAN, f32::INFINITY] {
let mut camera = Camera::default();
camera.zoom(pinch_zoom_steps(magnification));
assert!(
camera.distance.is_finite() && camera.distance > 0.0,
"m = {magnification} left distance at {}",
camera.distance
);
}
}
}