diff --git a/docs/trackpad.md b/docs/trackpad.md index f697a3eff..b10ebe3b7 100644 --- a/docs/trackpad.md +++ b/docs/trackpad.md @@ -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. diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index bde923ef3..e9bb61320 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -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::() { - 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 diff --git a/src/app/mod.rs b/src/app/mod.rs index f1975779e..be4af59e3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, diff --git a/src/app/options_session.rs b/src/app/options_session.rs index d0245f64b..0ef0d39cf 100644 --- a/src/app/options_session.rs +++ b/src/app/options_session.rs @@ -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()); } } diff --git a/src/app/settings.rs b/src/app/settings.rs index c85f736bd..a3b679815 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -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. diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 5bfc0e5cd..328ce22dd 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b51839274..e19ec4fa7 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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::() { + 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(); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 8200e8882..83e56870d 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -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 { 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 { 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 + ); + } } } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 0b2a697b9..2bfb97a4f 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -330,6 +330,7 @@ impl OpenCADStudio { self.spacemouse_paused, self.spacemouse_details, ), &self.snap_angle_input, + &self.zoom_factor_input, { let header = self .tabs diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index e94768e3e..d3ad2a908 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -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. diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 8384732c1..b75652cf6 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -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.)); diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 81b16fcbe..bce947795 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -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),