use super::FocusOptions;
use super::euclid::{
IntoEuclid, IntoIced, LayoutPoint, LayoutRect, LayoutSize, Screen, ScreenPoint, ScreenRect,
ScreenToWorld, ScreenVector, World, WorldPoint, WorldRect, WorldSize, WorldVector,
};
use euclid::{Scale, Transform2D};
use iced_wgpu::core::{mouse, renderer};
use iced_widget::core::{Padding, Point, Rectangle, Size, Transformation, Vector};
#[derive(Debug, Clone, Copy)]
pub struct Camera2D {
zoom: Scale<f32, Screen, World>,
position: WorldPoint,
viewport_origin: ScreenVector,
}
impl Default for Camera2D {
fn default() -> Self {
Self::new()
}
}
impl Camera2D {
pub const ZOOM_MIN: f32 = 0.1;
pub const ZOOM_MAX: f32 = 10.0;
fn clamp_zoom(zoom: f32) -> f32 {
if zoom.is_finite() {
zoom.clamp(Self::ZOOM_MIN, Self::ZOOM_MAX)
} else {
1.0
}
}
pub fn new() -> Self {
Self {
zoom: Scale::new(1.0),
position: WorldPoint::origin(),
viewport_origin: ScreenVector::zero(),
}
}
pub fn with_zoom_and_position(zoom: f32, position: WorldPoint) -> Self {
Self {
zoom: Scale::new(Self::clamp_zoom(zoom)),
position,
viewport_origin: ScreenVector::zero(),
}
}
pub fn with_viewport_origin(mut self, origin: ScreenVector) -> Self {
self.viewport_origin = origin;
self
}
pub fn viewport_origin(&self) -> ScreenVector {
self.viewport_origin
}
pub fn zoom(&self) -> f32 {
self.zoom.get()
}
pub fn position(&self) -> WorldPoint {
self.position
}
pub fn screen_to_world(&self) -> ScreenToWorld {
let inv_zoom = 1.0 / self.zoom.get();
Transform2D::translation(-self.viewport_origin.x, -self.viewport_origin.y)
.then_scale(inv_zoom, inv_zoom)
.then_translate(-self.position.to_vector())
}
#[cfg(test)]
pub fn world_to_screen(&self) -> Transform2D<f32, World, Screen> {
self.screen_to_world()
.inverse()
.expect("Camera transform must be invertible (zoom cannot be 0)")
}
pub fn move_by(&self, offset: WorldVector) -> Self {
Self {
zoom: self.zoom,
position: self.position + offset,
viewport_origin: self.viewport_origin,
}
}
pub fn zoom_at(&self, cursor_screen: ScreenPoint, offset: f32) -> Self {
let old_zoom = self.zoom.get();
let new_zoom = Self::clamp_zoom(old_zoom + offset);
let local_x = cursor_screen.x - self.viewport_origin.x;
let local_y = cursor_screen.y - self.viewport_origin.y;
let zoom_delta = 1.0 / new_zoom - 1.0 / old_zoom;
let position_offset = WorldVector::new(local_x * zoom_delta, local_y * zoom_delta);
Self {
zoom: Scale::new(new_zoom),
position: self.position + position_offset,
viewport_origin: self.viewport_origin,
}
}
pub fn layer_transformation(&self) -> Transformation {
let zoom = self.zoom.get();
let v_x = self.viewport_origin.x * (1.0 - zoom) + zoom * self.position.x;
let v_y = self.viewport_origin.y * (1.0 - zoom) + zoom * self.position.y;
Transformation::translate(v_x, v_y) * Transformation::scale(zoom)
}
pub fn overlay_translation(&self) -> Vector {
let zoom = self.zoom.get();
let v_x = self.viewport_origin.x * (1.0 - zoom) + zoom * self.position.x;
let v_y = self.viewport_origin.y * (1.0 - zoom) + zoom * self.position.y;
Vector::new(v_x / zoom, v_y / zoom)
}
pub fn draw_with<F, Renderer>(
self,
renderer: &mut Renderer,
viewport: &Rectangle,
cursor: mouse::Cursor,
f: F,
) where
Renderer: renderer::Renderer,
F: FnOnce(&mut Renderer, &Rectangle, mouse::Cursor),
{
let transformed_cursor = self.cursor_screen_to_layout(cursor);
let world_viewport = self.viewport_screen_to_layout(viewport);
renderer.with_transformation(self.layer_transformation(), |renderer| {
f(renderer, &world_viewport, transformed_cursor)
})
}
pub fn update_with<F, T>(self, viewport: &Rectangle, cursor: mouse::Cursor, f: F) -> T
where
F: FnOnce(&Rectangle, mouse::Cursor) -> T,
{
let transformed_cursor = self.cursor_screen_to_layout(cursor);
let world_viewport = self.viewport_screen_to_layout(viewport);
f(&world_viewport, transformed_cursor)
}
pub fn cursor_screen_to_layout(&self, cursor: mouse::Cursor) -> mouse::Cursor {
let to_world = self.screen_to_world();
let map = |pos: Point| -> Point {
self.world_to_layout(to_world.transform_point(pos.into_euclid()))
.into_iced()
};
match cursor {
mouse::Cursor::Available(pos) => mouse::Cursor::Available(map(pos)),
mouse::Cursor::Levitating(pos) => mouse::Cursor::Levitating(map(pos)),
mouse::Cursor::Unavailable => mouse::Cursor::Unavailable,
}
}
pub fn world_to_layout(&self, p: WorldPoint) -> LayoutPoint {
LayoutPoint::new(p.x + self.viewport_origin.x, p.y + self.viewport_origin.y)
}
pub fn layout_to_world(&self, p: LayoutPoint) -> WorldPoint {
WorldPoint::new(p.x - self.viewport_origin.x, p.y - self.viewport_origin.y)
}
pub fn visible_world_rect(&self, bounds: Rectangle) -> WorldRect {
let inv_zoom = 1.0 / self.zoom.get();
WorldRect::new(
self.screen_to_world()
.transform_point(ScreenPoint::new(bounds.x, bounds.y)),
WorldSize::new(bounds.width * inv_zoom, bounds.height * inv_zoom),
)
}
fn viewport_screen_to_layout(&self, viewport: &Rectangle<f32>) -> Rectangle<f32> {
let viewport: ScreenRect = viewport.into_euclid();
let inv_zoom = 1.0 / self.zoom.get();
let layout_viewport: LayoutRect = LayoutRect::new(
self.world_to_layout(WorldPoint::new(
(viewport.origin.x - self.viewport_origin.x) * inv_zoom - self.position.x,
(viewport.origin.y - self.viewport_origin.y) * inv_zoom - self.position.y,
)),
LayoutSize::new(
viewport.size.width * inv_zoom,
viewport.size.height * inv_zoom,
),
);
layout_viewport.into_iced()
}
pub fn fit(world_aabb: WorldRect, viewport: Size, opts: &FocusOptions) -> (WorldPoint, f32) {
let bw = world_aabb.size.width;
let bh = world_aabb.size.height;
let aw = viewport.width - opts.padding.left - opts.padding.right;
let ah = viewport.height - opts.padding.top - opts.padding.bottom;
let zoom_raw = match (bw > 0.0, bh > 0.0) {
(true, true) => (aw / bw).min(ah / bh),
(true, false) => aw / bw,
(false, true) => ah / bh,
(false, false) => f32::INFINITY,
};
let min_zoom = opts
.min_zoom
.map_or(Self::ZOOM_MIN, |m| m.max(Self::ZOOM_MIN));
let max_zoom = opts
.max_zoom
.map_or(Self::ZOOM_MAX, |m| m.min(Self::ZOOM_MAX));
let zoom = zoom_raw.max(min_zoom).min(max_zoom);
let position = Self::position_for_center(world_aabb.center(), zoom, viewport, opts.padding);
(position, zoom)
}
pub(crate) fn position_for_center(
center: WorldPoint,
zoom: f32,
viewport: Size,
padding: Padding,
) -> WorldPoint {
WorldPoint::new(
(viewport.width + padding.left - padding.right) / 2.0 / zoom - center.x,
(viewport.height + padding.top - padding.bottom) / 2.0 / zoom - center.y,
)
}
pub(crate) fn center_for_position(
position: WorldPoint,
zoom: f32,
viewport: Size,
padding: Padding,
) -> WorldPoint {
Self::position_for_center(position, zoom, viewport, padding)
}
pub(crate) fn tween_step(
start_center: WorldPoint,
start_zoom: f32,
end_center: WorldPoint,
end_zoom: f32,
e: f32,
) -> (WorldPoint, f32) {
if e >= 1.0 {
return (end_center, end_zoom);
}
let zoom = start_zoom * (end_zoom / start_zoom).powf(e);
let inv_start = 1.0 / start_zoom;
let inv_span = inv_start - 1.0 / end_zoom;
let progress = if inv_span.abs() > 1e-6 {
(inv_start - 1.0 / zoom) / inv_span
} else {
e
};
(
WorldPoint::new(
start_center.x + (end_center.x - start_center.x) * progress,
start_center.y + (end_center.y - start_center.y) * progress,
),
zoom,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node_graph::euclid::WorldSize;
fn tween_screen_path(
probe: WorldPoint,
start: (WorldPoint, f32),
end: (WorldPoint, f32),
viewport: Size,
) -> Vec<ScreenPoint> {
const STEPS: usize = 64;
(0..=STEPS)
.map(|i| {
let e = i as f32 / STEPS as f32;
let (center, zoom) = Camera2D::tween_step(start.0, start.1, end.0, end.1, e);
let position = Camera2D::position_for_center(center, zoom, viewport, Padding::ZERO);
Camera2D::with_zoom_and_position(zoom, position)
.world_to_screen()
.transform_point(probe)
})
.collect()
}
fn path_length(path: &[ScreenPoint]) -> f32 {
path.windows(2).map(|w| (w[1] - w[0]).length()).sum()
}
#[test]
fn a_focus_tween_moves_every_point_straight_across_the_screen() {
let viewport = Size::new(1280.0, 768.0);
let wide = (WorldPoint::new(2000.0, 1250.0), 0.2752);
let close = (WorldPoint::new(3500.0, 2000.0), 3.44);
for (name, from, to) in [("zoom in", wide, close), ("zoom out", close, wide)] {
for probe in [
to.0,
from.0,
WorldPoint::new(to.0.x + 800.0, to.0.y - 600.0),
] {
let path = tween_screen_path(probe, from, to, viewport);
let straight = (path[path.len() - 1] - path[0]).length();
assert!(
straight > 1.0,
"{name}: fixture must actually move {probe:?} on screen",
);
let ratio = path_length(&path) / straight;
assert!(
ratio < 1.01,
"{name}: {probe:?} travelled {ratio:.2}x its straight-line screen \
distance - the image is swinging, not moving",
);
}
}
}
#[test]
fn a_focus_tween_advances_every_point_by_the_same_fraction() {
let viewport = Size::new(1280.0, 768.0);
let from = (WorldPoint::new(0.0, 0.0), 1.0);
let to = (WorldPoint::new(2000.0, 1250.0), 0.2752);
let a = tween_screen_path(to.0, from, to, viewport);
let b = tween_screen_path(
WorldPoint::new(to.0.x - 450.0, to.0.y + 275.0),
from,
to,
viewport,
);
let (total_a, total_b) = (path_length(&a), path_length(&b));
for i in 1..a.len() {
let done_a = path_length(&a[..=i]) / total_a;
let done_b = path_length(&b[..=i]) / total_b;
assert!(
(done_a - done_b).abs() < 1e-3,
"step {i}: one point is {:.1}% along its path, the other {:.1}%",
done_a * 100.0,
done_b * 100.0,
);
}
}
#[test]
fn a_pure_pan_tween_interpolates_linearly() {
let from = (WorldPoint::new(0.0, 0.0), 1.5);
let to = (WorldPoint::new(400.0, -200.0), 1.5);
for e in [0.0, 0.25, 0.5, 0.75, 1.0] {
let (center, zoom) = Camera2D::tween_step(from.0, from.1, to.0, to.1, e);
assert!(approx_eq(zoom, 1.5), "zoom must not move: {zoom}");
assert!(
point_approx_eq(center, WorldPoint::new(to.0.x * e, to.0.y * e),),
"at e={e} the center must be the linear midpoint, got {center:?}",
);
}
}
const EPSILON: f32 = 0.001;
fn approx_eq(a: f32, b: f32) -> bool {
(a - b).abs() < EPSILON
}
fn point_approx_eq(a: WorldPoint, b: WorldPoint) -> bool {
approx_eq(a.x, b.x) && approx_eq(a.y, b.y)
}
#[test]
fn restored_zoom_is_clamped_and_invertible() {
for (input, expected) in [
(0.0, Camera2D::ZOOM_MIN),
(-3.0, Camera2D::ZOOM_MIN),
(1e9, Camera2D::ZOOM_MAX),
(f32::NAN, 1.0),
(f32::INFINITY, 1.0),
] {
let cam = Camera2D::with_zoom_and_position(input, WorldPoint::new(5.0, -7.0));
assert_eq!(cam.zoom(), expected, "zoom {input} should clamp");
let p = cam
.world_to_screen()
.transform_point(euclid::Point2D::new(1.0, 2.0));
assert!(p.x.is_finite() && p.y.is_finite());
}
}
#[test]
fn test_identity_transform() {
let camera = Camera2D::new();
let screen = ScreenPoint::new(100.0, 200.0);
let world = camera.screen_to_world().transform_point(screen);
assert!(
approx_eq(world.x, 100.0),
"x: expected 100.0, got {}",
world.x
);
assert!(
approx_eq(world.y, 200.0),
"y: expected 200.0, got {}",
world.y
);
}
#[test]
fn test_zoom_transform() {
let mut camera = Camera2D::new();
camera.zoom = euclid::Scale::new(2.0);
let screen = ScreenPoint::new(100.0, 200.0);
let world = camera.screen_to_world().transform_point(screen);
assert!(
approx_eq(world.x, 50.0),
"x: expected 50.0, got {}",
world.x
);
assert!(
approx_eq(world.y, 100.0),
"y: expected 100.0, got {}",
world.y
);
}
#[test]
fn test_pan_transform() {
let mut camera = Camera2D::new();
camera.position = WorldPoint::new(50.0, 100.0);
let screen = ScreenPoint::new(100.0, 200.0);
let world = camera.screen_to_world().transform_point(screen);
assert!(
approx_eq(world.x, 50.0),
"x: expected 50.0, got {}",
world.x
);
assert!(
approx_eq(world.y, 100.0),
"y: expected 100.0, got {}",
world.y
);
}
#[test]
fn test_zoom_and_pan() {
let mut camera = Camera2D::new();
camera.zoom = euclid::Scale::new(2.0);
camera.position = WorldPoint::new(100.0, 200.0);
let screen = ScreenPoint::new(100.0, 200.0);
let world = camera.screen_to_world().transform_point(screen);
assert!(
approx_eq(world.x, -50.0),
"x: expected -50.0, got {}",
world.x
);
assert!(
approx_eq(world.y, -100.0),
"y: expected -100.0, got {}",
world.y
);
}
#[test]
fn test_zoom_at_cursor() {
let camera = Camera2D::new();
let cursor_screen = ScreenPoint::new(400.0, 300.0);
let cursor_world_before = camera.screen_to_world().transform_point(cursor_screen);
let camera = camera.zoom_at(cursor_screen, 1.0);
let cursor_world_after = camera.screen_to_world().transform_point(cursor_screen);
assert!(
point_approx_eq(cursor_world_before, cursor_world_after),
"Cursor world position changed: {:?} -> {:?}",
cursor_world_before,
cursor_world_after
);
}
#[test]
fn test_round_trip() {
let camera = Camera2D::new();
let screen_orig = ScreenPoint::new(150.0, 250.0);
let world = camera.screen_to_world().transform_point(screen_orig);
let screen_back = camera.world_to_screen().transform_point(world);
assert!(
approx_eq(screen_orig.x, screen_back.x),
"x roundtrip failed"
);
assert!(
approx_eq(screen_orig.y, screen_back.y),
"y roundtrip failed"
);
}
#[test]
fn test_move_by() {
let camera = Camera2D::new();
let offset = WorldVector::new(100.0, 50.0);
let camera = camera.move_by(offset);
assert!(approx_eq(camera.position.x, 100.0), "position.x");
assert!(approx_eq(camera.position.y, 50.0), "position.y");
let screen = ScreenPoint::new(0.0, 0.0);
let world = camera.screen_to_world().transform_point(screen);
assert!(approx_eq(world.x, -100.0), "world.x at origin");
assert!(approx_eq(world.y, -50.0), "world.y at origin");
}
#[test]
fn test_world_to_screen_with_viewport_origin() {
let camera = Camera2D::with_zoom_and_position(2.0, WorldPoint::new(10.0, 20.0))
.with_viewport_origin(ScreenVector::new(40.0, 100.0));
let world = WorldPoint::new(30.0, 5.0);
let screen = camera.world_to_screen().transform_point(world);
assert!(
approx_eq(screen.x, 120.0),
"x: expected 120.0, got {}",
screen.x
);
assert!(
approx_eq(screen.y, 150.0),
"y: expected 150.0, got {}",
screen.y
);
}
#[test]
fn test_round_trip_with_viewport_origin() {
let camera = Camera2D::with_zoom_and_position(1.7, WorldPoint::new(-12.0, 33.0))
.with_viewport_origin(ScreenVector::new(40.0, 100.0));
let screen_orig = ScreenPoint::new(150.0, 250.0);
let world = camera.screen_to_world().transform_point(screen_orig);
let screen_back = camera.world_to_screen().transform_point(world);
assert!(
approx_eq(screen_orig.x, screen_back.x),
"x roundtrip with origin"
);
assert!(
approx_eq(screen_orig.y, screen_back.y),
"y roundtrip with origin"
);
}
#[test]
fn test_zoom_at_cursor_with_viewport_origin() {
let camera = Camera2D::with_zoom_and_position(1.0, WorldPoint::new(5.0, -7.0))
.with_viewport_origin(ScreenVector::new(40.0, 100.0));
let cursor_screen = ScreenPoint::new(400.0, 300.0);
let world_before = camera.screen_to_world().transform_point(cursor_screen);
let camera = camera.zoom_at(cursor_screen, 1.3);
let world_after = camera.screen_to_world().transform_point(cursor_screen);
assert!(
point_approx_eq(world_before, world_after),
"cursor world moved under zoom with origin: {world_before:?} -> {world_after:?}",
);
assert!(
approx_eq(camera.viewport_origin().x, 40.0),
"origin x preserved"
);
assert!(
approx_eq(camera.viewport_origin().y, 100.0),
"origin y preserved"
);
}
#[test]
fn test_zoom_increases() {
let camera = Camera2D::new();
assert!(approx_eq(camera.zoom(), 1.0), "initial zoom");
let camera = camera.zoom_at(ScreenPoint::new(0.0, 0.0), 0.5);
assert!(approx_eq(camera.zoom(), 1.5), "zoomed in");
let camera = camera.zoom_at(ScreenPoint::new(0.0, 0.0), -0.5);
assert!(approx_eq(camera.zoom(), 1.0), "zoomed back");
}
#[test]
fn test_inverse_consistency_at_various_zooms() {
let test_cases = vec![0.5, 1.0, 1.5, 2.0, 3.0];
for zoom in test_cases {
let mut camera = Camera2D::new();
camera.zoom = euclid::Scale::new(zoom);
let screen_orig = ScreenPoint::new(250.0, 180.0);
let world = camera.screen_to_world().transform_point(screen_orig);
let screen_back = camera.world_to_screen().transform_point(world);
assert!(
approx_eq(screen_orig.x, screen_back.x) && approx_eq(screen_orig.y, screen_back.y),
"Round-trip failed at zoom {}: {:?} -> {:?} -> {:?}",
zoom,
screen_orig,
world,
screen_back
);
}
}
#[test]
fn test_inverse_consistency_at_various_positions() {
let test_positions = vec![
WorldPoint::new(0.0, 0.0),
WorldPoint::new(100.0, 50.0),
WorldPoint::new(-100.0, -50.0),
WorldPoint::new(500.0, 300.0),
];
for pos in test_positions {
let mut camera = Camera2D::new();
camera.position = pos;
let screen_orig = ScreenPoint::new(320.0, 240.0);
let world = camera.screen_to_world().transform_point(screen_orig);
let screen_back = camera.world_to_screen().transform_point(world);
assert!(
approx_eq(screen_orig.x, screen_back.x) && approx_eq(screen_orig.y, screen_back.y),
"Round-trip failed at position {:?}: {:?} -> {:?} -> {:?}",
pos,
screen_orig,
world,
screen_back
);
}
}
#[test]
fn test_inverse_consistency_combined() {
let test_cases = vec![
(1.2, WorldPoint::new(-85.0, -45.0)),
(1.5, WorldPoint::new(-150.0, -75.0)),
(2.0, WorldPoint::new(-300.0, -200.0)),
(0.8, WorldPoint::new(50.0, 30.0)),
];
for (zoom, pos) in test_cases {
let mut camera = Camera2D::new();
camera.zoom = euclid::Scale::new(zoom);
camera.position = pos;
let screen_points = vec![
ScreenPoint::new(0.0, 0.0),
ScreenPoint::new(400.0, 300.0),
ScreenPoint::new(800.0, 600.0),
];
for screen_orig in screen_points {
let world = camera.screen_to_world().transform_point(screen_orig);
let screen_back = camera.world_to_screen().transform_point(world);
assert!(
approx_eq(screen_orig.x, screen_back.x)
&& approx_eq(screen_orig.y, screen_back.y),
"Round-trip failed at zoom {} pos {:?}: {:?} -> {:?} -> {:?}",
zoom,
pos,
screen_orig,
world,
screen_back
);
}
}
}
#[test]
fn test_rendering_formula_consistency() {
let mut camera = Camera2D::new();
camera.zoom = euclid::Scale::new(1.4);
camera.position = WorldPoint::new(-170.4, -91.8);
let node_world = WorldPoint::new(400.0, 224.0);
let expected_screen_x = (node_world.x + camera.position.x) * camera.zoom.get();
let expected_screen_y = (node_world.y + camera.position.y) * camera.zoom.get();
let expected_screen = ScreenPoint::new(expected_screen_x, expected_screen_y);
let calculated_world = camera.screen_to_world().transform_point(expected_screen);
assert!(
point_approx_eq(node_world, calculated_world),
"Rendering formula mismatch: node at {:?} renders at {:?}, but mouse at {:?} calculates {:?}",
node_world,
expected_screen,
expected_screen,
calculated_world
);
}
#[test]
fn test_zoom_at_maintains_cursor_position() {
let test_cursors = vec![
ScreenPoint::new(400.0, 300.0), ScreenPoint::new(0.0, 0.0), ScreenPoint::new(800.0, 600.0), ScreenPoint::new(200.0, 450.0), ];
for cursor_screen in test_cursors {
let camera = Camera2D::new();
let world_before = camera.screen_to_world().transform_point(cursor_screen);
let camera = camera.zoom_at(cursor_screen, 0.5);
let world_after = camera.screen_to_world().transform_point(cursor_screen);
assert!(
point_approx_eq(world_before, world_after),
"Cursor world position changed during zoom at {:?}: {:?} -> {:?}",
cursor_screen,
world_before,
world_after
);
}
}
#[test]
fn test_zoom_at_multiple_steps() {
let camera = Camera2D::new();
let cursor_screen = ScreenPoint::new(426.0, 222.0);
let world_initial = camera.screen_to_world().transform_point(cursor_screen);
let mut camera = camera;
for _ in 0..4 {
camera = camera.zoom_at(cursor_screen, 0.1);
}
let world_final = camera.screen_to_world().transform_point(cursor_screen);
assert!(
point_approx_eq(world_initial, world_final),
"Cursor position drifted after multiple zooms: {:?} -> {:?} (delta: {}, {})",
world_initial,
world_final,
world_final.x - world_initial.x,
world_final.y - world_initial.y
);
}
#[test]
fn test_real_world_scenario_from_bug_report() {
let mut camera = Camera2D::new();
let cursor_screen = ScreenPoint::new(426.0, 222.0);
for _ in 0..4 {
camera = camera.zoom_at(cursor_screen, 0.1);
}
assert!(
approx_eq(camera.zoom(), 1.4),
"zoom should be 1.4, got {}",
camera.zoom()
);
let world_after_zoom = camera.screen_to_world().transform_point(cursor_screen);
let world_before_zoom = Camera2D::new()
.screen_to_world()
.transform_point(cursor_screen);
assert!(
point_approx_eq(world_before_zoom, world_after_zoom),
"Cursor position drifted: {:?} -> {:?}",
world_before_zoom,
world_after_zoom
);
}
#[test]
fn test_layer_transformation_matches_world_to_screen() {
let camera = Camera2D::with_zoom_and_position(2.0, WorldPoint::new(10.0, 20.0))
.with_viewport_origin(ScreenVector::new(40.0, 100.0));
let world = WorldPoint::new(30.0, 5.0);
let layout_point = Point::new(40.0 + world.x, 100.0 + world.y);
let via_layer = layout_point * camera.layer_transformation();
let via_world_to_screen = camera.world_to_screen().transform_point(world);
assert!(approx_eq(via_layer.x, 120.0), "x: got {}", via_layer.x);
assert!(approx_eq(via_layer.y, 150.0), "y: got {}", via_layer.y);
assert!(
approx_eq(via_layer.x, via_world_to_screen.x),
"layer vs w2s x"
);
assert!(
approx_eq(via_layer.y, via_world_to_screen.y),
"layer vs w2s y"
);
}
#[test]
fn test_cursor_screen_to_layout_adds_origin() {
let camera = Camera2D::with_zoom_and_position(2.0, WorldPoint::new(10.0, 20.0))
.with_viewport_origin(ScreenVector::new(40.0, 100.0));
let cursor = mouse::Cursor::Available(Point::new(120.0, 150.0));
let mapped = match camera.cursor_screen_to_layout(cursor) {
mouse::Cursor::Available(p) => p,
other => panic!("expected available cursor, got {other:?}"),
};
assert!(approx_eq(mapped.x, 70.0), "x: got {}", mapped.x);
assert!(approx_eq(mapped.y, 105.0), "y: got {}", mapped.y);
}
#[test]
fn test_viewport_screen_to_layout_rect() {
let camera = Camera2D::with_zoom_and_position(2.0, WorldPoint::new(10.0, 20.0))
.with_viewport_origin(ScreenVector::new(40.0, 100.0));
let viewport = Rectangle {
x: 120.0,
y: 150.0,
width: 800.0,
height: 600.0,
};
let layout = camera.viewport_screen_to_layout(&viewport);
assert!(approx_eq(layout.x, 70.0), "x: got {}", layout.x);
assert!(approx_eq(layout.y, 105.0), "y: got {}", layout.y);
assert!(approx_eq(layout.width, 400.0), "w: got {}", layout.width);
assert!(approx_eq(layout.height, 300.0), "h: got {}", layout.height);
}
#[test]
fn fit_centers_aabb_with_no_padding() {
let world_aabb = WorldRect::new(WorldPoint::new(0.0, 25.0), WorldSize::new(100.0, 50.0));
let opts = FocusOptions {
padding: Padding::ZERO,
min_zoom: None,
max_zoom: None,
animation: None,
};
let (position, zoom) = Camera2D::fit(world_aabb, Size::new(400.0, 400.0), &opts);
assert!(approx_eq(zoom, 4.0), "zoom: got {zoom}");
assert!(point_approx_eq(position, WorldPoint::new(0.0, 0.0)));
}
#[test]
fn fit_honors_per_side_padding() {
let world_aabb =
WorldRect::new(WorldPoint::new(-50.0, -50.0), WorldSize::new(100.0, 100.0));
let opts = FocusOptions {
padding: Padding {
left: 10.0,
right: 30.0,
top: 0.0,
bottom: 100.0,
},
min_zoom: None,
max_zoom: None,
animation: None,
};
let (position, zoom) = Camera2D::fit(world_aabb, Size::new(400.0, 300.0), &opts);
assert!(approx_eq(zoom, 2.0), "zoom: got {zoom}");
assert!(point_approx_eq(position, WorldPoint::new(95.0, 50.0)));
}
#[test]
fn fit_clamps_zoom_to_camera_minimum() {
let world_aabb = WorldRect::new(
WorldPoint::new(-50_000.0, -50_000.0),
WorldSize::new(100_000.0, 100_000.0),
);
let opts = FocusOptions {
padding: Padding::ZERO,
min_zoom: None,
max_zoom: None,
animation: None,
};
let (position, zoom) = Camera2D::fit(world_aabb, Size::new(400.0, 400.0), &opts);
assert_eq!(zoom, Camera2D::ZOOM_MIN);
assert!(point_approx_eq(position, WorldPoint::new(2000.0, 2000.0)));
}
#[test]
fn fit_clamps_zoom_to_camera_maximum_without_a_max_zoom_option() {
let world_aabb = WorldRect::new(
WorldPoint::new(-0.0005, -0.0005),
WorldSize::new(0.001, 0.001),
);
let opts = FocusOptions {
padding: Padding::ZERO,
min_zoom: None,
max_zoom: None,
animation: None,
};
let (position, zoom) = Camera2D::fit(world_aabb, Size::new(400.0, 400.0), &opts);
assert_eq!(zoom, Camera2D::ZOOM_MAX);
assert!(point_approx_eq(position, WorldPoint::new(20.0, 20.0)));
}
#[test]
fn fit_default_max_zoom_caps_a_single_small_node() {
let world_aabb = WorldRect::new(WorldPoint::new(10.0, 5.0), WorldSize::new(40.0, 20.0));
let opts = FocusOptions::default();
let (position, zoom) = Camera2D::fit(world_aabb, Size::new(400.0, 400.0), &opts);
assert_eq!(zoom, 1.0, "raw fit (8x) must be capped to max_zoom");
assert!(point_approx_eq(position, WorldPoint::new(170.0, 185.0)));
}
#[test]
fn fit_degenerate_point_aabb_centers_at_max_zoom() {
let world_aabb = WorldRect::new(WorldPoint::new(5.0, 5.0), WorldSize::new(0.0, 0.0));
let opts = FocusOptions {
padding: Padding::ZERO,
min_zoom: None,
max_zoom: Some(2.0),
animation: None,
};
let (position, zoom) = Camera2D::fit(world_aabb, Size::new(400.0, 400.0), &opts);
assert_eq!(zoom, 2.0);
assert!(point_approx_eq(position, WorldPoint::new(95.0, 95.0)));
}
#[test]
fn fit_position_is_independent_of_viewport_origin() {
let world_aabb = WorldRect::new(WorldPoint::new(-10.0, 15.0), WorldSize::new(60.0, 40.0));
let viewport = Size::new(500.0, 350.0);
let opts = FocusOptions {
padding: Padding::new(20.0),
min_zoom: None,
max_zoom: None,
animation: None,
};
let (position, zoom) = Camera2D::fit(world_aabb, viewport, &opts);
let expected_offset = ScreenPoint::new(viewport.width / 2.0, viewport.height / 2.0);
for origin in [
ScreenVector::new(0.0, 0.0),
ScreenVector::new(50.0, 100.0),
ScreenVector::new(-20.0, 30.0),
] {
let camera =
Camera2D::with_zoom_and_position(zoom, position).with_viewport_origin(origin);
let screen = camera
.world_to_screen()
.transform_point(world_aabb.center());
assert!(
approx_eq(screen.x, origin.x + expected_offset.x)
&& approx_eq(screen.y, origin.y + expected_offset.y),
"origin {origin:?}: got {screen:?}, want offset {expected_offset:?} from origin"
);
}
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
const ZOOM: std::ops::RangeInclusive<f32> = 0.1..=10.0;
const COORD: std::ops::RangeInclusive<f32> = -5000.0..=5000.0;
const ORIGIN: std::ops::RangeInclusive<f32> = -1000.0..=1000.0;
const SCREEN: std::ops::RangeInclusive<f32> = 0.0..=2000.0;
fn close(a: f32, b: f32, scale: f32) -> bool {
let eps = 1e-2 + 1e-4 * scale.abs();
(a - b).abs() <= eps
}
fn camera(zoom: f32, px: f32, py: f32, ox: f32, oy: f32) -> Camera2D {
Camera2D::with_zoom_and_position(zoom, WorldPoint::new(px, py))
.with_viewport_origin(ScreenVector::new(ox, oy))
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn screen_world_screen_round_trip(
zoom in ZOOM, px in COORD, py in COORD, ox in ORIGIN, oy in ORIGIN,
sx in SCREEN, sy in SCREEN,
) {
let cam = camera(zoom, px, py, ox, oy);
let screen = ScreenPoint::new(sx, sy);
let world = cam.screen_to_world().transform_point(screen);
let back = cam.world_to_screen().transform_point(world);
let scale = world.x.abs() + world.y.abs() + zoom * (px.abs() + py.abs());
prop_assert!(close(screen.x, back.x, scale), "x: {} -> {} (scale {})", screen.x, back.x, scale);
prop_assert!(close(screen.y, back.y, scale), "y: {} -> {} (scale {})", screen.y, back.y, scale);
}
#[test]
fn world_screen_world_round_trip(
zoom in ZOOM, px in COORD, py in COORD, ox in ORIGIN, oy in ORIGIN,
wx in COORD, wy in COORD,
) {
let cam = camera(zoom, px, py, ox, oy);
let world = WorldPoint::new(wx, wy);
let screen = cam.world_to_screen().transform_point(world);
let back = cam.screen_to_world().transform_point(screen);
let scale = wx.abs() + wy.abs() + px.abs() + py.abs();
prop_assert!(close(world.x, back.x, scale), "x: {} -> {} (scale {})", world.x, back.x, scale);
prop_assert!(close(world.y, back.y, scale), "y: {} -> {} (scale {})", world.y, back.y, scale);
}
#[test]
fn zoom_at_keeps_cursor_world_fixed(
zoom in ZOOM, px in COORD, py in COORD, ox in ORIGIN, oy in ORIGIN,
sx in SCREEN, sy in SCREEN, delta in -5.0f32..=5.0f32,
) {
let cam = camera(zoom, px, py, ox, oy);
let cursor = ScreenPoint::new(sx, sy);
let before = cam.screen_to_world().transform_point(cursor);
let zoomed = cam.zoom_at(cursor, delta);
let after = zoomed.screen_to_world().transform_point(cursor);
let scale = before.x.abs() + before.y.abs() + after.x.abs() + after.y.abs();
prop_assert!(close(before.x, after.x, scale), "x drift: {} -> {} (scale {})", before.x, after.x, scale);
prop_assert!(close(before.y, after.y, scale), "y drift: {} -> {} (scale {})", before.y, after.y, scale);
prop_assert!(zoomed.zoom() >= 0.1 && zoomed.zoom() <= 10.0);
}
#[test]
fn move_by_is_additive(
zoom in ZOOM, px in COORD, py in COORD,
ax in COORD, ay in COORD, bx in COORD, by in COORD,
) {
let cam = Camera2D::with_zoom_and_position(zoom, WorldPoint::new(px, py));
let stepwise = cam.move_by(WorldVector::new(ax, ay)).move_by(WorldVector::new(bx, by));
let combined = cam.move_by(WorldVector::new(ax + bx, ay + by));
let scale = px.abs() + py.abs() + ax.abs() + ay.abs() + bx.abs() + by.abs();
prop_assert!(close(stepwise.position().x, combined.position().x, scale));
prop_assert!(close(stepwise.position().y, combined.position().y, scale));
}
#[test]
fn viewport_origin_is_pure_screen_translation(
zoom in ZOOM, px in COORD, py in COORD, ox in ORIGIN, oy in ORIGIN,
wx in COORD, wy in COORD,
) {
let base = Camera2D::with_zoom_and_position(zoom, WorldPoint::new(px, py));
let shifted = base.with_viewport_origin(ScreenVector::new(ox, oy));
let world = WorldPoint::new(wx, wy);
let s0 = base.world_to_screen().transform_point(world);
let s1 = shifted.world_to_screen().transform_point(world);
let scale = s0.x.abs() + s0.y.abs();
prop_assert!(close(s1.x, s0.x + ox, scale), "x: {} vs {}+{}", s1.x, s0.x, ox);
prop_assert!(close(s1.y, s0.y + oy, scale), "y: {} vs {}+{}", s1.y, s0.y, oy);
}
}
}