//! 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 { 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, fitting: bool, viewport: Option, 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, fitting: &mut bool, scale: Option, ) -> 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, fitting: &mut bool, viewport: Option, 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)); } }