use super::euclid::{
IntoEuclid, IntoIced, Screen, ScreenPoint, ScreenRect, ScreenToWorld, ScreenVector, World,
WorldPoint, WorldRect, WorldSize, WorldVector,
};
use euclid::{Scale, Transform2D};
use iced::Rectangle;
use iced_wgpu::core::{mouse, renderer};
#[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())
}
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) -> iced::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;
iced::Transformation::translate(v_x, v_y) * iced::Transformation::scale(zoom)
}
pub fn draw_with<F, Renderer>(
self,
renderer: &mut Renderer,
viewport: &iced::Rectangle,
cursor: mouse::Cursor,
f: F,
) where
Renderer: renderer::Renderer,
F: FnOnce(&mut Renderer, &iced::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>(self, viewport: &iced::Rectangle, cursor: mouse::Cursor, f: F)
where
F: FnOnce(&iced::Rectangle, mouse::Cursor),
{
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: iced::Point| -> iced::Point {
let w = to_world.transform_point(pos.into_euclid());
iced::Point::new(w.x + self.viewport_origin.x, w.y + self.viewport_origin.y)
};
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,
}
}
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 world_viewport: WorldRect = WorldRect::new(
WorldPoint::new(
(viewport.origin.x - self.viewport_origin.x) * inv_zoom - self.position.x
+ self.viewport_origin.x,
(viewport.origin.y - self.viewport_origin.y) * inv_zoom - self.position.y
+ self.viewport_origin.y,
),
WorldSize::new(
viewport.size.width * inv_zoom,
viewport.size.height * inv_zoom,
),
);
world_viewport.into_iced()
}
}
#[cfg(test)]
mod tests {
use super::*;
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 = iced::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(iced::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);
}
}
#[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);
}
}
}