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 <noreply@anthropic.com>
This commit is contained in:
1 parent
558b5f6526
commit
9fe2e4fa58
12 files changed
+549
-32
No files matched your search
+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.));
|
||||
|
||||
Reference in new issue
Block a user