Merge PR #1587 from mechpanos/fix/zoomfactor-range-and-exponential-zoom: hold ZOOMFACTOR to its own range, finish the exponential zoom

This commit is contained in:
Hakan Seven committed 2026-10-02 14:09:44 +03:00
commit 403a277588
12 files changed
+549 -32

No files matched your search

+21 -3
View File
@@ -17,6 +17,20 @@ together with the middle button orbits, exactly as before.
| Middle-button drag | Pan. Shift together with the middle button orbits the model view. |
| `PAN` / `P` | Pan with the left button until Escape. |
`ZOOMFACTOR` scales what one notch does. The system variable takes 3 to 100 —
the range it has everywhere — and `SETVAR ZOOMFACTOR` accepts nothing outside
it. Options → User Preferences reaches 500 for anyone who wants a faster wheel:
the slider covers the variable's own range, the field beside it takes any value
up to that ceiling, and both show what a notch does as a percentage. A value
above 100 therefore lives only in this application's own configuration, and a
bare `SETVAR ZOOMFACTOR` still reports it.
A notch multiplies the camera distance by `0.9 ^ (ZOOMFACTOR / 60)`, so the
default 60 brings the view a tenth closer, a notch in and a notch out cancel
exactly, and no setting can drive the distance to zero or through it. A floating
viewport in MSPACE zooms its own view the same way, from a base of 0.85 — the
15% a notch has always moved a viewport.
Panning covers the same ground whichever way it starts: the active model tile,
a paper sheet, or the model view inside a floating viewport in MSPACE. A pan
dragged out of a two-finger gesture and one dragged with the middle button move
@@ -46,8 +60,10 @@ from the split described below; it is not a separate setting.
means. A wheel reports notches and zooms; a precise-scrolling device reports
pixels and pans. There is no modifier and no setting in that decision.
`pan_active_view` and `zoom_view_at_cursor` are the bodies shared by the mouse
and the gestures, and they apply a pinch as `1 - step / 10 = 1 / (1 + m)` so a
pinch of `m` leaves the view exactly `1 + m` times closer.
and the gestures. A pinch of `m` leaves the view exactly `1 + m` times closer:
`pinch_zoom_steps` asks the camera for the step that applies that ratio
(`Camera::zoom_steps_for_ratio`) instead of inverting the zoom law here, which
is what let the two drift apart when the law stopped being a subtraction.
- `src/app/mod.rs` carries one `Message::TrackpadPinch(f32)` per gesture event.
Pixel deltas pan only where they can only mean a trackpad. The web build
@@ -64,7 +80,9 @@ cargo test --locked --lib scroll_intent
```
The tests cover the decision between a notch and a pixel, and the 1:1 mapping of
a pinch onto the camera. The gestures themselves are checked by hand: macOS
a pinch onto the camera — the mapping by zooming a real `Camera` and measuring
the distance it ends at, so it is checked against the camera's own zoom law and
not against a second copy of it. The gestures themselves are checked by hand: macOS
offers no way to synthesize a magnify event, so a pinch cannot be driven by a
test. Two-finger pan and pinch are exercised in model space, in a tiled view, on
a paper sheet, and inside a floating viewport in MSPACE.
+37 -2
View File
@@ -1687,13 +1687,23 @@ impl OpenCADStudio {
false,
)),
},
// The system variable keeps its own range. The
// Options window reaches further
// (`settings::ZOOM_FACTOR_MAX`) and may have
// stored a value above this, which a bare
// `SETVAR ZOOMFACTOR` still reports; only setting
// one is held to the range of the variable.
"ZOOMFACTOR" => match &value {
Some(v) => match v.parse::<i32>() {
Ok(factor) if (3..=500).contains(&factor) => {
Ok(factor)
if (crate::app::settings::ZOOM_FACTOR_MIN
..=crate::app::settings::ZOOM_FACTOR_SYSVAR_MAX)
.contains(&factor) =>
{
self.zoom_factor = factor;
Ok((format!("ZOOMFACTOR = {factor}"), true))
}
_ => Err("SETVAR: integer from 3 to 500 required.".into()),
_ => Err("SETVAR: integer from 3 to 100 required.".into()),
},
None => {
Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false))
@@ -3475,6 +3485,31 @@ mod tests {
app
}
/// `SETVAR ZOOMFACTOR` is the system variable, so it keeps the range
/// the variable has — the Options window is where a wheel faster than
/// that is set, and a value set there is still what the variable
/// reports.
#[test]
fn setvar_zoomfactor_keeps_the_range_of_the_system_variable() {
let mut app = fresh_app();
let _ = app.run_command_line("SETVAR ZOOMFACTOR 100");
assert_eq!(app.zoom_factor, 100);
let _ = app.run_command_line("SETVAR ZOOMFACTOR 101");
assert_eq!(app.zoom_factor, 100, "101 is not a value the variable takes");
let _ = app.run_command_line("SETVAR ZOOMFACTOR 2");
assert_eq!(app.zoom_factor, 100, "and neither is 2");
// What the Options field reached is still reported here, and the
// command line can still set any value the variable does have.
let _ = app.update(crate::app::Message::ZoomFactorInputChanged("250".into()));
assert_eq!(app.zoom_factor, 250);
let _ = app.run_command_line("SETVAR ZOOMFACTOR 60");
assert_eq!(app.zoom_factor, 60);
}
/// `SETVAR ISOLINES` stored any parseable `i16` verbatim. The header
/// value is the per-face isolate count handed to the tessellation
/// kernel (which loops `0..count`), so `ISOLINES 32767` made every
+15 -2
View File
@@ -557,8 +557,15 @@ pub(super) struct OpenCADStudio {
polar_increment_deg: f32,
/// Reverse the mouse-wheel zoom direction when true (ZOOMWHEEL = 1).
zoom_wheel_reversed: bool,
/// Mouse-wheel zoom sensitivity, clamped to 3..=500 (ZOOMFACTOR).
/// Mouse-wheel zoom sensitivity (ZOOMFACTOR), held inside
/// `settings::ZOOM_FACTOR_MIN..=settings::ZOOM_FACTOR_MAX`. `SETVAR`
/// only sets the range the system variable itself has; the Options
/// window reaches the rest.
zoom_factor: i32,
/// The Options field behind `zoom_factor`. A text field is edited as
/// text — cleared, half-typed — so the buffer is what the user sees and
/// the value follows only once it parses.
zoom_factor_input: String,
/// Crosshair size setting (CURSORSIZE, 1..=100).
cursor_size: i32,
/// Selection-box size setting (PICKBOX, 0..=50).
@@ -2322,8 +2329,13 @@ pub enum Message {
CommandLineFadeChanged(i32),
/// Toggle reversing the mouse-wheel zoom direction (ZOOMWHEEL).
ZoomWheelReversedChanged(bool),
/// Change how far one wheel notch zooms (ZOOMFACTOR, 3..=500).
/// Change how far one wheel notch zooms, from the Options slider
/// (ZOOMFACTOR).
ZoomFactorChanged(i32),
/// Edit the Options zoom-factor field; committed when it holds a number,
/// which is how a value above the system variable's own range is set
/// (ZOOMFACTOR).
ZoomFactorInputChanged(String),
/// Options → User Preferences: right-click behaviour (SHORTCUTMENU).
RightClickModeChanged(settings::RightClickMode),
/// Options → User Preferences: time-sensitive hold threshold, ms.
@@ -4094,6 +4106,7 @@ impl OpenCADStudio {
polar_increment_deg: 45.0,
zoom_wheel_reversed: false,
zoom_factor: 60,
zoom_factor_input: 60.to_string(),
cursor_size: 5,
pick_box: 3,
double_click_block_refedit: false,
+53 -7
View File
@@ -140,7 +140,7 @@ impl OpenCADStudio {
#[cfg(test)]
mod tests {
use crate::app::{Message, OpenCADStudio};
use crate::app::{settings, Message, OpenCADStudio};
fn app() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
@@ -223,14 +223,60 @@ mod tests {
assert!(!app.tabs[i].dirty, "the drawing is as unmodified as it was");
}
/// The Options window is the surface that reaches past the range the
/// ZOOMFACTOR system variable has, and its field is what reaches: a
/// number above the variable's own ceiling is kept, one above what the
/// application can store is held there, and text that is not yet a
/// number leaves the setting alone so the field can be cleared and
/// retyped.
#[test]
fn zoom_factor_supports_values_above_100() {
fn the_zoom_factor_field_reaches_past_the_system_variable() {
let mut app = app();
let _ = app.update(Message::ZoomFactorChanged(250));
let _ = app.update(Message::ZoomFactorInputChanged("250".into()));
assert_eq!(app.zoom_factor, 250);
let _ = app.update(Message::ZoomFactorChanged(500));
assert_eq!(app.zoom_factor, 500);
let _ = app.update(Message::ZoomFactorChanged(600));
assert_eq!(app.zoom_factor, 500, "clamped to new upper limit of 500");
let _ = app.update(Message::ZoomFactorInputChanged("600".into()));
assert_eq!(app.zoom_factor, settings::ZOOM_FACTOR_MAX);
let _ = app.update(Message::ZoomFactorInputChanged(String::new()));
assert_eq!(
app.zoom_factor,
settings::ZOOM_FACTOR_MAX,
"a cleared field keeps the value it set"
);
assert_eq!(app.zoom_factor_input, "", "while the field stays as typed");
let _ = app.update(Message::ZoomFactorInputChanged("nonsense".into()));
assert_eq!(app.zoom_factor, settings::ZOOM_FACTOR_MAX);
let _ = app.update(Message::ZoomFactorInputChanged("1".into()));
assert_eq!(app.zoom_factor, settings::ZOOM_FACTOR_MIN, "the floor holds");
}
/// The slider and the field are two views of one setting: dragging the
/// slider has to leave the number beside it reading what it just set, or
/// the window shows two different answers for what the wheel does.
#[test]
fn the_zoom_factor_slider_and_field_stay_in_step() {
let mut app = app();
let _ = app.update(Message::ZoomFactorInputChanged("250".into()));
let _ = app.update(Message::ZoomFactorChanged(40));
assert_eq!(app.zoom_factor, 40);
assert_eq!(app.zoom_factor_input, "40");
}
/// Close puts a zoom factor back like every other preference — the
/// field's text included, since it is edited in front of the value.
#[test]
fn closing_puts_the_zoom_factor_and_its_field_back() {
let mut app = app();
let _ = app.update(Message::OptionsOpen);
let before = app.zoom_factor;
let _ = app.update(Message::ZoomFactorInputChanged("250".into()));
assert!(app.options_dirty());
let _ = app.update(Message::OptionsClose);
let _ = app.update(Message::OptionsCloseDiscard);
assert_eq!(app.zoom_factor, before);
assert_eq!(app.zoom_factor_input, before.to_string());
}
}
+64
View File
@@ -169,6 +169,43 @@ pub fn clamp_right_click_hold_ms(v: i32) -> i32 {
v.clamp(100, 1000)
}
/// ZOOMFACTOR bounds.
///
/// `ZOOM_FACTOR_SYSVAR_MAX` is the range the system variable has: `SETVAR
/// ZOOMFACTOR` accepts 3 to 100 and nothing else, so a drawing session set
/// up on the command line stays within the range the variable has everywhere
/// else. The Options window is the app's own surface and reaches
/// `ZOOM_FACTOR_MAX` for anyone who wants a faster wheel than the variable
/// can express; that value lives only in this application's own
/// configuration file, so it is nobody else's to read.
pub const ZOOM_FACTOR_MIN: i32 = 3;
pub const ZOOM_FACTOR_SYSVAR_MAX: i32 = 100;
pub const ZOOM_FACTOR_MAX: i32 = 500;
/// Hold a ZOOMFACTOR inside the range the application can store, wherever it
/// arrives from — the slider, the Options field, or a configuration file
/// written by hand or by an older build.
pub fn clamp_zoom_factor(v: i32) -> i32 {
v.clamp(ZOOM_FACTOR_MIN, ZOOM_FACTOR_MAX)
}
/// The `Camera::zoom` step one wheel notch means at `factor`. The default 60
/// is one whole step, which is what makes 60 the setting that leaves the
/// camera's own `ZOOM_STEP` untouched.
pub fn zoom_notch_steps(factor: i32) -> f32 {
factor as f32 / 60.0
}
/// How much nearer one wheel notch brings the view at `factor`, as a
/// percentage — the number the Options window shows, so that what the slider
/// and the field are setting is readable instead of implied. Derived from the
/// camera's own law, never from a second copy of it.
pub fn zoom_notch_percent(factor: i32) -> f32 {
let ratio = crate::scene::view::camera::Camera::ZOOM_STEP
.powf(zoom_notch_steps(clamp_zoom_factor(factor)));
(1.0 - ratio) * 100.0
}
/// Active pair of axes while isometric drafting is enabled.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum IsoPlane {
@@ -816,6 +853,33 @@ impl Default for UserSettings {
mod tests {
use super::*;
/// The Options read-out has to say what a notch really does, or it is
/// worse than no read-out. These are the percentages of the law in
/// `Camera::zoom`, which is where the number comes from: the default a
/// tenth of the distance, the top of the system variable's range a
/// sixth, and the top of the application's range a little over half.
#[test]
fn the_zoom_read_out_says_what_a_notch_does() {
for (factor, percent) in [(3, 0.53_f32), (60, 10.0), (100, 16.1), (500, 58.4)] {
let shown = zoom_notch_percent(factor);
assert!(
(shown - percent).abs() < 0.05,
"ZOOMFACTOR {factor} reads {shown}%, not {percent}%"
);
}
// A value from outside the range reads as the one that will be used.
assert_eq!(zoom_notch_percent(900), zoom_notch_percent(ZOOM_FACTOR_MAX));
assert_eq!(zoom_notch_percent(0), zoom_notch_percent(ZOOM_FACTOR_MIN));
}
/// The default is one whole step, which is the whole reason the camera's
/// base is the per-notch ratio and not something scaled.
#[test]
fn the_default_zoom_factor_is_one_step() {
assert_eq!(zoom_notch_steps(60), 1.0);
assert_eq!(UserSettings::default().zoom_factor, 60);
}
/// Object snap ships live. The modes were always pre-selected; only the
/// master switch was off, so a new user got a configured snap set that
/// did nothing until they found the status-bar pill.
+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
);
}
}
}
+1
View File
@@ -330,6 +330,7 @@ impl OpenCADStudio {
self.spacemouse_paused, self.spacemouse_details,
),
&self.snap_angle_input,
&self.zoom_factor_input,
{
let header = self
.tabs
+89 -2
View File
@@ -214,6 +214,94 @@ impl Scene {
}
}
/// What one zoom step does to a floating viewport's `view_height`: zooming
/// in shrinks it, so the scale rises and the model inside appears larger.
///
/// Exponential for the same reason [`crate::scene::view::camera::Camera::zoom`]
/// is. The subtraction this replaced (`1 - 0.15 * steps`) reached zero at
/// 6.67 steps and went negative past it, so from ZOOMFACTOR 360 up every
/// notch inside a viewport bottomed out on the clamp — a tenfold jump a
/// notch, and a sensitivity setting that stopped doing anything over the top
/// third of its range.
///
/// The base is 0.85 where the camera's is 0.9 only because 15% is what a
/// notch has always moved a viewport: each surface keeps the feel it has
/// rather than inheriting the other's. The clamp stays to bound what a
/// single step can do.
fn viewport_zoom_factor(steps: f64) -> f64 {
0.85_f64.powf(steps).clamp(0.1, 10.0)
}
#[cfg(test)]
mod viewport_zoom_tests {
use super::*;
/// One notch, at each ZOOMFACTOR the application can hold (the step is
/// the factor over the default 60). Every one of them has to shrink the
/// view, and a higher setting has to shrink it further — which is exactly
/// what stopped being true above 360 while the factor was a subtraction.
#[test]
fn a_notch_shrinks_the_view_further_at_every_zoom_factor() {
let mut previous = 1.0;
for factor in [3.0, 60.0, 100.0, 250.0, 500.0] {
let f = viewport_zoom_factor(factor / 60.0);
assert!(f > 0.0 && f < 1.0, "ZOOMFACTOR {factor} gave {f}");
assert!(f < previous, "ZOOMFACTOR {factor} did not reach further");
previous = f;
}
}
/// The default notch still moves a viewport the 15% it always has.
#[test]
fn the_default_notch_is_what_it_always_was() {
assert!((viewport_zoom_factor(1.0) - 0.85).abs() < 1e-12);
// And no step at all leaves the viewport exactly where it is.
assert_eq!(viewport_zoom_factor(0.0), 1.0);
}
/// In and out cancel exactly, as they now do for the model-space camera.
#[test]
fn a_notch_in_and_a_notch_out_cancel() {
for factor in [3.0, 60.0, 500.0] {
let steps = factor / 60.0;
let round_trip = viewport_zoom_factor(steps) * viewport_zoom_factor(-steps);
assert!((round_trip - 1.0).abs() < 1e-12, "ZOOMFACTOR {factor}: {round_trip}");
}
}
/// The same thing through the viewport the user is really zooming: at the
/// top of the range each notch keeps shrinking the view by the same
/// ratio, instead of every notch alike bottoming out on the clamp.
#[test]
fn a_hard_notch_keeps_shrinking_the_active_viewport() {
let mut scene = Scene::new();
let mut viewport = codec::entities::Viewport::new();
viewport.center = codec::types::Vector3::new(0.0, 0.0, 0.0);
viewport.width = 100.0;
viewport.height = 100.0;
viewport.view_height = 200.0;
let handle = scene.add_entity(EntityType::Viewport(viewport));
scene.active_viewport = Some(handle);
let view_height = |scene: &Scene| match scene.document.get_entity(handle) {
Some(EntityType::Viewport(vp)) => vp.view_height,
_ => panic!("the viewport is still there"),
};
let steps = 500.0 / 60.0; // one notch at ZOOMFACTOR 500
let mut heights = vec![view_height(&scene)];
for _ in 0..3 {
scene.zoom_active_viewport(steps, None);
heights.push(view_height(&scene));
}
for step in heights.windows(2) {
assert!(step[1] > 0.0 && step[1] < step[0], "{heights:?}");
}
let first = heights[1] / heights[0];
let last = heights[3] / heights[2];
assert!((first - last).abs() < 1e-9, "the notch saturated: {heights:?}");
}
}
#[cfg(test)]
mod clip_tests {
use super::*;
@@ -378,8 +466,7 @@ impl Scene {
if vp.status.locked {
return;
}
// Zoom in = shrink view_height → higher scale → objects appear larger.
let factor = (1.0_f64 - 0.15 * steps as f64).clamp(0.1, 10.0);
let factor = viewport_zoom_factor(steps as f64);
if let Some(cp) = cursor_paper {
// Compute the model-space point under the cursor before zoom.
+163 -1
View File
@@ -491,8 +491,51 @@ impl Camera {
self.sync_yaw_pitch();
}
/// The ratio one zoom step applies to `distance`. A wheel notch is one
/// step at the default `ZOOMFACTOR` of 60, so the default notch still
/// moves the view a tenth closer, exactly as it always has.
pub const ZOOM_STEP: f32 = 0.9;
/// How near and how far `zoom` may take the eye. The floor has always
/// been here; the ceiling matters because the perspective far plane is
/// `distance * 1000` ([`Camera::view_proj_rte`]), so a distance past
/// ~3.4e35 renders an infinite matrix and the viewport goes black.
/// Zooming out multiplies, so with a fast wheel that ceiling is only a
/// couple of seconds of scrolling away.
const DISTANCE_MIN: f32 = 0.001;
const DISTANCE_MAX: f32 = 1e15;
/// Zoom by `delta` steps: a notch of the wheel, a pinch, a drag of ZOOM
/// Dynamic. Positive moves the eye in.
///
/// Each step multiplies `distance` by [`Camera::ZOOM_STEP`], which is
/// strictly positive for every real `delta` and exactly reciprocal
/// between in and out — a notch in and a notch out land back where they
/// started, where the subtraction this replaced (`1 - delta * 0.1`) lost
/// a percent of the view each round trip. That subtraction agreed with
/// this only at one notch of the default `ZOOMFACTOR`: it reached zero at
/// `delta` 10 and went negative past it, inverting the view through its
/// target — reachable from a high `ZOOMFACTOR` and from a fast ZOOM
/// Dynamic drag alike.
pub fn zoom(&mut self, delta: f32) {
self.distance = (self.distance * 0.9f32.powf(delta)).max(0.001);
self.distance = (self.distance * Self::ZOOM_STEP.powf(delta))
.clamp(Self::DISTANCE_MIN, Self::DISTANCE_MAX);
}
/// The `delta` `zoom` needs in order to scale `distance` by `ratio`:
/// `zoom(zoom_steps_for_ratio(r))` multiplies the distance by exactly
/// `r`.
///
/// A caller that owes the user a particular ratio — a trackpad pinch
/// tracks the fingers 1:1 — asks for its step here rather than inverting
/// the zoom law on its own, so the two cannot drift apart. A ratio that
/// is not a positive, finite number means nothing to invert, and stands
/// for no zoom at all.
pub fn zoom_steps_for_ratio(ratio: f32) -> f32 {
if !(ratio.is_finite() && ratio > 0.0) {
return 0.0;
}
ratio.ln() / Self::ZOOM_STEP.ln()
}
/// World-space offset from `target` to the point under `screen` on the
@@ -774,6 +817,125 @@ pub fn yaw_pitch_to_quat(yaw: f32, pitch: f32, roll: f32) -> Quat {
mod tests {
use super::*;
/// One wheel notch, at each ZOOMFACTOR the application can hold (the
/// step is the factor over the default 60). Every one has to bring the
/// eye nearer, a higher setting has to bring it nearer still, and none
/// may reach zero or cross it — the subtraction this replaced hit zero at
/// ten steps and inverted the view beyond that.
#[test]
fn a_notch_zooms_in_at_every_zoom_factor_without_collapsing() {
let mut previous = f32::INFINITY;
for factor in [3.0_f32, 60.0, 100.0, 250.0, 500.0] {
let mut camera = Camera::default();
let before = camera.distance;
camera.zoom(factor / 60.0);
assert!(
camera.distance > 0.0 && camera.distance < before,
"ZOOMFACTOR {factor} left distance at {}",
camera.distance
);
assert!(
camera.distance < previous,
"ZOOMFACTOR {factor} did not reach further than the one below it"
);
previous = camera.distance;
}
}
/// The default notch is still a tenth of the distance, which is what the
/// subtraction gave at `ZOOMFACTOR = 60` and the reason the base is 0.9.
#[test]
fn the_default_notch_is_what_it_always_was() {
let mut camera = Camera::default();
let before = camera.distance;
camera.zoom(1.0);
assert!((camera.distance - before * 0.9).abs() < 1e-4);
// And a scroll that resolves to nothing moves nothing at all.
let held = camera.distance;
camera.zoom(0.0);
assert_eq!(camera.distance, held);
}
/// A notch in and a notch out land back where they started. The
/// subtraction lost a percent of the view on every round trip (0.9 × 1.1
/// = 0.99), which is the drift people felt as the view creeping closer
/// while they scrolled back and forth.
#[test]
fn a_notch_in_and_a_notch_out_cancel() {
for factor in [3.0_f32, 60.0, 100.0, 500.0] {
let mut camera = Camera::default();
let before = camera.distance;
camera.zoom(factor / 60.0);
camera.zoom(-factor / 60.0);
assert!(
(camera.distance - before).abs() < 1e-3,
"ZOOMFACTOR {factor}: {} not {before}",
camera.distance
);
}
}
/// Zooming out multiplies, so a held wheel at the top of the range used
/// to run the distance past what an f32 far plane (`distance * 1000`) can
/// hold in about ninety notches, and the viewport went black on an
/// infinite projection. The ceiling has to keep the matrix finite however
/// long the wheel is turned.
#[test]
fn a_long_scroll_out_keeps_the_projection_finite() {
let bounds = Rectangle::with_size(iced::Size::new(800., 600.));
for projection in [Projection::Orthographic, Projection::Perspective] {
let mut camera = Camera {
projection,
rotation: Quat::IDENTITY,
..Camera::default()
};
for _ in 0..500 {
camera.zoom(-500.0 / 60.0);
}
assert!(
camera.distance.is_finite() && camera.distance > 0.0,
"{projection:?} ran to {}",
camera.distance
);
assert!(
(camera.distance * 1000.0).is_finite(),
"{projection:?} far plane is not finite"
);
assert!(
camera.view_proj_rte(bounds).is_finite(),
"{projection:?} projection is not finite"
);
}
// The floor is still a floor, however long the wheel turns inward.
let mut camera = Camera::default();
for _ in 0..500 {
camera.zoom(500.0 / 60.0);
}
assert!(camera.distance > 0.0, "the eye reached the target");
}
/// The published inverse has to be exactly that: ask for a ratio, get the
/// step that applies it. Every caller that owes the user an exact ratio
/// depends on this, so that none of them inverts the zoom law itself.
#[test]
fn zoom_steps_for_ratio_inverts_zoom() {
for ratio in [0.1_f32, 0.5, 0.95, 1.0, 1.05, 2.0, 10.0] {
let mut camera = Camera::default();
let before = camera.distance;
camera.zoom(Camera::zoom_steps_for_ratio(ratio));
let want = before * ratio;
assert!(
(camera.distance - want).abs() < 1e-4 * want,
"ratio {ratio}: {} not {want}",
camera.distance
);
}
// Nothing to invert means no zoom, rather than a step into infinity.
for ratio in [0.0_f32, -1.0, f32::NAN, f32::INFINITY] {
assert_eq!(Camera::zoom_steps_for_ratio(ratio), 0.0, "ratio {ratio}");
}
}
#[test]
fn projection_uses_the_webgpu_depth_range_once() {
let bounds = Rectangle::with_size(iced::Size::new(800., 600.));
+31 -5
View File
@@ -1,5 +1,6 @@
pub(crate) mod spacemouse;
use crate::app::config::UiThemeConfig;
use crate::app::settings;
use crate::app::settings::{CursorType, RightClickMode};
use crate::app::Message;
use crate::ui::style::form::{dialog_button, dialog_button_styled_opt};
@@ -55,7 +56,8 @@ pub struct AppPrefs {
pub commandline_fade_ms: i32,
/// ZOOMWHEEL: reverse the mouse-wheel zoom direction.
pub zoom_wheel_reversed: bool,
/// ZOOMFACTOR, 3..=500.
/// ZOOMFACTOR. The slider sets the range the system variable has; the
/// field beside it reaches `settings::ZOOM_FACTOR_MAX`.
pub zoom_factor: i32,
/// TEXTEDITMODE: TEXTEDIT keeps prompting for the next object.
pub texteditmode: bool,
@@ -200,6 +202,7 @@ pub fn view_window<'a>(
prefs: AppPrefs,
spacemouse: Element<'a, Message>,
snap_angle_input: &'a str,
zoom_factor_input: &'a str,
drawing_prefs: DrawingPrefs,
folders: Folders,
double_click_block_refedit: bool,
@@ -1181,10 +1184,24 @@ pub fn view_window<'a>(
Space::new().height(12),
row![
text(crate::t!("Zoom factor")).size(12).width(150),
slider(3..=500, prefs.zoom_factor.clamp(3, 500), Message::ZoomFactorChanged)
.step(1)
.width(Fill),
text(prefs.zoom_factor.clamp(3, 500).to_string()).size(11).width(44),
slider(
settings::ZOOM_FACTOR_MIN..=settings::ZOOM_FACTOR_SYSVAR_MAX,
prefs
.zoom_factor
.clamp(settings::ZOOM_FACTOR_MIN, settings::ZOOM_FACTOR_SYSVAR_MAX),
Message::ZoomFactorChanged,
)
.step(1)
.width(Fill),
text_input("60", zoom_factor_input)
.on_input(Message::ZoomFactorInputChanged)
.width(52),
text(crate::tf!(
"{:.1}% a notch",
settings::zoom_notch_percent(prefs.zoom_factor)
))
.size(11)
.width(76),
]
.spacing(10)
.align_y(iced::Center),
@@ -1192,6 +1209,15 @@ pub fn view_window<'a>(
text(crate::t!("How far one wheel notch zooms (ZOOMFACTOR)."))
.size(11)
.width(sizing.width),
Space::new().height(4),
text(crate::tf!(
"The slider covers the system variable's {} to {}; the field takes up to {} for a faster wheel.",
settings::ZOOM_FACTOR_MIN,
settings::ZOOM_FACTOR_SYSVAR_MAX,
settings::ZOOM_FACTOR_MAX
))
.size(11)
.width(sizing.width),
Space::new().height(24),
text(crate::t!("Text and Dimensions")).size(15),
Space::new().height(10),