forked from 1b-agent/opencadstudio-upstream
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:
commit
403a277588
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