1#[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 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 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 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 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 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 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 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 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 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 self.view_size = base * 1.05;
119 }
120
121 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 #[test]
135 fn coordinate_roundtrip() {
136 let cases = [
137 (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 #[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 #[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 #[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 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 #[test]
236 fn fit_contains_world_region() {
237 let cases = [
238 (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), (300.0, 10.0, 800.0, 600.0), ];
244
245 for (ww, wh, cw, ch) in cases {
246 let mut v = View2d::default();
247 v.fit(ww, wh, cw, ch);
248
249 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 #[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 #[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 v.set(1.0, 2.0, 42.0);
290 assert_eq!(v.view_size, 42.0);
291 }
292
293 #[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}