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

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

No files matched your search

+89 -2
View File
@@ -214,6 +214,94 @@ impl Scene {
}
}
/// What one zoom step does to a floating viewport's `view_height`: zooming
/// in shrinks it, so the scale rises and the model inside appears larger.
///
/// Exponential for the same reason [`crate::scene::view::camera::Camera::zoom`]
/// is. The subtraction this replaced (`1 - 0.15 * steps`) reached zero at
/// 6.67 steps and went negative past it, so from ZOOMFACTOR 360 up every
/// notch inside a viewport bottomed out on the clamp — a tenfold jump a
/// notch, and a sensitivity setting that stopped doing anything over the top
/// third of its range.
///
/// The base is 0.85 where the camera's is 0.9 only because 15% is what a
/// notch has always moved a viewport: each surface keeps the feel it has
/// rather than inheriting the other's. The clamp stays to bound what a
/// single step can do.
fn viewport_zoom_factor(steps: f64) -> f64 {
0.85_f64.powf(steps).clamp(0.1, 10.0)
}
#[cfg(test)]
mod viewport_zoom_tests {
use super::*;
/// One notch, at each ZOOMFACTOR the application can hold (the step is
/// the factor over the default 60). Every one of them has to shrink the
/// view, and a higher setting has to shrink it further — which is exactly
/// what stopped being true above 360 while the factor was a subtraction.
#[test]
fn a_notch_shrinks_the_view_further_at_every_zoom_factor() {
let mut previous = 1.0;
for factor in [3.0, 60.0, 100.0, 250.0, 500.0] {
let f = viewport_zoom_factor(factor / 60.0);
assert!(f > 0.0 && f < 1.0, "ZOOMFACTOR {factor} gave {f}");
assert!(f < previous, "ZOOMFACTOR {factor} did not reach further");
previous = f;
}
}
/// The default notch still moves a viewport the 15% it always has.
#[test]
fn the_default_notch_is_what_it_always_was() {
assert!((viewport_zoom_factor(1.0) - 0.85).abs() < 1e-12);
// And no step at all leaves the viewport exactly where it is.
assert_eq!(viewport_zoom_factor(0.0), 1.0);
}
/// In and out cancel exactly, as they now do for the model-space camera.
#[test]
fn a_notch_in_and_a_notch_out_cancel() {
for factor in [3.0, 60.0, 500.0] {
let steps = factor / 60.0;
let round_trip = viewport_zoom_factor(steps) * viewport_zoom_factor(-steps);
assert!((round_trip - 1.0).abs() < 1e-12, "ZOOMFACTOR {factor}: {round_trip}");
}
}
/// The same thing through the viewport the user is really zooming: at the
/// top of the range each notch keeps shrinking the view by the same
/// ratio, instead of every notch alike bottoming out on the clamp.
#[test]
fn a_hard_notch_keeps_shrinking_the_active_viewport() {
let mut scene = Scene::new();
let mut viewport = codec::entities::Viewport::new();
viewport.center = codec::types::Vector3::new(0.0, 0.0, 0.0);
viewport.width = 100.0;
viewport.height = 100.0;
viewport.view_height = 200.0;
let handle = scene.add_entity(EntityType::Viewport(viewport));
scene.active_viewport = Some(handle);
let view_height = |scene: &Scene| match scene.document.get_entity(handle) {
Some(EntityType::Viewport(vp)) => vp.view_height,
_ => panic!("the viewport is still there"),
};
let steps = 500.0 / 60.0; // one notch at ZOOMFACTOR 500
let mut heights = vec![view_height(&scene)];
for _ in 0..3 {
scene.zoom_active_viewport(steps, None);
heights.push(view_height(&scene));
}
for step in heights.windows(2) {
assert!(step[1] > 0.0 && step[1] < step[0], "{heights:?}");
}
let first = heights[1] / heights[0];
let last = heights[3] / heights[2];
assert!((first - last).abs() < 1e-9, "the notch saturated: {heights:?}");
}
}
#[cfg(test)]
mod clip_tests {
use super::*;
@@ -378,8 +466,7 @@ impl Scene {
if vp.status.locked {
return;
}
// Zoom in = shrink view_height → higher scale → objects appear larger.
let factor = (1.0_f64 - 0.15 * steps as f64).clamp(0.1, 10.0);
let factor = viewport_zoom_factor(steps as f64);
if let Some(cp) = cursor_paper {
// Compute the model-space point under the cursor before zoom.
+163 -1
View File
@@ -491,8 +491,51 @@ impl Camera {
self.sync_yaw_pitch();
}
/// The ratio one zoom step applies to `distance`. A wheel notch is one
/// step at the default `ZOOMFACTOR` of 60, so the default notch still
/// moves the view a tenth closer, exactly as it always has.
pub const ZOOM_STEP: f32 = 0.9;
/// How near and how far `zoom` may take the eye. The floor has always
/// been here; the ceiling matters because the perspective far plane is
/// `distance * 1000` ([`Camera::view_proj_rte`]), so a distance past
/// ~3.4e35 renders an infinite matrix and the viewport goes black.
/// Zooming out multiplies, so with a fast wheel that ceiling is only a
/// couple of seconds of scrolling away.
const DISTANCE_MIN: f32 = 0.001;
const DISTANCE_MAX: f32 = 1e15;
/// Zoom by `delta` steps: a notch of the wheel, a pinch, a drag of ZOOM
/// Dynamic. Positive moves the eye in.
///
/// Each step multiplies `distance` by [`Camera::ZOOM_STEP`], which is
/// strictly positive for every real `delta` and exactly reciprocal
/// between in and out — a notch in and a notch out land back where they
/// started, where the subtraction this replaced (`1 - delta * 0.1`) lost
/// a percent of the view each round trip. That subtraction agreed with
/// this only at one notch of the default `ZOOMFACTOR`: it reached zero at
/// `delta` 10 and went negative past it, inverting the view through its
/// target — reachable from a high `ZOOMFACTOR` and from a fast ZOOM
/// Dynamic drag alike.
pub fn zoom(&mut self, delta: f32) {
self.distance = (self.distance * 0.9f32.powf(delta)).max(0.001);
self.distance = (self.distance * Self::ZOOM_STEP.powf(delta))
.clamp(Self::DISTANCE_MIN, Self::DISTANCE_MAX);
}
/// The `delta` `zoom` needs in order to scale `distance` by `ratio`:
/// `zoom(zoom_steps_for_ratio(r))` multiplies the distance by exactly
/// `r`.
///
/// A caller that owes the user a particular ratio — a trackpad pinch
/// tracks the fingers 1:1 — asks for its step here rather than inverting
/// the zoom law on its own, so the two cannot drift apart. A ratio that
/// is not a positive, finite number means nothing to invert, and stands
/// for no zoom at all.
pub fn zoom_steps_for_ratio(ratio: f32) -> f32 {
if !(ratio.is_finite() && ratio > 0.0) {
return 0.0;
}
ratio.ln() / Self::ZOOM_STEP.ln()
}
/// World-space offset from `target` to the point under `screen` on the
@@ -774,6 +817,125 @@ pub fn yaw_pitch_to_quat(yaw: f32, pitch: f32, roll: f32) -> Quat {
mod tests {
use super::*;
/// One wheel notch, at each ZOOMFACTOR the application can hold (the
/// step is the factor over the default 60). Every one has to bring the
/// eye nearer, a higher setting has to bring it nearer still, and none
/// may reach zero or cross it — the subtraction this replaced hit zero at
/// ten steps and inverted the view beyond that.
#[test]
fn a_notch_zooms_in_at_every_zoom_factor_without_collapsing() {
let mut previous = f32::INFINITY;
for factor in [3.0_f32, 60.0, 100.0, 250.0, 500.0] {
let mut camera = Camera::default();
let before = camera.distance;
camera.zoom(factor / 60.0);
assert!(
camera.distance > 0.0 && camera.distance < before,
"ZOOMFACTOR {factor} left distance at {}",
camera.distance
);
assert!(
camera.distance < previous,
"ZOOMFACTOR {factor} did not reach further than the one below it"
);
previous = camera.distance;
}
}
/// The default notch is still a tenth of the distance, which is what the
/// subtraction gave at `ZOOMFACTOR = 60` and the reason the base is 0.9.
#[test]
fn the_default_notch_is_what_it_always_was() {
let mut camera = Camera::default();
let before = camera.distance;
camera.zoom(1.0);
assert!((camera.distance - before * 0.9).abs() < 1e-4);
// And a scroll that resolves to nothing moves nothing at all.
let held = camera.distance;
camera.zoom(0.0);
assert_eq!(camera.distance, held);
}
/// A notch in and a notch out land back where they started. The
/// subtraction lost a percent of the view on every round trip (0.9 × 1.1
/// = 0.99), which is the drift people felt as the view creeping closer
/// while they scrolled back and forth.
#[test]
fn a_notch_in_and_a_notch_out_cancel() {
for factor in [3.0_f32, 60.0, 100.0, 500.0] {
let mut camera = Camera::default();
let before = camera.distance;
camera.zoom(factor / 60.0);
camera.zoom(-factor / 60.0);
assert!(
(camera.distance - before).abs() < 1e-3,
"ZOOMFACTOR {factor}: {} not {before}",
camera.distance
);
}
}
/// Zooming out multiplies, so a held wheel at the top of the range used
/// to run the distance past what an f32 far plane (`distance * 1000`) can
/// hold in about ninety notches, and the viewport went black on an
/// infinite projection. The ceiling has to keep the matrix finite however
/// long the wheel is turned.
#[test]
fn a_long_scroll_out_keeps_the_projection_finite() {
let bounds = Rectangle::with_size(iced::Size::new(800., 600.));
for projection in [Projection::Orthographic, Projection::Perspective] {
let mut camera = Camera {
projection,
rotation: Quat::IDENTITY,
..Camera::default()
};
for _ in 0..500 {
camera.zoom(-500.0 / 60.0);
}
assert!(
camera.distance.is_finite() && camera.distance > 0.0,
"{projection:?} ran to {}",
camera.distance
);
assert!(
(camera.distance * 1000.0).is_finite(),
"{projection:?} far plane is not finite"
);
assert!(
camera.view_proj_rte(bounds).is_finite(),
"{projection:?} projection is not finite"
);
}
// The floor is still a floor, however long the wheel turns inward.
let mut camera = Camera::default();
for _ in 0..500 {
camera.zoom(500.0 / 60.0);
}
assert!(camera.distance > 0.0, "the eye reached the target");
}
/// The published inverse has to be exactly that: ask for a ratio, get the
/// step that applies it. Every caller that owes the user an exact ratio
/// depends on this, so that none of them inverts the zoom law itself.
#[test]
fn zoom_steps_for_ratio_inverts_zoom() {
for ratio in [0.1_f32, 0.5, 0.95, 1.0, 1.05, 2.0, 10.0] {
let mut camera = Camera::default();
let before = camera.distance;
camera.zoom(Camera::zoom_steps_for_ratio(ratio));
let want = before * ratio;
assert!(
(camera.distance - want).abs() < 1e-4 * want,
"ratio {ratio}: {} not {want}",
camera.distance
);
}
// Nothing to invert means no zoom, rather than a step into infinity.
for ratio in [0.0_f32, -1.0, f32::NAN, f32::INFINITY] {
assert_eq!(Camera::zoom_steps_for_ratio(ratio), 0.0, "ratio {ratio}");
}
}
#[test]
fn projection_uses_the_webgpu_depth_range_once() {
let bounds = Rectangle::with_size(iced::Size::new(800., 600.));