Wire saved Scenario arrangements through workspace navigation
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user