1use glam::{Mat4, Vec2, Vec3};
10
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14pub enum FitMode {
15 #[default]
16 Keep,
17 Expand,
21}
22
23#[derive(Clone, Copy, Debug)]
27pub struct Camera2d {
28 pub center: Vec2,
31 pub offset: Vec2,
34 pub units_per_pixel: f32,
38 pub zoom: f32,
41 pub roll: f32,
44 pub fit: FitMode,
47}
48
49impl Default for Camera2d {
50 fn default() -> Self {
51 Self {
52 center: Vec2::ZERO,
53 offset: Vec2::ZERO,
54 units_per_pixel: 1.0,
55 zoom: 1.0,
56 roll: 0.0,
57 fit: FitMode::Keep,
58 }
59 }
60}
61
62impl Camera2d {
65 pub fn effective_center(&self) -> [f32; 2] {
68 [self.center.x + self.offset.x, self.center.y + self.offset.y]
69 }
70
71 pub fn fit_matrix(&self, canvas_dp: [f32; 2], world_size: [f32; 2]) -> Mat4 {
74 let fit = effective_fit(canvas_dp, world_size, self);
75 fit_view_proj_with_roll(
76 canvas_dp,
77 world_size,
78 self.effective_center(),
79 fit,
80 self.roll,
81 )
82 }
83
84 pub fn dp_to_world_pt(&self, canvas_dp: [f32; 2], world_size: [f32; 2], dp: [f32; 2]) -> Vec2 {
87 let fit = effective_fit(canvas_dp, world_size, self);
88 let w = dp_to_world_with_roll(dp, world_size, self.effective_center(), fit, self.roll);
89 Vec2::new(w[0], w[1])
90 }
91
92 pub fn view_proj_screen(&self, viewport_px: [f32; 2]) -> Mat4 {
95 let vp_w = viewport_px[0].max(1.0);
96 let vp_h = viewport_px[1].max(1.0);
97 let zoom = sane_positive(self.zoom);
98 let upp = sane_positive(self.units_per_pixel);
99 let w = vp_w * upp / zoom;
100 let h = vp_h * upp / zoom;
101 let proj = glam::camera::rh::proj::directx::orthographic(
102 -w / 2.0,
103 w / 2.0,
104 h / 2.0,
105 -h / 2.0,
106 -1000.0,
107 1000.0,
108 );
109 let view = Mat4::from_translation(Vec3::new(
110 -(self.center.x + self.offset.x),
111 -(self.center.y + self.offset.y),
112 0.0,
113 ));
114 proj * view
115 }
116
117 #[deprecated(
122 since = "0.1.8",
123 note = "ignores world_size; use fit_view_proj for the shared canvas/GPU/pick framing contract"
124 )]
125 pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
126 self.view_proj_screen(viewport_px)
127 }
128
129 #[deprecated(
133 since = "0.1.8",
134 note = "ignores world_size; use pick_world over the painted FrameGeom instead"
135 )]
136 #[allow(deprecated)]
137 pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
138 if viewport_px[0] <= 0.0 || viewport_px[1] <= 0.0 {
139 let [cx, cy] = self.effective_center();
140 return Vec2::new(cx, cy);
141 }
142 let ndc = Vec2::new(
143 (px[0] / viewport_px[0]) * 2.0 - 1.0,
144 1.0 - (px[1] / viewport_px[1]) * 2.0,
145 );
146 let inv = self.view_proj(viewport_px).inverse();
147 let world = inv.project_point3(ndc.extend(0.0));
148 Vec2::new(world.x, world.y)
149 }
150}
151
152fn sane_positive(v: f32) -> f32 {
155 if v.is_finite() && v > 1e-6 { v } else { 1.0 }
156}
157
158pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
164 if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
165 return (1.0, 0.0, 0.0);
166 }
167 let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
168 if !s.is_finite() || s <= 1e-6 {
169 return (1.0, 0.0, 0.0);
170 }
171 (
172 s,
173 (canvas_dp[0] - world[0] * s) * 0.5,
174 (canvas_dp[1] - world[1] * s) * 0.5,
175 )
176}
177
178pub fn effective_fit(canvas_dp: [f32; 2], world: [f32; 2], cam: &Camera2d) -> (f32, f32, f32) {
187 let (base, _, _) = contain_fit(canvas_dp, world);
188 let zoom = sane_positive(cam.zoom);
189 let upp = sane_positive(cam.units_per_pixel);
190 let s = base * zoom / upp;
191 if !s.is_finite() || s <= 1e-6 {
192 return (1.0, 0.0, 0.0);
193 }
194 let (ox, oy) = match cam.fit {
195 FitMode::Keep => (
196 (canvas_dp[0] - world[0] * s) * 0.5,
197 (canvas_dp[1] - world[1] * s) * 0.5,
198 ),
199 FitMode::Expand => (0.0, 0.0),
200 };
201 (s, ox, oy)
202}
203
204pub fn fit_view_proj(
207 canvas_dp: [f32; 2],
208 world_size: [f32; 2],
209 cam_center: [f32; 2],
210 fit: (f32, f32, f32),
211) -> Mat4 {
212 fit_view_proj_with_roll(canvas_dp, world_size, cam_center, fit, 0.0)
213}
214
215pub fn fit_view_proj_with_roll(
218 canvas_dp: [f32; 2],
219 world_size: [f32; 2],
220 cam_center: [f32; 2],
221 fit: (f32, f32, f32),
222 roll: f32,
223) -> Mat4 {
224 let w = canvas_dp[0].max(1.0);
225 let h = canvas_dp[1].max(1.0);
226 let (s, ox, oy) = fit;
227 let s = if s.is_finite() && s > 1e-6 { s } else { 1.0 };
228 let lx = cam_center[0] - world_size[0] * 0.5;
229 let ly = cam_center[1] - world_size[1] * 0.5;
230 let sx = 2.0 * s / w;
231 let sy = -2.0 * s / h;
232 let tx = 2.0 * (ox - lx * s) / w - 1.0;
233 let ty = 1.0 - 2.0 * (oy - ly * s) / h;
234 let base = Mat4::from_cols(
235 glam::Vec4::new(sx, 0.0, 0.0, 0.0),
236 glam::Vec4::new(0.0, sy, 0.0, 0.0),
237 glam::Vec4::new(0.0, 0.0, 0.001, 0.0),
238 glam::Vec4::new(tx, ty, 0.0, 1.0),
239 );
240 if !roll.is_finite() || roll.abs() < 1e-7 {
241 return base;
242 }
243 let (c, r) = (roll.cos(), roll.sin());
244 let rot = Mat4::from_cols(
245 glam::Vec4::new(c, r, 0.0, 0.0),
246 glam::Vec4::new(-r, c, 0.0, 0.0),
247 glam::Vec4::new(0.0, 0.0, 1.0, 0.0),
248 glam::Vec4::new(
249 cam_center[0] - c * cam_center[0] + r * cam_center[1],
250 cam_center[1] - r * cam_center[0] - c * cam_center[1],
251 0.0,
252 1.0,
253 ),
254 );
255 base * rot
256}
257
258pub fn world_to_dp(
261 world: [f32; 2],
262 world_size: [f32; 2],
263 cam_center: [f32; 2],
264 fit: (f32, f32, f32),
265) -> [f32; 2] {
266 world_to_dp_with_roll(world, world_size, cam_center, fit, 0.0)
267}
268
269pub fn world_to_dp_with_roll(
271 world: [f32; 2],
272 world_size: [f32; 2],
273 cam_center: [f32; 2],
274 fit: (f32, f32, f32),
275 roll: f32,
276) -> [f32; 2] {
277 let (s, ox, oy) = fit;
278 let [wx, wy] = rotate_about(world, cam_center, roll);
279 [
280 ox + (wx - (cam_center[0] - world_size[0] * 0.5)) * s,
281 oy + (wy - (cam_center[1] - world_size[1] * 0.5)) * s,
282 ]
283}
284
285pub fn dp_to_world(
287 dp: [f32; 2],
288 world_size: [f32; 2],
289 cam_center: [f32; 2],
290 fit: (f32, f32, f32),
291) -> [f32; 2] {
292 dp_to_world_with_roll(dp, world_size, cam_center, fit, 0.0)
293}
294
295pub fn dp_to_world_with_roll(
297 dp: [f32; 2],
298 world_size: [f32; 2],
299 cam_center: [f32; 2],
300 fit: (f32, f32, f32),
301 roll: f32,
302) -> [f32; 2] {
303 let (s, ox, oy) = fit;
304 if !s.is_finite() || s.abs() < 1e-6 {
305 return cam_center;
306 }
307 let base = [
308 (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
309 (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
310 ];
311 rotate_about(base, cam_center, -roll)
312}
313
314fn rotate_about(p: [f32; 2], center: [f32; 2], roll: f32) -> [f32; 2] {
315 if !roll.is_finite() || roll.abs() < 1e-7 {
316 return p;
317 }
318 let (c, r) = (roll.cos(), roll.sin());
319 let dx = p[0] - center[0];
320 let dy = p[1] - center[1];
321 [center[0] + c * dx - r * dy, center[1] + r * dx + c * dy]
322}
323
324pub fn unproject_px(
326 px: Vec2,
327 viewport_dp: [f32; 2],
328 world_size: [f32; 2],
329 density: f32,
330 cam: &Camera2d,
331) -> Vec2 {
332 let d = if density.is_finite() && density > 1e-6 {
333 density
334 } else {
335 1.0
336 };
337 let dp = [px.x / d, px.y / d];
338 cam.dp_to_world_pt(viewport_dp, world_size, dp)
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use repose_core::locals::{Density, effective_density_scale, set_density_default};
345
346 #[test]
347 fn contain_fit_roundtrip_at_density_125() {
348 set_density_default(Density { scale: 1.25 });
350 let d = effective_density_scale();
351 let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
353 let cam = [400.0, 300.0];
354 let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
356 let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
358 set_density_default(Density { scale: 1.0 });
359 assert!(
360 (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
361 "dp fit, got {fit:?}"
362 );
363 assert!(
364 (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
365 "draw forward, got ({dx},{dy})"
366 );
367 assert!(
368 (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
369 "pick inverse, got ({wx},{wy})"
370 );
371 let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
373 let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
374 assert!(
375 (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
376 "shifted round trip, got {back:?}"
377 );
378 }
379
380 #[test]
381 fn contain_fit_has_no_silent_clamp() {
382 for (canvas, world) in [
383 ([8000.0, 6000.0], [80.0, 60.0]),
384 ([80.0, 60.0], [8000.0, 6000.0]),
385 ([1000.0, 750.0], [800.0, 600.0]),
386 ] {
387 let fit = contain_fit(canvas, world);
388 let cam = [world[0] * 0.5, world[1] * 0.5];
389 let dp = world_to_dp([cam[0], cam[1]], world, cam, fit);
390 let back = dp_to_world(dp, world, cam, fit);
391 assert!(
392 (back[0] - cam[0]).abs() < 1e-3 && (back[1] - cam[1]).abs() < 1e-3,
393 "round trip for {canvas:?}/{world:?}, fit {fit:?} gave {back:?}"
394 );
395 }
396 assert_eq!(contain_fit([0.0, 600.0], [800.0, 600.0]), (1.0, 0.0, 0.0));
397 assert_eq!(
398 dp_to_world(
399 [10.0, 10.0],
400 [800.0, 600.0],
401 [400.0, 300.0],
402 (0.0, 0.0, 0.0)
403 ),
404 [400.0, 300.0]
405 );
406 }
407
408 #[test]
411 fn fit_mode_keep_is_contain_fit() {
412 for (canvas, world) in [
413 ([1600.0, 900.0], [800.0, 800.0]),
414 ([900.0, 1600.0], [800.0, 800.0]),
415 ([1000.0, 1000.0], [800.0, 600.0]),
416 ] {
417 let cam = Camera2d {
418 center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
419 fit: FitMode::Keep,
420 ..Default::default()
421 };
422 let fit = effective_fit(canvas, world, &cam);
423 assert_eq!(fit, contain_fit(canvas, world), "{canvas:?}/{world:?}");
424 let (s, ox, oy) = fit;
425 assert!((ox - (canvas[0] - world[0] * s) * 0.5).abs() < 1e-4);
427 assert!((oy - (canvas[1] - world[1] * s) * 0.5).abs() < 1e-4);
428 assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
429 }
430 }
431
432 #[test]
436 fn fit_mode_expand_reveals_more_and_never_crops() {
437 let canvas = [1600.0, 900.0];
438 let world = [800.0, 800.0];
439 let cam = Camera2d {
440 center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
441 fit: FitMode::Expand,
442 ..Default::default()
443 };
444 let (s, ox, oy) = effective_fit(canvas, world, &cam);
445 let keep = effective_fit(
446 canvas,
447 world,
448 &Camera2d {
449 fit: FitMode::Keep,
450 ..cam
451 },
452 );
453 assert_eq!(s, keep.0, "expand must not change the uniform scale");
454 assert_eq!((ox, oy), (0.0, 0.0), "expand anchors at the origin");
455 assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
457 let visible = [canvas[0] / s, canvas[1] / s];
460 let exact = [
461 (visible[0] - world[0]).abs() < 1e-3,
462 (visible[1] - world[1]).abs() < 1e-3,
463 ];
464 assert!(exact[0] ^ exact[1], "one axis exact: {visible:?}");
465 if exact[0] {
466 assert!(visible[1] > world[1], "y reveals: {visible:?}");
467 } else {
468 assert!(visible[0] > world[0], "x reveals: {visible:?}");
469 }
470 let dp = world_to_dp([100.0, 700.0], world, cam.effective_center(), (s, ox, oy));
472 let back = dp_to_world(dp, world, cam.effective_center(), (s, ox, oy));
473 assert!((back[0] - 100.0).abs() < 1e-3 && (back[1] - 700.0).abs() < 1e-3);
474 }
475
476 #[test]
477 fn canvas_rotated_rect_matches_instance_rows() {
478 let cam = Camera2d {
482 center: Vec2::new(400.0, 300.0),
483 offset: Vec2::ZERO,
484 units_per_pixel: 1.0,
485 zoom: 1.0,
486 roll: 0.3,
487 fit: FitMode::Keep,
488 };
489 let world_size = [800.0, 600.0];
490 let canvas_dp = [800.0, 600.0];
491 let fit = effective_fit(canvas_dp, world_size, &cam);
492 let center = cam.effective_center();
493 let project = |wx: f32, wy: f32| -> [f32; 2] {
494 world_to_dp_with_roll([wx, wy], world_size, center, fit, cam.roll)
495 };
496 let k = fit.0;
497 for (rotation, flip_x, flip_y, anchor) in [
498 (0.7f32, false, false, [0.5, 0.5]),
499 (0.7, true, false, [0.5, 0.5]),
500 (0.7, false, true, [0.5, 0.5]),
501 (0.7, true, true, [0.5, 0.5]),
502 (-1.2, true, false, [0.0, 0.0]),
503 (2.1, false, true, [1.0, 0.25]),
504 (0.0, true, false, [0.5, 0.5]),
505 ] {
506 let size = [64.0f32, 40.0];
507 let ctr = [400.0f32, 300.0];
508 let (row0, row1) =
510 crate::batch::instance_rows(ctr, size, rotation, anchor, flip_x, flip_y);
511 let mut expect = [[0.0f32; 2]; 4];
512 for (i, corner) in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]
513 .iter()
514 .enumerate()
515 {
516 let wx = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
517 let wy = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
518 expect[i] = project(wx, wy);
519 }
520 let ax = if flip_x { 1.0 - anchor[0] } else { anchor[0] };
524 let ay = if flip_y { 1.0 - anchor[1] } else { anchor[1] };
525 let [px, py] = project(ctr[0], ctr[1]);
526 let angle = cam.roll + rotation;
527 let (c, s) = (angle.cos(), angle.sin());
528 let (ox, oy) = (px - ax * size[0] * k, py - ay * size[1] * k);
529 let w = size[0] * k;
530 let h = size[1] * k;
531 for (i, corner) in [[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]].iter().enumerate() {
532 let rx = px + (ox + corner[0] - px) * c - (oy + corner[1] - py) * s;
533 let ry = py + (ox + corner[0] - px) * s + (oy + corner[1] - py) * c;
534 let mut best = f32::INFINITY;
535 for e in expect {
536 best = best.min((rx - e[0]).hypot(ry - e[1]));
537 }
538 assert!(
539 best < 1e-2,
540 "rot={rotation} flip=({flip_x},{flip_y}) anchor={anchor:?}: corner {i} ({rx},{ry}) matches none of {expect:?}"
541 );
542 }
543 }
544 }
545
546 #[test]
547 #[allow(deprecated)]
548 fn camera_guards_div_by_zero() {
549 let bad = Camera2d {
550 center: Vec2::ZERO,
551 offset: Vec2::ZERO,
552 units_per_pixel: 0.0,
553 zoom: 0.0,
554 roll: 0.0,
555 fit: FitMode::Keep,
556 };
557 let m = bad.view_proj([0.0, 0.0]);
558 assert!(m.is_finite(), "view_proj stays finite, got {m:?}");
559 assert_eq!(bad.screen_to_world([0.0, 0.0], [5.0, 5.0]), Vec2::ZERO);
560 }
561
562 #[test]
563 fn fit_matrix_canvas_gpu_picks_agree() {
564 let cam = Camera2d {
565 center: Vec2::new(410.0, 290.0),
566 offset: Vec2::ZERO,
567 units_per_pixel: 1.0,
568 zoom: 2.0,
569 roll: 0.0,
570 fit: FitMode::Keep,
571 };
572 let world_size = [800.0, 600.0];
573 let density = 1.25;
574 let vp_phys = [1250.0, 750.0];
575 let canvas_dp = [vp_phys[0] / density, vp_phys[1] / density];
576 let cam_center = cam.effective_center();
577 let fit = effective_fit(canvas_dp, world_size, &cam);
578 assert!((fit.0 - 2.0).abs() < 1e-4, "effective fit, got {fit:?}");
579 let vp = cam.fit_matrix(canvas_dp, world_size);
580 assert!(vp.is_finite());
581 for world in [[400.0, 300.0], [410.0, 290.0], [0.0, 0.0], [800.0, 600.0]] {
582 let [dx, dy] = world_to_dp(world, world_size, cam_center, fit);
583 let px = [dx * density, dy * density];
584 let ndc = vp.project_point3(glam::Vec3::new(world[0], world[1], 0.0));
585 let gpu_px = [
586 (ndc.x + 1.0) * 0.5 * vp_phys[0],
587 (1.0 - ndc.y) * 0.5 * vp_phys[1],
588 ];
589 assert!(
590 (px[0] - gpu_px[0]).abs() < 1e-2 && (px[1] - gpu_px[1]).abs() < 1e-2,
591 "canvas/GPU disagree for {world:?}: canvas {px:?} vs gpu {gpu_px:?}"
592 );
593 let back =
594 cam.dp_to_world_pt(canvas_dp, world_size, [px[0] / density, px[1] / density]);
595 assert!(
596 (back.x - world[0]).abs() < 1e-3 && (back.y - world[1]).abs() < 1e-3,
597 "pick round trip for {world:?} gave {back:?}"
598 );
599 }
600 }
601
602 #[test]
603 fn letterbox_pick_matches_draw() {
604 let cam = Camera2d {
606 center: Vec2::new(400.0, 400.0),
607 ..Default::default()
608 };
609 let fit = effective_fit([1600.0, 900.0], [800.0, 800.0], &cam);
610 let dp = world_to_dp([400.0, 400.0], [800.0, 800.0], cam.effective_center(), fit);
611 let back = dp_to_world(dp, [800.0, 800.0], cam.effective_center(), fit);
612 assert!((back[0] - 400.0).abs() < 1e-3 && (back[1] - 400.0).abs() < 1e-3);
613 let m = cam.fit_matrix([1600.0, 900.0], [800.0, 800.0]);
615 let plain = fit_view_proj([1600.0, 900.0], [800.0, 800.0], cam.effective_center(), fit);
616 let dm = m - plain;
617 let max = dm
618 .to_cols_array()
619 .iter()
620 .fold(0.0f32, |a, b| a.max(b.abs()));
621 assert!(max < 1e-5);
622 }
623
624 #[test]
625 fn offset_shifts_look_and_draw_together() {
626 let world_size = [800.0, 600.0];
629 let canvas_dp = [1000.0, 600.0];
630 let mut cam = Camera2d {
631 center: Vec2::new(400.0, 300.0),
632 offset: Vec2::new(10.0, -10.0),
633 units_per_pixel: 1.0,
634 zoom: 1.0,
635 roll: 0.0,
636 fit: FitMode::Keep,
637 };
638 assert_eq!(cam.effective_center(), [410.0, 290.0]);
639 let fit = effective_fit(canvas_dp, world_size, &cam);
640 let dp = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
641 let back = dp_to_world(dp, world_size, cam.effective_center(), fit);
642 assert!((back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4);
643 cam.offset = Vec2::ZERO;
644 let dp2 = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
645 assert!((dp[0] - dp2[0] + 10.0).abs() < 1e-4);
647 assert!((dp[1] - dp2[1] - 10.0).abs() < 1e-4);
648 }
649
650 #[test]
651 fn roll_roundtrips_and_moves_pixels() {
652 let cam = Camera2d {
653 center: Vec2::new(400.0, 300.0),
654 offset: Vec2::ZERO,
655 units_per_pixel: 1.0,
656 zoom: 1.0,
657 roll: std::f32::consts::FRAC_PI_2,
658 fit: FitMode::Keep,
659 };
660 let world = [800.0, 600.0];
661 let dp = [1000.0, 600.0];
662 let fit = effective_fit(dp, world, &cam);
663 for p in [[400.0, 300.0], [500.0, 300.0], [0.0, 0.0]] {
664 let fwd = world_to_dp_with_roll(p, world, cam.effective_center(), fit, cam.roll);
665 let back = dp_to_world_with_roll(fwd, world, cam.effective_center(), fit, cam.roll);
666 assert!(
667 (back[0] - p[0]).abs() < 1e-3 && (back[1] - p[1]).abs() < 1e-3,
668 "roll roundtrip for {p:?} gave {back:?}"
669 );
670 }
671 let plain = world_to_dp([500.0, 300.0], world, cam.effective_center(), fit);
672 let rolled =
673 world_to_dp_with_roll([500.0, 300.0], world, cam.effective_center(), fit, cam.roll);
674 assert!(
675 (plain[0] - rolled[0]).abs() + (plain[1] - rolled[1]).abs() > 1.0,
676 "roll must move pixels, plain {plain:?} rolled {rolled:?}"
677 );
678 let m0 = fit_view_proj_with_roll(dp, world, cam.effective_center(), fit, 0.0);
679 let legacy = fit_view_proj(dp, world, cam.effective_center(), fit);
680 assert_eq!(m0, legacy);
681 }
682
683 #[test]
684 fn px_round_trips_through_fit() {
685 let cam = Camera2d {
686 center: Vec2::new(213.0, 120.0),
687 offset: Vec2::ZERO,
688 units_per_pixel: 1.0 / 3.0,
689 zoom: 1.0,
690 roll: 0.0,
691 fit: FitMode::Keep,
692 };
693 let world = Vec2::new(100.0, 60.0);
694 let viewport_dp = [1024.0, 600.0];
695 let world_size = [1024.0, 600.0];
696 let (s, ox, oy) = effective_fit(viewport_dp, world_size, &cam);
697 let [dx, dy] = world_to_dp(
698 [world.x, world.y],
699 world_size,
700 cam.effective_center(),
701 (s, ox, oy),
702 );
703 let px = Vec2::new(dx * 1.25, dy * 1.25);
704 let back = unproject_px(px, viewport_dp, world_size, 1.25, &cam);
705 assert!(
706 (back - world).length() < 0.01,
707 "round trip, got {back:?} for {world:?}"
708 );
709 }
710}