1use glam::{Mat4, Vec2, Vec3};
10use repose_core::Color;
11
12pub mod batch;
13pub use batch::{
14 AtlasUpload, BatchDesc, BatchFrame, SpriteBatch, TextureFilter, draw_batch, draw_batch_with_id,
15 frame_uv, instance_rows, screen_camera, sprite_aabb,
16};
17
18pub mod fullscreen;
19pub use fullscreen::{
20 FullscreenDesc, FullscreenPass, FullscreenTexture, FullscreenTextureRef,
21 FullscreenTextureUpload,
22};
23
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum FitMode {
28 #[default]
29 Keep,
30 Expand,
34}
35
36#[derive(Clone, Copy, Debug)]
40pub struct Camera2d {
41 pub center: Vec2,
44 pub offset: Vec2,
47 pub units_per_pixel: f32,
51 pub zoom: f32,
54 pub roll: f32,
57 pub fit: FitMode,
60}
61
62impl Default for Camera2d {
63 fn default() -> Self {
64 Self {
65 center: Vec2::ZERO,
66 offset: Vec2::ZERO,
67 units_per_pixel: 1.0,
68 zoom: 1.0,
69 roll: 0.0,
70 fit: FitMode::Keep,
71 }
72 }
73}
74
75impl Camera2d {
78 pub fn effective_center(&self) -> [f32; 2] {
81 [self.center.x + self.offset.x, self.center.y + self.offset.y]
82 }
83
84 pub fn fit_matrix(&self, canvas_dp: [f32; 2], world_size: [f32; 2]) -> Mat4 {
87 let fit = effective_fit(canvas_dp, world_size, self);
88 fit_view_proj_with_roll(
89 canvas_dp,
90 world_size,
91 self.effective_center(),
92 fit,
93 self.roll,
94 )
95 }
96
97 pub fn dp_to_world_pt(&self, canvas_dp: [f32; 2], world_size: [f32; 2], dp: [f32; 2]) -> Vec2 {
100 let fit = effective_fit(canvas_dp, world_size, self);
101 let w = dp_to_world_with_roll(dp, world_size, self.effective_center(), fit, self.roll);
102 Vec2::new(w[0], w[1])
103 }
104
105 pub fn view_proj_screen(&self, viewport_px: [f32; 2]) -> Mat4 {
108 let vp_w = viewport_px[0].max(1.0);
109 let vp_h = viewport_px[1].max(1.0);
110 let zoom = sane_positive(self.zoom);
111 let upp = sane_positive(self.units_per_pixel);
112 let w = vp_w * upp / zoom;
113 let h = vp_h * upp / zoom;
114 let proj = glam::camera::rh::proj::directx::orthographic(
115 -w / 2.0,
116 w / 2.0,
117 h / 2.0,
118 -h / 2.0,
119 -1000.0,
120 1000.0,
121 );
122 let view = Mat4::from_translation(Vec3::new(
123 -(self.center.x + self.offset.x),
124 -(self.center.y + self.offset.y),
125 0.0,
126 ));
127 proj * view
128 }
129
130 #[deprecated(
135 since = "0.1.8",
136 note = "ignores world_size; use fit_view_proj for the shared canvas/GPU/pick framing contract"
137 )]
138 pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
139 self.view_proj_screen(viewport_px)
140 }
141
142 #[deprecated(
146 since = "0.1.8",
147 note = "ignores world_size; use pick_world over the painted FrameGeom instead"
148 )]
149 #[allow(deprecated)]
150 pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
151 if viewport_px[0] <= 0.0 || viewport_px[1] <= 0.0 {
152 let [cx, cy] = self.effective_center();
153 return Vec2::new(cx, cy);
154 }
155 let ndc = Vec2::new(
156 (px[0] / viewport_px[0]) * 2.0 - 1.0,
157 1.0 - (px[1] / viewport_px[1]) * 2.0,
158 );
159 let inv = self.view_proj(viewport_px).inverse();
160 let world = inv.project_point3(ndc.extend(0.0));
161 Vec2::new(world.x, world.y)
162 }
163}
164
165#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
169pub enum SpriteBlend {
170 #[default]
171 Alpha,
172 Additive,
173 Multiply,
174}
175
176#[derive(Clone, Copy, Debug)]
178pub struct SpriteInstance {
179 pub center: Vec2,
181 pub rotation: f32,
182 pub size: Vec2,
183 pub anchor: Vec2,
186 pub flip_x: bool,
189 pub flip_y: bool,
190 pub uv_min: Vec2,
191 pub uv_max: Vec2,
192 pub color: [f32; 4],
194 pub page: u32,
196 pub z: f32,
201 pub blend: SpriteBlend,
202}
203
204impl Default for SpriteInstance {
205 fn default() -> Self {
206 Self {
207 center: Vec2::ZERO,
208 rotation: 0.0,
209 size: Vec2::ONE,
210 anchor: Vec2::new(0.5, 0.5),
211 flip_x: false,
212 flip_y: false,
213 uv_min: Vec2::ZERO,
214 uv_max: Vec2::ONE,
215 color: [1.0, 1.0, 1.0, 1.0],
216 page: 0,
217 z: 0.0,
218 blend: SpriteBlend::Alpha,
219 }
220 }
221}
222
223#[derive(Clone, Debug, Default)]
225pub struct WorldText {
226 pub text: String,
227 pub pos: Vec2,
229 pub color: [f32; 4],
231 pub size: f32,
233 pub font_family: Option<&'static str>,
237}
238
239pub enum PickEvent {
240 Press {
241 world: Vec2,
242 screen: [f32; 2],
243 button: repose_core::input::PointerButton,
244 },
245 Click {
246 world: Vec2,
247 screen: [f32; 2],
248 button: repose_core::input::PointerButton,
249 },
250 Release {
256 world: Vec2,
257 screen: [f32; 2],
258 button: repose_core::input::PointerButton,
259 },
260 Hover {
261 world: Vec2,
262 screen: [f32; 2],
268 },
269 TouchDown { id: u64, screen: [f32; 2] },
272 TouchMove { id: u64, screen: [f32; 2] },
274 TouchUp { id: u64 },
276}
277
278fn sane_positive(v: f32) -> f32 {
281 if v.is_finite() && v > 1e-6 { v } else { 1.0 }
282}
283
284pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
288 if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
289 return (1.0, 0.0, 0.0);
290 }
291 let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
292 if !s.is_finite() || s <= 1e-6 {
293 return (1.0, 0.0, 0.0);
294 }
295 (
296 s,
297 (canvas_dp[0] - world[0] * s) * 0.5,
298 (canvas_dp[1] - world[1] * s) * 0.5,
299 )
300}
301
302pub fn effective_fit(canvas_dp: [f32; 2], world: [f32; 2], cam: &Camera2d) -> (f32, f32, f32) {
311 let (base, _, _) = contain_fit(canvas_dp, world);
312 let zoom = sane_positive(cam.zoom);
313 let upp = sane_positive(cam.units_per_pixel);
314 let s = base * zoom / upp;
315 if !s.is_finite() || s <= 1e-6 {
316 return (1.0, 0.0, 0.0);
317 }
318 let (ox, oy) = match cam.fit {
319 FitMode::Keep => (
320 (canvas_dp[0] - world[0] * s) * 0.5,
321 (canvas_dp[1] - world[1] * s) * 0.5,
322 ),
323 FitMode::Expand => (0.0, 0.0),
324 };
325 (s, ox, oy)
326}
327
328pub fn fit_view_proj(
331 canvas_dp: [f32; 2],
332 world_size: [f32; 2],
333 cam_center: [f32; 2],
334 fit: (f32, f32, f32),
335) -> Mat4 {
336 fit_view_proj_with_roll(canvas_dp, world_size, cam_center, fit, 0.0)
337}
338
339pub fn fit_view_proj_with_roll(
342 canvas_dp: [f32; 2],
343 world_size: [f32; 2],
344 cam_center: [f32; 2],
345 fit: (f32, f32, f32),
346 roll: f32,
347) -> Mat4 {
348 let w = canvas_dp[0].max(1.0);
349 let h = canvas_dp[1].max(1.0);
350 let (s, ox, oy) = fit;
351 let s = if s.is_finite() && s > 1e-6 { s } else { 1.0 };
352 let lx = cam_center[0] - world_size[0] * 0.5;
353 let ly = cam_center[1] - world_size[1] * 0.5;
354 let sx = 2.0 * s / w;
355 let sy = -2.0 * s / h;
356 let tx = 2.0 * (ox - lx * s) / w - 1.0;
357 let ty = 1.0 - 2.0 * (oy - ly * s) / h;
358 let base = Mat4::from_cols(
359 glam::Vec4::new(sx, 0.0, 0.0, 0.0),
360 glam::Vec4::new(0.0, sy, 0.0, 0.0),
361 glam::Vec4::new(0.0, 0.0, 0.001, 0.0),
362 glam::Vec4::new(tx, ty, 0.0, 1.0),
363 );
364 if !roll.is_finite() || roll.abs() < 1e-7 {
365 return base;
366 }
367 let (c, r) = (roll.cos(), roll.sin());
368 let rot = Mat4::from_cols(
369 glam::Vec4::new(c, r, 0.0, 0.0),
370 glam::Vec4::new(-r, c, 0.0, 0.0),
371 glam::Vec4::new(0.0, 0.0, 1.0, 0.0),
372 glam::Vec4::new(
373 cam_center[0] - c * cam_center[0] + r * cam_center[1],
374 cam_center[1] - r * cam_center[0] - c * cam_center[1],
375 0.0,
376 1.0,
377 ),
378 );
379 base * rot
380}
381
382pub fn world_to_dp(
385 world: [f32; 2],
386 world_size: [f32; 2],
387 cam_center: [f32; 2],
388 fit: (f32, f32, f32),
389) -> [f32; 2] {
390 world_to_dp_with_roll(world, world_size, cam_center, fit, 0.0)
391}
392
393pub fn world_to_dp_with_roll(
395 world: [f32; 2],
396 world_size: [f32; 2],
397 cam_center: [f32; 2],
398 fit: (f32, f32, f32),
399 roll: f32,
400) -> [f32; 2] {
401 let (s, ox, oy) = fit;
402 let [wx, wy] = rotate_about(world, cam_center, roll);
403 [
404 ox + (wx - (cam_center[0] - world_size[0] * 0.5)) * s,
405 oy + (wy - (cam_center[1] - world_size[1] * 0.5)) * s,
406 ]
407}
408
409pub fn dp_to_world(
411 dp: [f32; 2],
412 world_size: [f32; 2],
413 cam_center: [f32; 2],
414 fit: (f32, f32, f32),
415) -> [f32; 2] {
416 dp_to_world_with_roll(dp, world_size, cam_center, fit, 0.0)
417}
418
419pub fn dp_to_world_with_roll(
421 dp: [f32; 2],
422 world_size: [f32; 2],
423 cam_center: [f32; 2],
424 fit: (f32, f32, f32),
425 roll: f32,
426) -> [f32; 2] {
427 let (s, ox, oy) = fit;
428 if !s.is_finite() || s.abs() < 1e-6 {
429 return cam_center;
430 }
431 let base = [
432 (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
433 (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
434 ];
435 rotate_about(base, cam_center, -roll)
436}
437
438fn rotate_about(p: [f32; 2], center: [f32; 2], roll: f32) -> [f32; 2] {
439 if !roll.is_finite() || roll.abs() < 1e-7 {
440 return p;
441 }
442 let (c, r) = (roll.cos(), roll.sin());
443 let dx = p[0] - center[0];
444 let dy = p[1] - center[1];
445 [center[0] + c * dx - r * dy, center[1] + r * dx + c * dy]
446}
447
448pub const CLICK_SLOP_DP: f32 = 12.0;
453
454pub fn click_within_slop_dp(a: [f32; 2], b: [f32; 2], density: f32) -> bool {
457 let d = if density.is_finite() && density > 1e-6 {
458 density
459 } else {
460 1.0
461 };
462 let dx = (a[0] - b[0]) / d;
463 let dy = (a[1] - b[1]) / d;
464 dx * dx + dy * dy <= CLICK_SLOP_DP * CLICK_SLOP_DP
465}
466
467pub fn rgba8(c: [f32; 4]) -> Color {
468 Color::from_rgba(
469 (c[0].clamp(0.0, 1.0) * 255.0) as u8,
470 (c[1].clamp(0.0, 1.0) * 255.0) as u8,
471 (c[2].clamp(0.0, 1.0) * 255.0) as u8,
472 (c[3].clamp(0.0, 1.0) * 255.0) as u8,
473 )
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use repose_core::locals::{Density, effective_density_scale, set_density_default};
480
481 #[test]
482 fn contain_fit_roundtrip_at_density_125() {
483 set_density_default(Density { scale: 1.25 });
485 let d = effective_density_scale();
486 let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
488 let cam = [400.0, 300.0];
489 let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
491 let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
493 set_density_default(Density { scale: 1.0 });
494 assert!(
495 (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
496 "dp fit, got {fit:?}"
497 );
498 assert!(
499 (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
500 "draw forward, got ({dx},{dy})"
501 );
502 assert!(
503 (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
504 "pick inverse, got ({wx},{wy})"
505 );
506 let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
508 let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
509 assert!(
510 (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
511 "shifted round trip, got {back:?}"
512 );
513 }
514
515 #[test]
516 fn single_layer_atlas_draws_tinted_sprites() {
517 assert_eq!(
523 crate::batch::atlas_shape(1),
524 (wgpu::TextureViewDimension::D2, false)
525 );
526 assert_eq!(
527 crate::batch::atlas_shape(4),
528 (wgpu::TextureViewDimension::D2Array, true)
529 );
530 assert!(
531 crate::batch::sprite_shader(1).contains("texture_2d<")
532 && !crate::batch::sprite_shader(1).contains("texture_2d_array"),
533 "single-layer shader samples a 2d atlas"
534 );
535 assert!(
536 crate::batch::sprite_shader(4).contains("texture_2d_array"),
537 "multi-layer shader samples an array atlas"
538 );
539 }
540
541 #[test]
542 fn contain_fit_has_no_silent_clamp() {
543 for (canvas, world) in [
544 ([8000.0, 6000.0], [80.0, 60.0]),
545 ([80.0, 60.0], [8000.0, 6000.0]),
546 ([1000.0, 750.0], [800.0, 600.0]),
547 ] {
548 let fit = contain_fit(canvas, world);
549 let cam = [world[0] * 0.5, world[1] * 0.5];
550 let dp = world_to_dp([cam[0], cam[1]], world, cam, fit);
551 let back = dp_to_world(dp, world, cam, fit);
552 assert!(
553 (back[0] - cam[0]).abs() < 1e-3 && (back[1] - cam[1]).abs() < 1e-3,
554 "round trip for {canvas:?}/{world:?}, fit {fit:?} gave {back:?}"
555 );
556 }
557 assert_eq!(contain_fit([0.0, 600.0], [800.0, 600.0]), (1.0, 0.0, 0.0));
558 assert_eq!(
559 dp_to_world(
560 [10.0, 10.0],
561 [800.0, 600.0],
562 [400.0, 300.0],
563 (0.0, 0.0, 0.0)
564 ),
565 [400.0, 300.0]
566 );
567 }
568
569 #[test]
572 fn fit_mode_keep_is_contain_fit() {
573 for (canvas, world) in [
574 ([1600.0, 900.0], [800.0, 800.0]),
575 ([900.0, 1600.0], [800.0, 800.0]),
576 ([1000.0, 1000.0], [800.0, 600.0]),
577 ] {
578 let cam = Camera2d {
579 center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
580 fit: FitMode::Keep,
581 ..Default::default()
582 };
583 let fit = effective_fit(canvas, world, &cam);
584 assert_eq!(fit, contain_fit(canvas, world), "{canvas:?}/{world:?}");
585 let (s, ox, oy) = fit;
586 assert!((ox - (canvas[0] - world[0] * s) * 0.5).abs() < 1e-4);
588 assert!((oy - (canvas[1] - world[1] * s) * 0.5).abs() < 1e-4);
589 assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
590 }
591 }
592
593 #[test]
597 fn fit_mode_expand_reveals_more_and_never_crops() {
598 let canvas = [1600.0, 900.0];
599 let world = [800.0, 800.0];
600 let cam = Camera2d {
601 center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
602 fit: FitMode::Expand,
603 ..Default::default()
604 };
605 let (s, ox, oy) = effective_fit(canvas, world, &cam);
606 let keep = effective_fit(
607 canvas,
608 world,
609 &Camera2d {
610 fit: FitMode::Keep,
611 ..cam
612 },
613 );
614 assert_eq!(s, keep.0, "expand must not change the uniform scale");
615 assert_eq!((ox, oy), (0.0, 0.0), "expand anchors at the origin");
616 assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
618 let visible = [canvas[0] / s, canvas[1] / s];
621 let exact = [
622 (visible[0] - world[0]).abs() < 1e-3,
623 (visible[1] - world[1]).abs() < 1e-3,
624 ];
625 assert!(exact[0] ^ exact[1], "one axis exact: {visible:?}");
626 if exact[0] {
627 assert!(visible[1] > world[1], "y reveals: {visible:?}");
628 } else {
629 assert!(visible[0] > world[0], "x reveals: {visible:?}");
630 }
631 let dp = world_to_dp([100.0, 700.0], world, cam.effective_center(), (s, ox, oy));
633 let back = dp_to_world(dp, world, cam.effective_center(), (s, ox, oy));
634 assert!((back[0] - 100.0).abs() < 1e-3 && (back[1] - 700.0).abs() < 1e-3);
635 }
636
637 #[test]
638 fn canvas_rotated_rect_matches_instance_rows() {
639 let cam = Camera2d {
643 center: Vec2::new(400.0, 300.0),
644 offset: Vec2::ZERO,
645 units_per_pixel: 1.0,
646 zoom: 1.0,
647 roll: 0.3,
648 fit: FitMode::Keep,
649 };
650 let world_size = [800.0, 600.0];
651 let canvas_dp = [800.0, 600.0];
652 let fit = effective_fit(canvas_dp, world_size, &cam);
653 let center = cam.effective_center();
654 let project = |wx: f32, wy: f32| -> [f32; 2] {
655 world_to_dp_with_roll([wx, wy], world_size, center, fit, cam.roll)
656 };
657 let k = fit.0;
658 for (rotation, flip_x, flip_y, anchor) in [
659 (0.7f32, false, false, [0.5, 0.5]),
660 (0.7, true, false, [0.5, 0.5]),
661 (0.7, false, true, [0.5, 0.5]),
662 (0.7, true, true, [0.5, 0.5]),
663 (-1.2, true, false, [0.0, 0.0]),
664 (2.1, false, true, [1.0, 0.25]),
665 (0.0, true, false, [0.5, 0.5]),
666 ] {
667 let size = [64.0f32, 40.0];
668 let ctr = [400.0f32, 300.0];
669 let (row0, row1) = batch::instance_rows(ctr, size, rotation, anchor, flip_x, flip_y);
671 let mut expect = [[0.0f32; 2]; 4];
672 for (i, corner) in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]
673 .iter()
674 .enumerate()
675 {
676 let wx = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
677 let wy = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
678 expect[i] = project(wx, wy);
679 }
680 let ax = if flip_x { 1.0 - anchor[0] } else { anchor[0] };
684 let ay = if flip_y { 1.0 - anchor[1] } else { anchor[1] };
685 let [px, py] = project(ctr[0], ctr[1]);
686 let angle = cam.roll + rotation;
687 let (c, s) = (angle.cos(), angle.sin());
688 let (ox, oy) = (px - ax * size[0] * k, py - ay * size[1] * k);
689 let w = size[0] * k;
690 let h = size[1] * k;
691 for (i, corner) in [[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]].iter().enumerate() {
692 let rx = px + (ox + corner[0] - px) * c - (oy + corner[1] - py) * s;
693 let ry = py + (ox + corner[0] - px) * s + (oy + corner[1] - py) * c;
694 let mut best = f32::INFINITY;
695 for e in expect {
696 best = best.min((rx - e[0]).hypot(ry - e[1]));
697 }
698 assert!(
699 best < 1e-2,
700 "rot={rotation} flip=({flip_x},{flip_y}) anchor={anchor:?}: corner {i} ({rx},{ry}) matches none of {expect:?}"
701 );
702 }
703 }
704 }
705
706 #[test]
707 #[allow(deprecated)]
708 fn camera_guards_div_by_zero() {
709 let bad = Camera2d {
710 center: Vec2::ZERO,
711 offset: Vec2::ZERO,
712 units_per_pixel: 0.0,
713 zoom: 0.0,
714 roll: 0.0,
715 fit: FitMode::Keep,
716 };
717 let m = bad.view_proj([0.0, 0.0]);
718 assert!(m.is_finite(), "view_proj stays finite, got {m:?}");
719 assert_eq!(bad.screen_to_world([0.0, 0.0], [5.0, 5.0]), Vec2::ZERO);
720 }
721
722 #[test]
723 fn fit_matrix_canvas_gpu_picks_agree() {
724 let cam = Camera2d {
725 center: Vec2::new(410.0, 290.0),
726 offset: Vec2::ZERO,
727 units_per_pixel: 1.0,
728 zoom: 2.0,
729 roll: 0.0,
730 fit: FitMode::Keep,
731 };
732 let world_size = [800.0, 600.0];
733 let density = 1.25;
734 let vp_phys = [1250.0, 750.0];
735 let canvas_dp = [vp_phys[0] / density, vp_phys[1] / density];
736 let cam_center = cam.effective_center();
737 let fit = effective_fit(canvas_dp, world_size, &cam);
738 assert!((fit.0 - 2.0).abs() < 1e-4, "effective fit, got {fit:?}");
739 let vp = cam.fit_matrix(canvas_dp, world_size);
740 assert!(vp.is_finite());
741 for world in [[400.0, 300.0], [410.0, 290.0], [0.0, 0.0], [800.0, 600.0]] {
742 let [dx, dy] = world_to_dp(world, world_size, cam_center, fit);
743 let px = [dx * density, dy * density];
744 let ndc = vp.project_point3(glam::Vec3::new(world[0], world[1], 0.0));
745 let gpu_px = [
746 (ndc.x + 1.0) * 0.5 * vp_phys[0],
747 (1.0 - ndc.y) * 0.5 * vp_phys[1],
748 ];
749 assert!(
750 (px[0] - gpu_px[0]).abs() < 1e-2 && (px[1] - gpu_px[1]).abs() < 1e-2,
751 "canvas/GPU disagree for {world:?}: canvas {px:?} vs gpu {gpu_px:?}"
752 );
753 let back =
754 cam.dp_to_world_pt(canvas_dp, world_size, [px[0] / density, px[1] / density]);
755 assert!(
756 (back.x - world[0]).abs() < 1e-3 && (back.y - world[1]).abs() < 1e-3,
757 "pick round trip for {world:?} gave {back:?}"
758 );
759 }
760 }
761
762 #[test]
763 fn letterbox_pick_matches_draw() {
764 let cam = Camera2d {
766 center: Vec2::new(400.0, 400.0),
767 ..Default::default()
768 };
769 let fit = effective_fit([1600.0, 900.0], [800.0, 800.0], &cam);
770 let dp = world_to_dp([400.0, 400.0], [800.0, 800.0], cam.effective_center(), fit);
771 let back = dp_to_world(dp, [800.0, 800.0], cam.effective_center(), fit);
772 assert!((back[0] - 400.0).abs() < 1e-3 && (back[1] - 400.0).abs() < 1e-3);
773 let m = cam.fit_matrix([1600.0, 900.0], [800.0, 800.0]);
775 let plain = fit_view_proj([1600.0, 900.0], [800.0, 800.0], cam.effective_center(), fit);
776 let dm = m - plain;
777 let max = dm
778 .to_cols_array()
779 .iter()
780 .fold(0.0f32, |a, b| a.max(b.abs()));
781 assert!(max < 1e-5);
782 }
783
784 #[test]
785 fn offset_shifts_look_and_draw_together() {
786 let world_size = [800.0, 600.0];
789 let canvas_dp = [1000.0, 600.0];
790 let mut cam = Camera2d {
791 center: Vec2::new(400.0, 300.0),
792 offset: Vec2::new(10.0, -10.0),
793 units_per_pixel: 1.0,
794 zoom: 1.0,
795 roll: 0.0,
796 fit: FitMode::Keep,
797 };
798 assert_eq!(cam.effective_center(), [410.0, 290.0]);
799 let fit = effective_fit(canvas_dp, world_size, &cam);
800 let dp = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
801 let back = dp_to_world(dp, world_size, cam.effective_center(), fit);
802 assert!((back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4);
803 cam.offset = Vec2::ZERO;
804 let dp2 = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
805 assert!((dp[0] - dp2[0] + 10.0).abs() < 1e-4);
807 assert!((dp[1] - dp2[1] - 10.0).abs() < 1e-4);
808 }
809
810 #[test]
811 fn click_slop_cancels_drags() {
812 assert!(click_within_slop_dp([100.0, 100.0], [105.0, 105.0], 1.0));
813 assert!(click_within_slop_dp([100.0, 100.0], [105.0, 105.0], 1.0));
814 assert!(!click_within_slop_dp(
815 [100.0, 100.0],
816 [100.0 + CLICK_SLOP_DP + 1.0, 100.0],
817 1.0
818 ));
819 }
820
821 #[test]
822 fn click_slop_is_density_independent() {
823 assert!(click_within_slop_dp([0.0, 0.0], [36.0, 0.0], 3.0));
825 assert!(!click_within_slop_dp([0.0, 0.0], [37.0, 0.0], 3.0));
826 assert!(click_within_slop_dp([0.0, 0.0], [12.0, 0.0], 1.0));
827 assert!(!click_within_slop_dp([0.0, 0.0], [13.0, 0.0], 1.0));
828 }
829
830 #[test]
831 fn roll_roundtrips_and_moves_pixels() {
832 let cam = Camera2d {
833 center: Vec2::new(400.0, 300.0),
834 offset: Vec2::ZERO,
835 units_per_pixel: 1.0,
836 zoom: 1.0,
837 roll: std::f32::consts::FRAC_PI_2,
838 fit: FitMode::Keep,
839 };
840 let world = [800.0, 600.0];
841 let dp = [1000.0, 600.0];
842 let fit = effective_fit(dp, world, &cam);
843 for p in [[400.0, 300.0], [500.0, 300.0], [0.0, 0.0]] {
844 let fwd = world_to_dp_with_roll(p, world, cam.effective_center(), fit, cam.roll);
845 let back = dp_to_world_with_roll(fwd, world, cam.effective_center(), fit, cam.roll);
846 assert!(
847 (back[0] - p[0]).abs() < 1e-3 && (back[1] - p[1]).abs() < 1e-3,
848 "roll roundtrip for {p:?} gave {back:?}"
849 );
850 }
851 let plain = world_to_dp([500.0, 300.0], world, cam.effective_center(), fit);
852 let rolled =
853 world_to_dp_with_roll([500.0, 300.0], world, cam.effective_center(), fit, cam.roll);
854 assert!(
855 (plain[0] - rolled[0]).abs() + (plain[1] - rolled[1]).abs() > 1.0,
856 "roll must move pixels, plain {plain:?} rolled {rolled:?}"
857 );
858 let m0 = fit_view_proj_with_roll(dp, world, cam.effective_center(), fit, 0.0);
859 let legacy = fit_view_proj(dp, world, cam.effective_center(), fit);
860 assert_eq!(m0, legacy);
861 }
862
863 #[test]
864 fn px_round_trips_through_fit() {
865 let cam = Camera2d {
866 center: Vec2::new(213.0, 120.0),
867 offset: Vec2::ZERO,
868 units_per_pixel: 1.0 / 3.0,
869 zoom: 1.0,
870 roll: 0.0,
871 fit: FitMode::Keep,
872 };
873 let world = Vec2::new(100.0, 60.0);
874 let viewport_dp = [1024.0, 600.0];
875 let world_size = [1024.0, 600.0];
876 let (s, ox, oy) = effective_fit(viewport_dp, world_size, &cam);
877 let [dx, dy] = world_to_dp(
878 [world.x, world.y],
879 world_size,
880 cam.effective_center(),
881 (s, ox, oy),
882 );
883 let px = Vec2::new(dx * 1.25, dy * 1.25);
884 let back = unproject_px(px, viewport_dp, world_size, 1.25, &cam);
885 assert!(
886 (back - world).length() < 0.01,
887 "round trip, got {back:?} for {world:?}"
888 );
889 }
890}
891
892#[derive(Clone, Debug, PartialEq, Eq)]
894pub struct CursorFrame {
895 pub rgba: Vec<u8>,
896 pub size: [u16; 2],
897 pub hotspot: [u16; 2],
898 pub key: (i32, [u32; 3], u64, u32),
899}
900
901pub fn cursor_frame(
902 strip: &[u8],
903 strip_w: u32,
904 strip_h: u32,
905 frames: u32,
906 frame: i32,
907 cell: (u32, u32),
908 origin: (f32, f32),
909 tint: [f32; 4],
910 mag: u32,
911 strip_hash: u64,
912) -> Option<CursorFrame> {
913 let frames = frames.max(1) as i32;
914 let frame = frame.clamp(0, frames - 1);
915 let (cw, ch) = (cell.0.max(1), cell.1.max(1));
916 if strip_w < cw * (frame as u32 + 1) || strip_h < ch {
917 return None;
918 }
919 let mag = mag.clamp(1, 8);
920 let tint_u8 = [
921 (tint[0].clamp(0.0, 1.0) * 255.0) as u32,
922 (tint[1].clamp(0.0, 1.0) * 255.0) as u32,
923 (tint[2].clamp(0.0, 1.0) * 255.0) as u32,
924 ];
925 let key = (frame, tint_u8, strip_hash, mag);
926 let fx = (frame as u32 * cw) as usize;
927 let (dw, dh) = (cw * mag, ch * mag);
928 if dw > 2048 || dh > 2048 {
929 return None;
930 }
931 let mut px = Vec::with_capacity((dw * dh * 4) as usize);
932 for row in 0..ch {
933 let start = ((row * strip_w) as usize + fx) * 4;
934 let end = start + (cw as usize) * 4;
935 let cell = strip.get(start..end)?;
936 let mut mag_row = Vec::with_capacity((cw * mag * 4) as usize);
937 for pix in cell.chunks_exact(4) {
938 let t = [
939 (pix[0] as u32 * tint_u8[0] / 255) as u8,
940 (pix[1] as u32 * tint_u8[1] / 255) as u8,
941 (pix[2] as u32 * tint_u8[2] / 255) as u8,
942 pix[3],
943 ];
944 for _ in 0..mag {
945 mag_row.extend_from_slice(&t);
946 }
947 }
948 for _ in 0..mag {
949 px.extend_from_slice(&mag_row);
950 }
951 }
952 let (w16, h16) = (dw.min(2048) as u16, dh.min(2048) as u16);
953 Some(CursorFrame {
954 rgba: px,
955 size: [w16, h16],
956 hotspot: [
957 (origin.0 * mag as f32)
958 .round()
959 .clamp(0.0, w16.saturating_sub(1) as f32) as u16,
960 (origin.1 * mag as f32)
961 .round()
962 .clamp(0.0, h16.saturating_sub(1) as f32) as u16,
963 ],
964 key,
965 })
966}
967
968pub fn unproject_px(
970 px: Vec2,
971 viewport_dp: [f32; 2],
972 world_size: [f32; 2],
973 density: f32,
974 cam: &Camera2d,
975) -> Vec2 {
976 let d = if density.is_finite() && density > 1e-6 {
977 density
978 } else {
979 1.0
980 };
981 let dp = [px.x / d, px.y / d];
982 cam.dp_to_world_pt(viewport_dp, world_size, dp)
983}