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
+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