Files
ARCADIA-Studio/crates/oa-desktop/src/behavior/scenario/camera.rs
T

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));
}
}