Skip to main content

neco_view2d/
lib.rs

1/// 2D view transform (pan & zoom).
2///
3/// `view_size` is the world-space height mapped to the canvas vertical extent.
4/// Smaller values mean more zoom.
5#[derive(Debug, Clone)]
6#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
7pub struct View2d {
8    pub center_x: f64,
9    pub center_y: f64,
10    /// World-space height visible on canvas. Always positive.
11    pub view_size: f64,
12}
13
14impl Default for View2d {
15    fn default() -> Self {
16        Self {
17            center_x: 0.0,
18            center_y: 0.0,
19            view_size: 1.0,
20        }
21    }
22}
23
24impl View2d {
25    /// Set view parameters. `view_size` is clamped to positive.
26    pub fn set(&mut self, center_x: f64, center_y: f64, view_size: f64) {
27        self.center_x = center_x;
28        self.center_y = center_y;
29        self.view_size = if view_size < f64::EPSILON {
30            f64::EPSILON
31        } else {
32            view_size
33        };
34    }
35
36    /// Pan by pixel delta. Speed scales with `view_size / canvas_height`.
37    pub fn pan(&mut self, dx: f64, dy: f64, canvas_height: f64) {
38        let speed = self.view_size / canvas_height;
39        self.center_x += dx * speed;
40        self.center_y += dy * speed;
41    }
42
43    /// Zoom centered on a canvas position. `delta > 0` zooms in.
44    pub fn zoom_at(
45        &mut self,
46        delta: f64,
47        canvas_x: f64,
48        canvas_y: f64,
49        canvas_width: f64,
50        canvas_height: f64,
51    ) {
52        let factor = 1.0 + delta * 0.001;
53
54        // Record cursor world position before zoom
55        let (wx, wy) = self.canvas_to_world(canvas_x, canvas_y, canvas_width, canvas_height);
56
57        let new_view_size = self.view_size / factor;
58        self.view_size = if new_view_size < f64::EPSILON {
59            f64::EPSILON
60        } else {
61            new_view_size
62        };
63
64        // Adjust center so cursor world position stays invariant
65        let (wx2, wy2) = self.canvas_to_world(canvas_x, canvas_y, canvas_width, canvas_height);
66        self.center_x += wx - wx2;
67        self.center_y += wy - wy2;
68    }
69
70    /// Convert canvas coordinates to world coordinates.
71    pub fn canvas_to_world(
72        &self,
73        cx: f64,
74        cy: f64,
75        canvas_width: f64,
76        canvas_height: f64,
77    ) -> (f64, f64) {
78        let aspect = canvas_width / canvas_height;
79        let world_x = self.center_x + (cx / canvas_width - 0.5) * self.view_size * aspect;
80        let world_y = self.center_y + (cy / canvas_height - 0.5) * self.view_size;
81        (world_x, world_y)
82    }
83
84    /// Convert world coordinates to canvas coordinates.
85    pub fn world_to_canvas(
86        &self,
87        wx: f64,
88        wy: f64,
89        canvas_width: f64,
90        canvas_height: f64,
91    ) -> (f64, f64) {
92        let aspect = canvas_width / canvas_height;
93        let canvas_x = ((wx - self.center_x) / (self.view_size * aspect) + 0.5) * canvas_width;
94        let canvas_y = ((wy - self.center_y) / self.view_size + 0.5) * canvas_height;
95        (canvas_x, canvas_y)
96    }
97
98    /// Fit the entire world region into the canvas.
99    pub fn fit(
100        &mut self,
101        world_width: f64,
102        world_height: f64,
103        canvas_width: f64,
104        canvas_height: f64,
105    ) {
106        self.center_x = world_width / 2.0;
107        self.center_y = world_height / 2.0;
108
109        let fit_by_height = world_height;
110        let fit_by_width = world_width * canvas_height / canvas_width;
111        let base = if fit_by_height > fit_by_width {
112            fit_by_height
113        } else {
114            fit_by_width
115        };
116
117        // Add slight margin
118        self.view_size = base * 1.05;
119    }
120
121    /// Current zoom factor relative to a reference `view_size`.
122    pub fn zoom_factor(&self, reference_view_size: f64) -> f64 {
123        reference_view_size / self.view_size
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    const EPS: f64 = 1e-10;
132
133    /// canvas_to_world and world_to_canvas roundtrip
134    #[test]
135    fn coordinate_roundtrip() {
136        let cases = [
137            // (center_x, center_y, view_size, canvas_w, canvas_h, cx, cy)
138            (0.0, 0.0, 1.0, 800.0, 600.0, 400.0, 300.0),
139            (100.0, 200.0, 50.0, 1920.0, 1080.0, 960.0, 540.0),
140            (-10.0, 5.0, 0.5, 640.0, 480.0, 0.0, 0.0),
141            (0.0, 0.0, 10.0, 800.0, 600.0, 800.0, 600.0),
142            (50.0, 50.0, 100.0, 1024.0, 768.0, 123.0, 456.0),
143        ];
144
145        for (center_x, center_y, view_size, cw, ch, cx, cy) in cases {
146            let v = View2d {
147                center_x,
148                center_y,
149                view_size,
150            };
151            let (wx, wy) = v.canvas_to_world(cx, cy, cw, ch);
152            let (cx2, cy2) = v.world_to_canvas(wx, wy, cw, ch);
153            assert!(
154                (cx - cx2).abs() < EPS && (cy - cy2).abs() < EPS,
155                "roundtrip failed: ({cx}, {cy}) -> ({wx}, {wy}) -> ({cx2}, {cy2})"
156            );
157        }
158    }
159
160    /// Reverse roundtrip: world_to_canvas then canvas_to_world
161    #[test]
162    fn coordinate_roundtrip_reverse() {
163        let v = View2d {
164            center_x: 30.0,
165            center_y: -20.0,
166            view_size: 8.0,
167        };
168        let (cw, ch) = (1280.0, 720.0);
169        let (wx, wy) = (35.0, -18.0);
170        let (cx, cy) = v.world_to_canvas(wx, wy, cw, ch);
171        let (wx2, wy2) = v.canvas_to_world(cx, cy, cw, ch);
172        assert!((wx - wx2).abs() < EPS && (wy - wy2).abs() < EPS);
173    }
174
175    /// zoom_at preserves cursor world position
176    #[test]
177    fn zoom_at_cursor_invariance() {
178        let deltas = [100.0, -100.0, 500.0, -500.0, 1.0];
179        let (cw, ch) = (800.0, 600.0);
180
181        for delta in deltas {
182            let mut v = View2d {
183                center_x: 50.0,
184                center_y: 30.0,
185                view_size: 10.0,
186            };
187            let (cx, cy) = (200.0, 150.0);
188            let (wx_before, wy_before) = v.canvas_to_world(cx, cy, cw, ch);
189            v.zoom_at(delta, cx, cy, cw, ch);
190            let (wx_after, wy_after) = v.canvas_to_world(cx, cy, cw, ch);
191            assert!(
192                (wx_before - wx_after).abs() < 1e-6 && (wy_before - wy_after).abs() < 1e-6,
193                "zoom_at cursor invariance violated: delta={delta}, before=({wx_before},{wy_before}), after=({wx_after},{wy_after})"
194            );
195        }
196    }
197
198    /// Pan distance scales proportionally with view_size
199    #[test]
200    fn pan_proportional_to_view_size() {
201        let ch = 600.0;
202        let dx = 10.0;
203        let dy = 20.0;
204
205        let mut v1 = View2d {
206            center_x: 0.0,
207            center_y: 0.0,
208            view_size: 5.0,
209        };
210        v1.pan(dx, dy, ch);
211        let move1_x = v1.center_x;
212        let move1_y = v1.center_y;
213
214        let mut v2 = View2d {
215            center_x: 0.0,
216            center_y: 0.0,
217            view_size: 10.0,
218        };
219        v2.pan(dx, dy, ch);
220        let move2_x = v2.center_x;
221        let move2_y = v2.center_y;
222
223        // 2x view_size should yield 2x displacement
224        assert!(
225            (move2_x - move1_x * 2.0).abs() < EPS,
226            "pan X proportionality violated: {move1_x} * 2 != {move2_x}"
227        );
228        assert!(
229            (move2_y - move1_y * 2.0).abs() < EPS,
230            "pan Y proportionality violated: {move1_y} * 2 != {move2_y}"
231        );
232    }
233
234    /// fit ensures all four world corners are within canvas bounds
235    #[test]
236    fn fit_contains_world_region() {
237        let cases = [
238            // (world_w, world_h, canvas_w, canvas_h)
239            (100.0, 80.0, 800.0, 600.0),
240            (1920.0, 1080.0, 640.0, 480.0),
241            (50.0, 200.0, 1024.0, 768.0), // tall world
242            (300.0, 10.0, 800.0, 600.0),  // wide world
243        ];
244
245        for (ww, wh, cw, ch) in cases {
246            let mut v = View2d::default();
247            v.fit(ww, wh, cw, ch);
248
249            // Verify all 4 corners
250            let corners = [(0.0, 0.0), (ww, 0.0), (0.0, wh), (ww, wh)];
251            for (wx, wy) in corners {
252                let (cx, cy) = v.world_to_canvas(wx, wy, cw, ch);
253                assert!(
254                    cx >= -EPS && cx <= cw + EPS && cy >= -EPS && cy <= ch + EPS,
255                    "fit out of bounds: world=({wx},{wy}) -> canvas=({cx},{cy}), canvas_size=({cw},{ch})"
256                );
257            }
258        }
259    }
260
261    /// zoom_factor calculation
262    #[test]
263    fn zoom_factor_calculation() {
264        let v = View2d {
265            center_x: 0.0,
266            center_y: 0.0,
267            view_size: 5.0,
268        };
269        assert!((v.zoom_factor(10.0) - 2.0).abs() < EPS);
270        assert!((v.zoom_factor(5.0) - 1.0).abs() < EPS);
271        assert!((v.zoom_factor(2.5) - 0.5).abs() < EPS);
272    }
273
274    /// set clamps non-positive view_size to epsilon
275    #[test]
276    fn set_clamps_view_size() {
277        let mut v = View2d::default();
278
279        v.set(1.0, 2.0, 0.0);
280        assert_eq!(v.view_size, f64::EPSILON);
281
282        v.set(1.0, 2.0, -100.0);
283        assert_eq!(v.view_size, f64::EPSILON);
284
285        v.set(1.0, 2.0, f64::EPSILON * 0.5);
286        assert_eq!(v.view_size, f64::EPSILON);
287
288        // Positive value passes through
289        v.set(1.0, 2.0, 42.0);
290        assert_eq!(v.view_size, 42.0);
291    }
292
293    /// Default values
294    #[test]
295    fn default_values() {
296        let v = View2d::default();
297        assert_eq!(v.center_x, 0.0);
298        assert_eq!(v.center_y, 0.0);
299        assert_eq!(v.view_size, 1.0);
300    }
301}