forked from 1b-agent/opencadstudio-upstream
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:
1 parent
558b5f6526
commit
9fe2e4fa58
12 files changed
+549
-32
No files matched your search
+21
-3
@@ -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.
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.));
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user