#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct View2d {
pub center_x: f64,
pub center_y: f64,
pub view_size: f64,
}
impl Default for View2d {
fn default() -> Self {
Self {
center_x: 0.0,
center_y: 0.0,
view_size: 1.0,
}
}
}
impl View2d {
pub fn set(&mut self, center_x: f64, center_y: f64, view_size: f64) {
self.center_x = center_x;
self.center_y = center_y;
self.view_size = if view_size < f64::EPSILON {
f64::EPSILON
} else {
view_size
};
}
pub fn pan(&mut self, dx: f64, dy: f64, canvas_height: f64) {
let speed = self.view_size / canvas_height;
self.center_x += dx * speed;
self.center_y += dy * speed;
}
pub fn zoom_at(
&mut self,
delta: f64,
canvas_x: f64,
canvas_y: f64,
canvas_width: f64,
canvas_height: f64,
) {
let factor = 1.0 + delta * 0.001;
let (wx, wy) = self.canvas_to_world(canvas_x, canvas_y, canvas_width, canvas_height);
let new_view_size = self.view_size / factor;
self.view_size = if new_view_size < f64::EPSILON {
f64::EPSILON
} else {
new_view_size
};
let (wx2, wy2) = self.canvas_to_world(canvas_x, canvas_y, canvas_width, canvas_height);
self.center_x += wx - wx2;
self.center_y += wy - wy2;
}
pub fn canvas_to_world(
&self,
cx: f64,
cy: f64,
canvas_width: f64,
canvas_height: f64,
) -> (f64, f64) {
let aspect = canvas_width / canvas_height;
let world_x = self.center_x + (cx / canvas_width - 0.5) * self.view_size * aspect;
let world_y = self.center_y + (cy / canvas_height - 0.5) * self.view_size;
(world_x, world_y)
}
pub fn world_to_canvas(
&self,
wx: f64,
wy: f64,
canvas_width: f64,
canvas_height: f64,
) -> (f64, f64) {
let aspect = canvas_width / canvas_height;
let canvas_x = ((wx - self.center_x) / (self.view_size * aspect) + 0.5) * canvas_width;
let canvas_y = ((wy - self.center_y) / self.view_size + 0.5) * canvas_height;
(canvas_x, canvas_y)
}
pub fn fit(
&mut self,
world_width: f64,
world_height: f64,
canvas_width: f64,
canvas_height: f64,
) {
self.center_x = world_width / 2.0;
self.center_y = world_height / 2.0;
let fit_by_height = world_height;
let fit_by_width = world_width * canvas_height / canvas_width;
let base = if fit_by_height > fit_by_width {
fit_by_height
} else {
fit_by_width
};
self.view_size = base * 1.05;
}
pub fn zoom_factor(&self, reference_view_size: f64) -> f64 {
reference_view_size / self.view_size
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-10;
#[test]
fn coordinate_roundtrip() {
let cases = [
(0.0, 0.0, 1.0, 800.0, 600.0, 400.0, 300.0),
(100.0, 200.0, 50.0, 1920.0, 1080.0, 960.0, 540.0),
(-10.0, 5.0, 0.5, 640.0, 480.0, 0.0, 0.0),
(0.0, 0.0, 10.0, 800.0, 600.0, 800.0, 600.0),
(50.0, 50.0, 100.0, 1024.0, 768.0, 123.0, 456.0),
];
for (center_x, center_y, view_size, cw, ch, cx, cy) in cases {
let v = View2d {
center_x,
center_y,
view_size,
};
let (wx, wy) = v.canvas_to_world(cx, cy, cw, ch);
let (cx2, cy2) = v.world_to_canvas(wx, wy, cw, ch);
assert!(
(cx - cx2).abs() < EPS && (cy - cy2).abs() < EPS,
"roundtrip failed: ({cx}, {cy}) -> ({wx}, {wy}) -> ({cx2}, {cy2})"
);
}
}
#[test]
fn coordinate_roundtrip_reverse() {
let v = View2d {
center_x: 30.0,
center_y: -20.0,
view_size: 8.0,
};
let (cw, ch) = (1280.0, 720.0);
let (wx, wy) = (35.0, -18.0);
let (cx, cy) = v.world_to_canvas(wx, wy, cw, ch);
let (wx2, wy2) = v.canvas_to_world(cx, cy, cw, ch);
assert!((wx - wx2).abs() < EPS && (wy - wy2).abs() < EPS);
}
#[test]
fn zoom_at_cursor_invariance() {
let deltas = [100.0, -100.0, 500.0, -500.0, 1.0];
let (cw, ch) = (800.0, 600.0);
for delta in deltas {
let mut v = View2d {
center_x: 50.0,
center_y: 30.0,
view_size: 10.0,
};
let (cx, cy) = (200.0, 150.0);
let (wx_before, wy_before) = v.canvas_to_world(cx, cy, cw, ch);
v.zoom_at(delta, cx, cy, cw, ch);
let (wx_after, wy_after) = v.canvas_to_world(cx, cy, cw, ch);
assert!(
(wx_before - wx_after).abs() < 1e-6 && (wy_before - wy_after).abs() < 1e-6,
"zoom_at cursor invariance violated: delta={delta}, before=({wx_before},{wy_before}), after=({wx_after},{wy_after})"
);
}
}
#[test]
fn pan_proportional_to_view_size() {
let ch = 600.0;
let dx = 10.0;
let dy = 20.0;
let mut v1 = View2d {
center_x: 0.0,
center_y: 0.0,
view_size: 5.0,
};
v1.pan(dx, dy, ch);
let move1_x = v1.center_x;
let move1_y = v1.center_y;
let mut v2 = View2d {
center_x: 0.0,
center_y: 0.0,
view_size: 10.0,
};
v2.pan(dx, dy, ch);
let move2_x = v2.center_x;
let move2_y = v2.center_y;
assert!(
(move2_x - move1_x * 2.0).abs() < EPS,
"pan X proportionality violated: {move1_x} * 2 != {move2_x}"
);
assert!(
(move2_y - move1_y * 2.0).abs() < EPS,
"pan Y proportionality violated: {move1_y} * 2 != {move2_y}"
);
}
#[test]
fn fit_contains_world_region() {
let cases = [
(100.0, 80.0, 800.0, 600.0),
(1920.0, 1080.0, 640.0, 480.0),
(50.0, 200.0, 1024.0, 768.0), (300.0, 10.0, 800.0, 600.0), ];
for (ww, wh, cw, ch) in cases {
let mut v = View2d::default();
v.fit(ww, wh, cw, ch);
let corners = [(0.0, 0.0), (ww, 0.0), (0.0, wh), (ww, wh)];
for (wx, wy) in corners {
let (cx, cy) = v.world_to_canvas(wx, wy, cw, ch);
assert!(
cx >= -EPS && cx <= cw + EPS && cy >= -EPS && cy <= ch + EPS,
"fit out of bounds: world=({wx},{wy}) -> canvas=({cx},{cy}), canvas_size=({cw},{ch})"
);
}
}
}
#[test]
fn zoom_factor_calculation() {
let v = View2d {
center_x: 0.0,
center_y: 0.0,
view_size: 5.0,
};
assert!((v.zoom_factor(10.0) - 2.0).abs() < EPS);
assert!((v.zoom_factor(5.0) - 1.0).abs() < EPS);
assert!((v.zoom_factor(2.5) - 0.5).abs() < EPS);
}
#[test]
fn set_clamps_view_size() {
let mut v = View2d::default();
v.set(1.0, 2.0, 0.0);
assert_eq!(v.view_size, f64::EPSILON);
v.set(1.0, 2.0, -100.0);
assert_eq!(v.view_size, f64::EPSILON);
v.set(1.0, 2.0, f64::EPSILON * 0.5);
assert_eq!(v.view_size, f64::EPSILON);
v.set(1.0, 2.0, 42.0);
assert_eq!(v.view_size, 42.0);
}
#[test]
fn default_values() {
let v = View2d::default();
assert_eq!(v.center_x, 0.0);
assert_eq!(v.center_y, 0.0);
assert_eq!(v.view_size, 1.0);
}
}