283 lines
9.0 KiB
Rust
283 lines
9.0 KiB
Rust
//! Scenario camera policy. Bounds are from the exact displayed presentation,
|
|
//! including its origin and automatic fallback; no model geometry is authored.
|
|
use crate::behavior::canvas_workspace::{self, Event};
|
|
use iced::{Rectangle, Size, Vector};
|
|
|
|
pub(super) const MIN_SCALE: f32 = f32::from_bits(1);
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub(super) struct Camera {
|
|
pub(super) scale: f32,
|
|
pub(super) pan: Vector,
|
|
}
|
|
|
|
/// Fit never enlarges above 100%. Use f64 intermediates so a valid f32 origin
|
|
/// and size cannot overflow while finding the center. Unrepresentable cameras
|
|
/// are refused rather than silently clamped to an unrelated minimum scale.
|
|
pub(super) fn fit(bounds: Rectangle, viewport: Size) -> Option<Camera> {
|
|
if [
|
|
bounds.x,
|
|
bounds.y,
|
|
bounds.width,
|
|
bounds.height,
|
|
viewport.width,
|
|
viewport.height,
|
|
]
|
|
.iter()
|
|
.any(|n| !n.is_finite())
|
|
|| bounds.width < 0.
|
|
|| bounds.height < 0.
|
|
|| viewport.width <= 0.
|
|
|| viewport.height <= 0.
|
|
{
|
|
return None;
|
|
}
|
|
let width = f64::from(viewport.width);
|
|
let height = f64::from(viewport.height);
|
|
let scale = ((width - 2. * (width / 4.).min(8.)) / f64::from(bounds.width).max(1.))
|
|
.min((height - 2. * (height / 4.).min(8.)) / f64::from(bounds.height).max(1.))
|
|
.min(1.) as f32;
|
|
if !scale.is_finite() || scale <= 0. {
|
|
return None;
|
|
}
|
|
let x = width / 2. - (f64::from(bounds.x) + f64::from(bounds.width) / 2.) * f64::from(scale);
|
|
let y = height / 2. - (f64::from(bounds.y) + f64::from(bounds.height) / 2.) * f64::from(scale);
|
|
let pan = Vector::new(x as f32, y as f32);
|
|
(pan.x.is_finite() && pan.y.is_finite()).then_some(Camera { scale, pan })
|
|
}
|
|
|
|
pub(super) fn effective_camera(
|
|
pan: Vector,
|
|
zoom: Option<f32>,
|
|
fitting: bool,
|
|
viewport: Option<Size>,
|
|
bounds: Rectangle,
|
|
) -> Camera {
|
|
if fitting && let Some(camera) = viewport.and_then(|size| fit(bounds, size)) {
|
|
return camera;
|
|
}
|
|
Camera {
|
|
scale: zoom.unwrap_or(1.),
|
|
pan,
|
|
}
|
|
}
|
|
|
|
/// None preserves Fit as a mode, including before the first viewport arrives.
|
|
pub(super) fn set_zoom(
|
|
pan: &mut Vector,
|
|
zoom: &mut Option<f32>,
|
|
fitting: &mut bool,
|
|
scale: Option<f32>,
|
|
) -> bool {
|
|
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
|
|
return false;
|
|
}
|
|
let next = scale.map(|value| value.min(3.));
|
|
let next_pan = if scale.is_none() { Vector::ZERO } else { *pan };
|
|
let changed = *zoom != next || *fitting != scale.is_none() || *pan != next_pan;
|
|
*zoom = next;
|
|
*fitting = scale.is_none();
|
|
*pan = next_pan;
|
|
changed
|
|
}
|
|
|
|
/// Materialize the visible fitted camera only when a camera gesture succeeds.
|
|
/// A zero-motion or rejected event preserves the original mode and fields.
|
|
pub(super) fn change(
|
|
pan: &mut Vector,
|
|
zoom: &mut Option<f32>,
|
|
fitting: &mut bool,
|
|
viewport: Option<Size>,
|
|
bounds: Rectangle,
|
|
event: Event,
|
|
) -> bool {
|
|
let camera = effective_camera(*pan, *zoom, *fitting, viewport, bounds);
|
|
let (mut next_pan, mut next_zoom, mut next_fit) = (camera.pan, Some(camera.scale), false);
|
|
let event = match event {
|
|
Event::Zoom { base, .. } if !base.is_finite() || base <= 0. => return false,
|
|
Event::Zoom { factor, at, .. } => Event::Zoom {
|
|
factor,
|
|
at,
|
|
base: camera.scale,
|
|
},
|
|
event => event,
|
|
};
|
|
if !canvas_workspace::change_with_minimum(
|
|
&mut next_pan,
|
|
&mut next_zoom,
|
|
&mut next_fit,
|
|
event,
|
|
MIN_SCALE,
|
|
) {
|
|
return false;
|
|
}
|
|
*pan = next_pan;
|
|
*zoom = next_zoom;
|
|
*fitting = next_fit;
|
|
true
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use iced::Point;
|
|
|
|
fn bounds() -> Rectangle {
|
|
Rectangle {
|
|
x: -1_000_160.,
|
|
y: -200.,
|
|
width: 2_000_400.,
|
|
height: 1_000.,
|
|
}
|
|
}
|
|
fn near(actual: f32, expected: f32, tolerance: f32) {
|
|
assert!(
|
|
(actual - expected).abs() <= tolerance,
|
|
"{actual} != {expected}"
|
|
);
|
|
}
|
|
fn projected(camera: Camera, point: Point) -> Point {
|
|
Point::new(
|
|
point.x * camera.scale + camera.pan.x,
|
|
point.y * camera.scale + camera.pan.y,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn negative_origin_fit_contains_independently_known_extremes() {
|
|
let camera = fit(bounds(), Size::new(700., 500.)).unwrap();
|
|
near(camera.scale, 0.0003419316, 1e-10);
|
|
near(camera.pan.x, 349.98633, 0.0001);
|
|
near(
|
|
projected(camera, Point::new(-1_000_160., -200.)).x,
|
|
8.,
|
|
0.0001,
|
|
);
|
|
near(
|
|
projected(camera, Point::new(1_000_240., 800.)).x,
|
|
692.,
|
|
0.0001,
|
|
);
|
|
near(projected(camera, Point::new(40., 300.)).y, 250., 0.0001);
|
|
}
|
|
|
|
#[test]
|
|
fn fit_remains_active_across_first_and_changed_viewport() {
|
|
let (mut pan, mut zoom, mut fitting) = (Vector::new(50., 80.), Some(0.8), false);
|
|
assert!(set_zoom(&mut pan, &mut zoom, &mut fitting, None));
|
|
let retained = effective_camera(pan, zoom, fitting, Some(Size::new(1200., 500.)), bounds());
|
|
let actual = effective_camera(pan, zoom, fitting, Some(Size::new(500., 500.)), bounds());
|
|
assert!(actual.scale < retained.scale);
|
|
near(
|
|
projected(actual, Point::new(-1_000_160., -200.)).x,
|
|
8.,
|
|
0.0001,
|
|
);
|
|
near(
|
|
projected(actual, Point::new(1_000_240., 800.)).x,
|
|
492.,
|
|
0.0001,
|
|
);
|
|
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
|
|
}
|
|
|
|
#[test]
|
|
fn tiny_fit_zoom_in_out_preserves_pointer_world_anchor_without_floor_jump() {
|
|
let viewport = Some(Size::new(700., 500.));
|
|
let start = fit(bounds(), viewport.unwrap()).unwrap();
|
|
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
|
|
let at = Point::new(210., 180.);
|
|
let world = Point::new(
|
|
(at.x - start.pan.x) / start.scale,
|
|
(at.y - start.pan.y) / start.scale,
|
|
);
|
|
for factor in [1.25, 0.8] {
|
|
let prior_scale = zoom.unwrap_or(start.scale);
|
|
assert!(change(
|
|
&mut pan,
|
|
&mut zoom,
|
|
&mut fitting,
|
|
viewport,
|
|
bounds(),
|
|
Event::Zoom {
|
|
factor,
|
|
at,
|
|
base: start.scale,
|
|
}
|
|
));
|
|
let camera = effective_camera(pan, zoom, fitting, viewport, bounds());
|
|
near(camera.scale, prior_scale * factor, 1e-10);
|
|
assert!(camera.scale < 0.001);
|
|
near(projected(camera, world).x, at.x, 0.0001);
|
|
near(projected(camera, world).y, at.y, 0.0001);
|
|
}
|
|
near(zoom.unwrap(), start.scale, 1e-10);
|
|
near(pan.x, start.pan.x, 0.0001);
|
|
assert!(!fitting);
|
|
}
|
|
|
|
#[test]
|
|
fn pan_leaves_fit_from_displayed_position_and_zero_motion_preserves_fit() {
|
|
let viewport = Some(Size::new(700., 500.));
|
|
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
|
|
for event in [
|
|
Event::Pan(Vector::ZERO),
|
|
Event::Zoom {
|
|
factor: 1.,
|
|
at: Point::ORIGIN,
|
|
base: 1.,
|
|
},
|
|
] {
|
|
assert!(!change(
|
|
&mut pan,
|
|
&mut zoom,
|
|
&mut fitting,
|
|
viewport,
|
|
bounds(),
|
|
event
|
|
));
|
|
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
|
|
}
|
|
let start = fit(bounds(), viewport.unwrap()).unwrap();
|
|
assert!(change(
|
|
&mut pan,
|
|
&mut zoom,
|
|
&mut fitting,
|
|
viewport,
|
|
bounds(),
|
|
Event::Pan(Vector::new(20., -30.))
|
|
));
|
|
assert_eq!(pan, start.pan + Vector::new(20., -30.));
|
|
assert_eq!(zoom, Some(start.scale));
|
|
assert!(!fitting);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_inputs_are_passive_and_large_finite_bounds_do_not_overflow_center() {
|
|
assert!(fit(bounds(), Size::new(f32::NAN, 500.)).is_none());
|
|
let extreme = Rectangle {
|
|
x: f32::MAX,
|
|
y: 0.,
|
|
width: f32::MAX,
|
|
height: 100.,
|
|
};
|
|
let camera = fit(extreme, Size::new(700., 500.)).unwrap();
|
|
assert!(camera.scale > 0. && camera.scale < 1e-30 && camera.pan.x.is_finite());
|
|
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
|
|
assert!(!set_zoom(&mut pan, &mut zoom, &mut fitting, Some(f32::NAN)));
|
|
assert!(!change(
|
|
&mut pan,
|
|
&mut zoom,
|
|
&mut fitting,
|
|
Some(Size::new(700., 500.)),
|
|
bounds(),
|
|
Event::Zoom {
|
|
factor: 1.25,
|
|
at: Point::ORIGIN,
|
|
base: f32::NAN
|
|
}
|
|
));
|
|
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
|
|
}
|
|
}
|