1use std::cell::Cell;
11use std::rc::Rc;
12use std::sync::{Arc, Mutex};
13
14use glam::{Mat4, Vec2, Vec3};
15use repose_canvas::{Canvas, DrawScope, Embedded};
16use repose_core::locals::effective_density_scale;
17use repose_core::{Color, Dp, Modifier, Px, Rect, View};
18use repose_render_wgpu::{Callback, CallbackResources, ScreenDescriptor, WgpuCallback};
19use repose_ui::Box as UiBox;
20use repose_ui::ViewExt;
21
22pub mod batch;
23pub use batch::{
24 AtlasUpload, BatchDesc, SpriteBatch, TextureFilter, draw_batch, draw_batch_with_id, frame_uv,
25 instance_rows, screen_camera, sprite_aabb,
26};
27
28pub mod fullscreen;
29pub use fullscreen::{FullscreenDesc, FullscreenPass, FullscreenTexture};
30
31pub mod post;
32
33#[derive(Clone, Copy, Debug)]
37pub struct Camera2d {
38 pub center: Vec2,
41 pub offset: Vec2,
44 pub units_per_pixel: f32,
48 pub zoom: f32,
51 pub roll: f32,
54}
55
56impl Default for Camera2d {
57 fn default() -> Self {
58 Self {
59 center: Vec2::ZERO,
60 offset: Vec2::ZERO,
61 units_per_pixel: 1.0,
62 zoom: 1.0,
63 roll: 0.0,
64 }
65 }
66}
67
68impl Camera2d {
71 pub fn effective_center(&self) -> [f32; 2] {
74 [self.center.x + self.offset.x, self.center.y + self.offset.y]
75 }
76
77 pub fn fit_matrix(&self, canvas_dp: [f32; 2], world_size: [f32; 2]) -> Mat4 {
80 let fit = effective_fit(canvas_dp, world_size, self);
81 fit_view_proj_with_roll(
82 canvas_dp,
83 world_size,
84 self.effective_center(),
85 fit,
86 self.roll,
87 )
88 }
89
90 pub fn dp_to_world_pt(&self, canvas_dp: [f32; 2], world_size: [f32; 2], dp: [f32; 2]) -> Vec2 {
93 let fit = effective_fit(canvas_dp, world_size, self);
94 let w = dp_to_world_with_roll(dp, world_size, self.effective_center(), fit, self.roll);
95 Vec2::new(w[0], w[1])
96 }
97
98 pub fn view_proj_screen(&self, viewport_px: [f32; 2]) -> Mat4 {
101 let vp_w = viewport_px[0].max(1.0);
102 let vp_h = viewport_px[1].max(1.0);
103 let zoom = sane_positive(self.zoom);
104 let upp = sane_positive(self.units_per_pixel);
105 let w = vp_w * upp / zoom;
106 let h = vp_h * upp / zoom;
107 let proj = glam::camera::rh::proj::directx::orthographic(
108 -w / 2.0,
109 w / 2.0,
110 h / 2.0,
111 -h / 2.0,
112 -1000.0,
113 1000.0,
114 );
115 let view = Mat4::from_translation(Vec3::new(
116 -(self.center.x + self.offset.x),
117 -(self.center.y + self.offset.y),
118 0.0,
119 ));
120 proj * view
121 }
122
123 #[deprecated(
128 since = "0.1.8",
129 note = "ignores world_size; use fit_view_proj for the shared canvas/GPU/pick framing contract"
130 )]
131 pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
132 self.view_proj_screen(viewport_px)
133 }
134
135 #[deprecated(
139 since = "0.1.8",
140 note = "ignores world_size; use pick_world over the painted FrameGeom instead"
141 )]
142 #[allow(deprecated)]
143 pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
144 if viewport_px[0] <= 0.0 || viewport_px[1] <= 0.0 {
145 let [cx, cy] = self.effective_center();
146 return Vec2::new(cx, cy);
147 }
148 let ndc = Vec2::new(
149 (px[0] / viewport_px[0]) * 2.0 - 1.0,
150 1.0 - (px[1] / viewport_px[1]) * 2.0,
151 );
152 let inv = self.view_proj(viewport_px).inverse();
153 let world = inv.project_point3(ndc.extend(0.0));
154 Vec2::new(world.x, world.y)
155 }
156}
157
158#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
162pub enum SpriteBlend {
163 #[default]
164 Alpha,
165 Additive,
166 Multiply,
167}
168
169#[derive(Clone, Copy, Debug)]
171pub struct SpriteInstance {
172 pub center: Vec2,
174 pub rotation: f32,
175 pub size: Vec2,
176 pub anchor: Vec2,
179 pub flip_x: bool,
182 pub flip_y: bool,
183 pub uv_min: Vec2,
184 pub uv_max: Vec2,
185 pub color: [f32; 4],
187 pub page: u32,
189 pub z: f32,
194 pub blend: SpriteBlend,
195}
196
197impl Default for SpriteInstance {
198 fn default() -> Self {
199 Self {
200 center: Vec2::ZERO,
201 rotation: 0.0,
202 size: Vec2::ONE,
203 anchor: Vec2::new(0.5, 0.5),
204 flip_x: false,
205 flip_y: false,
206 uv_min: Vec2::ZERO,
207 uv_max: Vec2::ONE,
208 color: [1.0, 1.0, 1.0, 1.0],
209 page: 0,
210 z: 0.0,
211 blend: SpriteBlend::Alpha,
212 }
213 }
214}
215
216#[derive(Clone, Debug, Default)]
218pub struct WorldText {
219 pub text: String,
220 pub pos: Vec2,
222 pub color: [f32; 4],
224 pub size: f32,
226}
227
228#[derive(Clone, Default, Debug)]
233pub struct FrameInput {
234 pub cam: Camera2d,
235 pub world_size: [f32; 2],
237 pub viewport_dp: [f32; 2],
241 pub sprites: Vec<SpriteInstance>,
242 pub texts: Vec<WorldText>,
245 pub background: Option<[f32; 4]>,
247 pub overlay_color: Option<[f32; 4]>,
250 pub chroma: f32,
254}
255
256#[derive(Clone, Debug)]
260pub enum PickEvent {
261 Press {
262 world: Vec2,
263 screen: [f32; 2],
264 button: repose_core::input::PointerButton,
265 },
266 Click {
267 world: Vec2,
268 screen: [f32; 2],
269 button: repose_core::input::PointerButton,
270 },
271 Hover {
272 world: Vec2,
273 screen: [f32; 2],
279 },
280 TouchDown {
283 id: u64,
284 screen: [f32; 2],
285 },
286 TouchMove {
288 id: u64,
289 screen: [f32; 2],
290 },
291 TouchUp {
293 id: u64,
294 },
295}
296
297fn is_touch(ev: &repose_core::input::PointerEvent) -> bool {
303 matches!(
304 ev.kind,
305 repose_core::input::PointerKind::Touch | repose_core::input::PointerKind::Pen
306 )
307}
308
309fn screen_of(ev: &repose_core::input::PointerEvent) -> [f32; 2] {
311 let p = ev.position_in_window();
312 [p.x, p.y]
313}
314
315fn button_of(ev: &repose_core::input::PointerEvent) -> repose_core::input::PointerButton {
318 match ev.event {
319 repose_core::input::PointerEventKind::Down(b)
320 | repose_core::input::PointerEventKind::Up(b) => b,
321 _ => repose_core::input::PointerButton::Primary,
322 }
323}
324
325fn sane_positive(v: f32) -> f32 {
328 if v.is_finite() && v > 1e-6 { v } else { 1.0 }
329}
330
331pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
335 if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
336 return (1.0, 0.0, 0.0);
337 }
338 let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
339 if !s.is_finite() || s <= 1e-6 {
340 return (1.0, 0.0, 0.0);
341 }
342 (
343 s,
344 (canvas_dp[0] - world[0] * s) * 0.5,
345 (canvas_dp[1] - world[1] * s) * 0.5,
346 )
347}
348
349pub fn effective_fit(canvas_dp: [f32; 2], world: [f32; 2], cam: &Camera2d) -> (f32, f32, f32) {
353 let (base, _, _) = contain_fit(canvas_dp, world);
354 let zoom = sane_positive(cam.zoom);
355 let upp = sane_positive(cam.units_per_pixel);
356 let s = base * zoom / upp;
357 if !s.is_finite() || s <= 1e-6 {
358 return (1.0, 0.0, 0.0);
359 }
360 (
361 s,
362 (canvas_dp[0] - world[0] * s) * 0.5,
363 (canvas_dp[1] - world[1] * s) * 0.5,
364 )
365}
366
367pub fn fit_view_proj(
370 canvas_dp: [f32; 2],
371 world_size: [f32; 2],
372 cam_center: [f32; 2],
373 fit: (f32, f32, f32),
374) -> Mat4 {
375 fit_view_proj_with_roll(canvas_dp, world_size, cam_center, fit, 0.0)
376}
377
378pub fn fit_view_proj_with_roll(
381 canvas_dp: [f32; 2],
382 world_size: [f32; 2],
383 cam_center: [f32; 2],
384 fit: (f32, f32, f32),
385 roll: f32,
386) -> Mat4 {
387 let w = canvas_dp[0].max(1.0);
388 let h = canvas_dp[1].max(1.0);
389 let (s, ox, oy) = fit;
390 let s = if s.is_finite() && s > 1e-6 { s } else { 1.0 };
391 let lx = cam_center[0] - world_size[0] * 0.5;
392 let ly = cam_center[1] - world_size[1] * 0.5;
393 let sx = 2.0 * s / w;
394 let sy = -2.0 * s / h;
395 let tx = 2.0 * (ox - lx * s) / w - 1.0;
396 let ty = 1.0 - 2.0 * (oy - ly * s) / h;
397 let base = Mat4::from_cols(
398 glam::Vec4::new(sx, 0.0, 0.0, 0.0),
399 glam::Vec4::new(0.0, sy, 0.0, 0.0),
400 glam::Vec4::new(0.0, 0.0, 0.001, 0.0),
401 glam::Vec4::new(tx, ty, 0.0, 1.0),
402 );
403 if !roll.is_finite() || roll.abs() < 1e-7 {
404 return base;
405 }
406 let (c, r) = (roll.cos(), roll.sin());
407 let rot = Mat4::from_cols(
408 glam::Vec4::new(c, r, 0.0, 0.0),
409 glam::Vec4::new(-r, c, 0.0, 0.0),
410 glam::Vec4::new(0.0, 0.0, 1.0, 0.0),
411 glam::Vec4::new(
412 cam_center[0] - c * cam_center[0] + r * cam_center[1],
413 cam_center[1] - r * cam_center[0] - c * cam_center[1],
414 0.0,
415 1.0,
416 ),
417 );
418 base * rot
419}
420
421pub fn world_to_dp(
424 world: [f32; 2],
425 world_size: [f32; 2],
426 cam_center: [f32; 2],
427 fit: (f32, f32, f32),
428) -> [f32; 2] {
429 world_to_dp_with_roll(world, world_size, cam_center, fit, 0.0)
430}
431
432pub fn world_to_dp_with_roll(
434 world: [f32; 2],
435 world_size: [f32; 2],
436 cam_center: [f32; 2],
437 fit: (f32, f32, f32),
438 roll: f32,
439) -> [f32; 2] {
440 let (s, ox, oy) = fit;
441 let [wx, wy] = rotate_about(world, cam_center, roll);
442 [
443 ox + (wx - (cam_center[0] - world_size[0] * 0.5)) * s,
444 oy + (wy - (cam_center[1] - world_size[1] * 0.5)) * s,
445 ]
446}
447
448pub fn dp_to_world(
450 dp: [f32; 2],
451 world_size: [f32; 2],
452 cam_center: [f32; 2],
453 fit: (f32, f32, f32),
454) -> [f32; 2] {
455 dp_to_world_with_roll(dp, world_size, cam_center, fit, 0.0)
456}
457
458pub fn dp_to_world_with_roll(
460 dp: [f32; 2],
461 world_size: [f32; 2],
462 cam_center: [f32; 2],
463 fit: (f32, f32, f32),
464 roll: f32,
465) -> [f32; 2] {
466 let (s, ox, oy) = fit;
467 if !s.is_finite() || s.abs() < 1e-6 {
468 return cam_center;
469 }
470 let base = [
471 (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
472 (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
473 ];
474 rotate_about(base, cam_center, -roll)
475}
476
477fn rotate_about(p: [f32; 2], center: [f32; 2], roll: f32) -> [f32; 2] {
478 if !roll.is_finite() || roll.abs() < 1e-7 {
479 return p;
480 }
481 let (c, r) = (roll.cos(), roll.sin());
482 let dx = p[0] - center[0];
483 let dy = p[1] - center[1];
484 [center[0] + c * dx - r * dy, center[1] + r * dx + c * dy]
485}
486
487pub fn pick_world(local_px: [f32; 2], geom: FrameGeom, world_size: [f32; 2]) -> [f32; 2] {
498 let d = if geom.density.is_finite() && geom.density > 1e-6 {
499 geom.density
500 } else {
501 1.0
502 };
503 dp_to_world_with_roll(
504 [local_px[0] / d, local_px[1] / d],
505 world_size,
506 geom.pivot,
507 geom.fit,
508 geom.roll,
509 )
510}
511
512pub fn aim_world(
518 local_px: [f32; 2],
519 look_unshaken: [f32; 2],
520 roll_unshaken: f32,
521 fit: (f32, f32, f32),
522 density: f32,
523 world_size: [f32; 2],
524) -> [f32; 2] {
525 let d = if density.is_finite() && density > 1e-6 {
526 density
527 } else {
528 1.0
529 };
530 dp_to_world_with_roll(
531 [local_px[0] / d, local_px[1] / d],
532 world_size,
533 look_unshaken,
534 fit,
535 roll_unshaken,
536 )
537}
538
539pub fn aim_world_unshaken(
544 local_px: [f32; 2],
545 cam: &Camera2d,
546 canvas_dp: [f32; 2],
547 world_size: [f32; 2],
548 density: f32,
549) -> [f32; 2] {
550 let fit = effective_fit(canvas_dp, world_size, cam);
551 aim_world(
552 local_px,
553 [cam.center.x, cam.center.y],
554 0.0,
555 fit,
556 density,
557 world_size,
558 )
559}
560
561#[derive(Clone, Copy, Debug)]
571pub struct FrameGeom {
572 pub fit: (f32, f32, f32),
575 pub look: [f32; 2],
578 pub density: f32,
580 pub viewport_px: [f32; 2],
582 pub roll: f32,
584 pub pivot: [f32; 2],
587}
588
589impl Default for FrameGeom {
590 fn default() -> Self {
591 Self {
592 fit: (1.0, 0.0, 0.0),
593 look: [0.0, 0.0],
594 density: 1.0,
595 viewport_px: [1.0, 1.0],
596 roll: 0.0,
597 pivot: [0.0, 0.0],
598 }
599 }
600}
601
602#[derive(Clone, Default)]
605pub struct GeomHandle(Arc<Mutex<FrameGeom>>);
606
607impl GeomHandle {
608 pub fn new() -> Self {
609 Self(Arc::new(Mutex::new(FrameGeom::default())))
610 }
611
612 pub fn get(&self) -> FrameGeom {
613 self.0.lock().map(|g| *g).unwrap_or_default()
614 }
615
616 pub fn set(&self, g: FrameGeom) {
617 if let Ok(mut slot) = self.0.lock() {
618 *slot = g;
619 }
620 }
621
622 pub fn arc(&self) -> Arc<Mutex<FrameGeom>> {
623 self.0.clone()
624 }
625}
626
627impl From<FrameGeom> for GeomHandle {
628 fn from(g: FrameGeom) -> Self {
629 Self(Arc::new(Mutex::new(g)))
630 }
631}
632
633impl From<std::sync::Arc<std::sync::Mutex<FrameGeom>>> for GeomHandle {
634 fn from(arc: std::sync::Arc<std::sync::Mutex<FrameGeom>>) -> Self {
636 Self(arc)
637 }
638}
639
640impl From<std::rc::Rc<std::cell::Cell<FrameGeom>>> for GeomHandle {
641 fn from(rc: std::rc::Rc<std::cell::Cell<FrameGeom>>) -> Self {
644 Self(Arc::new(Mutex::new(rc.get())))
645 }
646}
647
648pub const CLICK_SLOP_DP: f32 = 12.0;
653pub const CLICK_SLOP_PX: f32 = CLICK_SLOP_DP;
656
657#[allow(dead_code)]
660fn click_within_slop(a: [f32; 2], b: [f32; 2]) -> bool {
661 click_within_slop_dp(a, b, 1.0)
662}
663
664pub fn click_within_slop_dp(a: [f32; 2], b: [f32; 2], density: f32) -> bool {
667 let d = if density.is_finite() && density > 1e-6 {
668 density
669 } else {
670 1.0
671 };
672 let dx = (a[0] - b[0]) / d;
673 let dy = (a[1] - b[1]) / d;
674 dx * dx + dy * dy <= CLICK_SLOP_DP * CLICK_SLOP_DP
675}
676
677pub fn surface_dp(center: [f32; 2], size: [f32; 2], geom: FrameGeom) -> ([f32; 2], [f32; 2]) {
681 let (s, ox, oy) = geom.fit;
682 let r = rotate_about(center, geom.pivot, geom.roll);
683 (
684 [
685 ox + (r[0] - size[0] * 0.5 - geom.look[0]) * s,
686 oy + (r[1] - size[1] * 0.5 - geom.look[1]) * s,
687 ],
688 [size[0] * s, size[1] * s],
689 )
690}
691
692fn rgba8(c: [f32; 4]) -> Color {
693 Color::from_rgba(
694 (c[0].clamp(0.0, 1.0) * 255.0) as u8,
695 (c[1].clamp(0.0, 1.0) * 255.0) as u8,
696 (c[2].clamp(0.0, 1.0) * 255.0) as u8,
697 (c[3].clamp(0.0, 1.0) * 255.0) as u8,
698 )
699}
700
701#[allow(non_snake_case)] pub fn Viewport2d(
707 input: FrameInput,
708 geom_out: GeomHandle,
709 on_event: impl Fn(PickEvent) + 'static,
710) -> View {
711 let input = Rc::new(input);
712 let world_size = input.world_size;
713 let pick_geom = geom_out.clone();
714 let move_geom = geom_out.clone();
715 let release_geom = geom_out.clone();
716 let draw_input = input.clone();
717 let draw_geom = geom_out.clone();
718 let on_event = Rc::new(on_event);
719 let on_down = on_event.clone();
720 let on_touch_down = on_event.clone();
721 let on_move = on_event.clone();
722 let on_touch_move = on_event.clone();
723 let on_up = on_event.clone();
724 let on_up_click = on_event.clone();
725 let on_leave = on_event;
726 let press: Rc<Cell<Option<[f32; 2]>>> = Rc::new(Cell::new(None));
727 let press_down = press.clone();
728 let press_up = press.clone();
729 let press_leave = press.clone();
730
731 let modifier = Modifier::new()
732 .fill_max_size()
733 .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
734 let p = ev.position;
735 let g = pick_geom.get();
736 let world = pick_world([p.x, p.y], g, world_size);
737 press_down.set(Some([p.x, p.y]));
738 let w = ev.position_in_window();
739 let button = button_of(&ev);
740 on_down(PickEvent::Press {
741 world: Vec2::new(world[0], world[1]),
742 screen: [w.x, w.y],
743 button,
744 });
745 if is_touch(&ev) {
746 on_touch_down(PickEvent::TouchDown {
747 id: ev.id.0,
748 screen: screen_of(&ev),
749 });
750 }
751 })
752 .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
753 let p = ev.position;
754 let g = move_geom.get();
755 let world = pick_world([p.x, p.y], g, world_size);
756 let w = ev.position_in_window();
757 on_move(PickEvent::Hover {
758 world: Vec2::new(world[0], world[1]),
759 screen: [w.x, w.y],
760 });
761 if is_touch(&ev) {
762 on_touch_move(PickEvent::TouchMove {
763 id: ev.id.0,
764 screen: screen_of(&ev),
765 });
766 }
767 })
768 .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
769 let p = ev.position;
770 if let Some(start) = press_up.take()
771 && click_within_slop_dp(start, [p.x, p.y], release_geom.get().density)
772 {
773 let g = release_geom.get();
774 let world = pick_world([p.x, p.y], g, world_size);
775 let w = ev.position_in_window();
776 let button = button_of(&ev);
777 on_up_click(PickEvent::Click {
778 world: Vec2::new(world[0], world[1]),
779 screen: [w.x, w.y],
780 button,
781 });
782 }
783 if is_touch(&ev) {
784 on_up(PickEvent::TouchUp { id: ev.id.0 });
785 }
786 })
787 .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
788 press_leave.set(None);
789 if is_touch(&ev) {
790 on_leave(PickEvent::TouchUp { id: ev.id.0 });
791 }
792 });
793 Canvas(modifier, move |scope: &mut DrawScope| {
794 let d = effective_density_scale();
795 let cam = draw_input.cam;
796 let cam_center = cam.effective_center();
797 let fit = effective_fit(
798 [scope.size.width / d, scope.size.height / d],
799 draw_input.world_size,
800 &cam,
801 );
802 draw_geom.set(FrameGeom {
803 fit,
804 look: [
805 cam_center[0] - draw_input.world_size[0] * 0.5,
806 cam_center[1] - draw_input.world_size[1] * 0.5,
807 ],
808 density: d,
809 viewport_px: [scope.size.width, scope.size.height],
810 roll: cam.roll,
811 pivot: cam_center,
812 });
813 let project = |wx: f32, wy: f32| -> [f32; 2] {
814 let [dx, dy] = world_to_dp_with_roll(
815 [wx, wy],
816 draw_input.world_size,
817 cam_center,
818 (fit.0, fit.1, fit.2),
819 cam.roll,
820 );
821 [dx * d, dy * d]
822 };
823 if let Some(bg) = draw_input.background {
824 scope.draw_rect(
825 Rect {
826 x: 0.0,
827 y: 0.0,
828 w: scope.size.width,
829 h: scope.size.height,
830 },
831 rgba8(bg),
832 Px(0.0),
833 );
834 }
835 for spr in draw_input.sprites.iter() {
836 let px_per_world = fit.0 * d;
841 let [px, py] = project(spr.center.x, spr.center.y);
842 let ax = if spr.flip_x {
843 1.0 - spr.anchor.x
844 } else {
845 spr.anchor.x
846 };
847 let ay = if spr.flip_y {
848 1.0 - spr.anchor.y
849 } else {
850 spr.anchor.y
851 };
852 scope.draw_rect_rotated(
853 Rect {
854 x: px - ax * spr.size.x * px_per_world,
855 y: py - ay * spr.size.y * px_per_world,
856 w: (spr.size.x * px_per_world).max(0.0),
857 h: (spr.size.y * px_per_world).max(0.0),
858 },
859 rgba8(spr.color),
860 Px(0.0),
861 cam.roll + spr.rotation,
862 repose_core::Vec2 { x: px, y: py },
863 );
864 }
865 for t in draw_input.texts.iter() {
866 let [tx, ty] = project(t.pos.x, t.pos.y);
867 scope.draw_text(
868 t.text.clone(),
869 repose_core::Vec2 { x: tx, y: ty },
870 rgba8(t.color),
871 Px(t.size * fit.0 * d),
872 );
873 }
874 if let Some(tint) = draw_input.overlay_color {
875 scope.draw_rect(
876 Rect {
877 x: 0.0,
878 y: 0.0,
879 w: scope.size.width,
880 h: scope.size.height,
881 },
882 rgba8(tint),
883 Px(0.0),
884 );
885 }
886 })
887}
888
889#[allow(non_snake_case)]
899pub fn Viewport2dGpu(
900 input: FrameInput,
901 geom_out: GeomHandle,
902 uploads: Vec<AtlasUpload>,
903 desc: BatchDesc,
904 on_event: impl Fn(PickEvent) + 'static,
905) -> View {
906 Viewport2dGpuWithId(input, geom_out, uploads, desc, "viewport2d.main", on_event)
907}
908
909#[allow(non_snake_case)]
912pub fn Viewport2dGpuWithId(
913 input: FrameInput,
914 geom_out: GeomHandle,
915 uploads: Vec<AtlasUpload>,
916 desc: BatchDesc,
917 batch_id: impl Into<String>,
918 on_event: impl Fn(PickEvent) + 'static,
919) -> View {
920 let batch_id: String = batch_id.into();
921 let bg_id = format!("{batch_id}.background");
922 let overlay_id = format!("{batch_id}.overlay");
923 let input = Arc::new(input);
924 let world_size = input.world_size;
925 let pick_geom = geom_out.clone();
926 let move_geom = geom_out.clone();
927 let release_geom = geom_out.clone();
928 let size_geom = geom_out.clone();
929 let size_input = input.clone();
930 let geom_for_payload = geom_out.clone();
931 let on_event = Arc::new(on_event);
932 let on_down = on_event.clone();
933 let on_touch_down = on_event.clone();
934 let on_move = on_event.clone();
935 let on_touch_move = on_event.clone();
936 let on_up = on_event.clone();
937 let on_up_click = on_event.clone();
938 let on_leave = on_event;
939 let press: Arc<Mutex<Option<[f32; 2]>>> = Arc::new(Mutex::new(None));
940 let press_down = press.clone();
941 let press_up = press.clone();
942 let press_leave = press.clone();
943 let payload = GpuViewport {
944 input: input.clone(),
945 geom: geom_for_payload.arc(),
946 batch_id,
947 uploads: Arc::from(uploads.into_boxed_slice()),
948 desc,
949 bg: FullscreenPass::new(
950 bg_id,
951 fullscreen::SOLID_WGSL,
952 FullscreenDesc {
953 texture_slots: 0,
954 filter: TextureFilter::Nearest,
955 },
956 ),
957 overlay: FullscreenPass::new(
958 overlay_id,
959 fullscreen::SOLID_WGSL,
960 FullscreenDesc {
961 texture_slots: 0,
962 filter: TextureFilter::Nearest,
963 },
964 ),
965 };
966 let modifier = Modifier::new()
967 .fill_max_size()
968 .on_size_changed(move |dp: repose_core::Vec2| {
969 let d = effective_density_scale();
970 let d = if d.is_finite() && d > 1e-6 { d } else { 1.0 };
971 let fit = effective_fit([dp.x, dp.y], size_input.world_size, &size_input.cam);
972 let center = size_input.cam.effective_center();
973 size_geom.set(FrameGeom {
974 viewport_px: [dp.x * d, dp.y * d],
975 density: d,
976 fit,
977 look: [
978 center[0] - size_input.world_size[0] * 0.5,
979 center[1] - size_input.world_size[1] * 0.5,
980 ],
981 roll: size_input.cam.roll,
982 pivot: center,
983 });
984 })
985 .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
986 let p = ev.position;
987 let g = pick_geom.get();
988 let world = pick_world([p.x, p.y], g, world_size);
989 if let Ok(mut slot) = press_down.lock() {
990 *slot = Some([p.x, p.y]);
991 }
992 let w = ev.position_in_window();
993 let button = button_of(&ev);
994 on_down(PickEvent::Press {
995 world: Vec2::new(world[0], world[1]),
996 screen: [w.x, w.y],
997 button,
998 });
999 if is_touch(&ev) {
1000 on_touch_down(PickEvent::TouchDown {
1001 id: ev.id.0,
1002 screen: screen_of(&ev),
1003 });
1004 }
1005 })
1006 .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
1007 let p = ev.position;
1008 let g = move_geom.get();
1009 let world = pick_world([p.x, p.y], g, world_size);
1010 let w = ev.position_in_window();
1011 on_move(PickEvent::Hover {
1012 world: Vec2::new(world[0], world[1]),
1013 screen: [w.x, w.y],
1014 });
1015 if is_touch(&ev) {
1016 on_touch_move(PickEvent::TouchMove {
1017 id: ev.id.0,
1018 screen: screen_of(&ev),
1019 });
1020 }
1021 })
1022 .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
1023 let p = ev.position;
1024 let start = press_up.lock().ok().and_then(|mut s| s.take());
1025 if let Some(start) = start
1026 && click_within_slop_dp(start, [p.x, p.y], release_geom.get().density)
1027 {
1028 let g = release_geom.get();
1029 let world = pick_world([p.x, p.y], g, world_size);
1030 let w = ev.position_in_window();
1031 let button = button_of(&ev);
1032 on_up_click(PickEvent::Click {
1033 world: Vec2::new(world[0], world[1]),
1034 screen: [w.x, w.y],
1035 button,
1036 });
1037 }
1038 if is_touch(&ev) {
1039 on_up(PickEvent::TouchUp { id: ev.id.0 });
1040 }
1041 })
1042 .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
1043 if let Ok(mut s) = press_leave.lock() {
1044 *s = None;
1045 }
1046 if is_touch(&ev) {
1047 on_leave(PickEvent::TouchUp { id: ev.id.0 });
1048 }
1049 });
1050 Embedded(modifier, Callback::new(payload))
1051}
1052
1053struct GpuViewport {
1054 input: Arc<FrameInput>,
1055 geom: Arc<Mutex<FrameGeom>>,
1056 batch_id: String,
1057 uploads: Arc<[AtlasUpload]>,
1058 desc: BatchDesc,
1059 bg: FullscreenPass,
1060 overlay: FullscreenPass,
1061}
1062
1063impl WgpuCallback for GpuViewport {
1064 fn prepare(
1065 &self,
1066 device: &wgpu::Device,
1067 queue: &wgpu::Queue,
1068 encoder: &mut wgpu::CommandEncoder,
1069 screen: &ScreenDescriptor,
1070 resources: &mut CallbackResources,
1071 ) -> Vec<wgpu::CommandBuffer> {
1072 let (vp_phys, density) = self
1073 .geom
1074 .lock()
1075 .map(|g| {
1076 let d = if g.density.is_finite() && g.density > 1e-6 {
1077 g.density
1078 } else {
1079 1.0
1080 };
1081 (g.viewport_px, d)
1082 })
1083 .unwrap_or(([1.0, 1.0], 1.0));
1084 let dp = if vp_phys[0] > 1.0 && vp_phys[1] > 1.0 {
1085 [vp_phys[0] / density, vp_phys[1] / density]
1086 } else {
1087 self.input.viewport_dp
1088 };
1089 let mut batch = SpriteBatch::with_id(self.batch_id.clone(), self.desc);
1090 batch.set_camera(self.input.cam.fit_matrix(dp, self.input.world_size));
1091 for s in &self.input.sprites {
1092 batch.push_sprite(s);
1093 }
1094 batch.extend_uploads(self.uploads.iter().cloned());
1095 batch.prepare(device, queue, encoder, screen, resources);
1096 let composite = post::use_composite(self.input.chroma);
1097 if let Some(bg) = self.input.background
1099 && !composite
1100 {
1101 let words = [bg[0], bg[1], bg[2], bg[3]];
1102 self.bg.prepare_with(
1103 device,
1104 queue,
1105 screen,
1106 resources,
1107 bytemuck::cast_slice(&words),
1108 &[],
1109 );
1110 }
1111 if let Some(ov) = self.input.overlay_color {
1112 let words = [ov[0], ov[1], ov[2], ov[3]];
1113 self.overlay.prepare_with(
1114 device,
1115 queue,
1116 screen,
1117 resources,
1118 bytemuck::cast_slice(&words),
1119 &[],
1120 );
1121 }
1122 if post::use_composite(self.input.chroma) {
1123 let w = vp_phys[0].max(1.0) as u32;
1124 let h = vp_phys[1].max(1.0) as u32;
1125 post::prepare_composite(
1126 device,
1127 queue,
1128 encoder,
1129 screen,
1130 resources,
1131 self.batch_id.as_str(),
1132 w,
1133 h,
1134 self.input.chroma,
1135 self.input.background,
1136 );
1137 }
1138 Vec::new()
1139 }
1140
1141 fn paint(
1142 &self,
1143 info: repose_core::PaintCallbackInfo,
1144 rpass: &mut wgpu::RenderPass<'static>,
1145 resources: &CallbackResources,
1146 ) {
1147 let d = if info.pixels_per_point.is_finite() && info.pixels_per_point > 1e-6 {
1148 info.pixels_per_point
1149 } else {
1150 1.0
1151 };
1152 let vp = [info.viewport.w, info.viewport.h];
1153 let fit = effective_fit(
1154 [vp[0] / d, vp[1] / d],
1155 self.input.world_size,
1156 &self.input.cam,
1157 );
1158 let cam = self.input.cam.effective_center();
1159 let roll = self.input.cam.roll;
1160 if let Ok(mut g) = self.geom.lock() {
1161 *g = FrameGeom {
1162 fit,
1163 look: [
1164 cam[0] - self.input.world_size[0] * 0.5,
1165 cam[1] - self.input.world_size[1] * 0.5,
1166 ],
1167 density: d,
1168 viewport_px: vp,
1169 roll,
1170 pivot: cam,
1171 };
1172 }
1173 if post::use_composite(self.input.chroma) {
1174 post::paint_composite(self.batch_id.as_str(), rpass, resources);
1175 } else {
1176 if self.input.background.is_some() {
1177 self.bg.paint(info, rpass, resources);
1178 }
1179 draw_batch_with_id(self.batch_id.as_str(), rpass, resources);
1180 }
1181 if self.input.overlay_color.is_some() {
1182 self.overlay.paint(info, rpass, resources);
1183 }
1184 }
1185}
1186
1187#[allow(non_snake_case)]
1191pub fn Viewport2dGpuWithHud(
1192 input: FrameInput,
1193 geom: GeomHandle,
1194 uploads: Vec<AtlasUpload>,
1195 desc: BatchDesc,
1196 on_event: impl Fn(PickEvent) + 'static,
1197) -> View {
1198 use repose_ui::ViewExt as _;
1199 let texts = input.texts.clone();
1200 let overlay = input.overlay_color;
1201 let cam = input.cam;
1202 let world_size = input.world_size;
1203 let hud_input = std::rc::Rc::new((cam, world_size, texts, overlay));
1204 let hud_geom = geom.clone();
1205 let hud = Canvas(
1206 Modifier::new().fill_max_size().hit_passthrough(),
1207 move |scope: &mut DrawScope| {
1208 let (cam, world_size, texts, overlay) =
1209 (hud_input.0, hud_input.1, &hud_input.2, hud_input.3);
1210 let d = effective_density_scale();
1211 let cam_center = cam.effective_center();
1212 let fit = effective_fit(
1213 [scope.size.width / d, scope.size.height / d],
1214 world_size,
1215 &cam,
1216 );
1217 hud_geom.set(FrameGeom {
1218 fit,
1219 look: [
1220 cam_center[0] - world_size[0] * 0.5,
1221 cam_center[1] - world_size[1] * 0.5,
1222 ],
1223 density: d,
1224 viewport_px: [scope.size.width, scope.size.height],
1225 roll: cam.roll,
1226 pivot: cam_center,
1227 });
1228 let project = |wx: f32, wy: f32| -> [f32; 2] {
1229 let [dx, dy] = world_to_dp_with_roll(
1230 [wx, wy],
1231 world_size,
1232 cam_center,
1233 (fit.0, fit.1, fit.2),
1234 cam.roll,
1235 );
1236 [dx * d, dy * d]
1237 };
1238 for t in texts.iter() {
1239 let [tx, ty] = project(t.pos.x, t.pos.y);
1240 scope.draw_text(
1241 t.text.clone(),
1242 repose_core::Vec2 { x: tx, y: ty },
1243 rgba8(t.color),
1244 Px(t.size * fit.0 * d),
1245 );
1246 }
1247 if let Some(tint) = overlay {
1248 scope.draw_rect(
1249 Rect {
1250 x: 0.0,
1251 y: 0.0,
1252 w: scope.size.width,
1253 h: scope.size.height,
1254 },
1255 rgba8(tint),
1256 Px(0.0),
1257 );
1258 }
1259 },
1260 );
1261 repose_ui::ZStack(Modifier::new().fill_max_size())
1262 .child(Viewport2dGpu(input, geom, uploads, desc, on_event))
1263 .child(hud)
1264}
1265#[allow(non_snake_case)]
1273pub fn ActorFrame(
1274 center: [f32; 2],
1275 size: [f32; 2],
1276 geom: GeomHandle,
1277 mirror_x: bool,
1278 child: View,
1279) -> View {
1280 ActorFrameWithOrigin(center, size, geom, mirror_x, [0.5, 0.5], child)
1281}
1282
1283#[allow(non_snake_case)]
1289pub fn ActorFrameWithOrigin(
1290 center: [f32; 2],
1291 size: [f32; 2],
1292 geom: GeomHandle,
1293 mirror_x: bool,
1294 origin: [f32; 2],
1295 child: View,
1296) -> View {
1297 let ([ox, oy], [w, h]) = surface_dp(center, size, geom.get());
1298 let mut modifier = Modifier::new().size(Dp(w), Dp(h)).absolute().offset(
1301 Some(Dp(ox)),
1302 Some(Dp(oy)),
1303 None,
1304 None,
1305 );
1306 if mirror_x {
1307 modifier = modifier
1308 .transform_origin(origin[0].clamp(0.0, 1.0), origin[1].clamp(0.0, 1.0))
1309 .scale2(-1.0, 1.0);
1310 }
1311 UiBox(modifier).child(child)
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316 use super::*;
1317 use repose_core::locals::{Density, set_density_default};
1318
1319 #[test]
1320 fn contain_fit_roundtrip_at_density_125() {
1321 set_density_default(Density { scale: 1.25 });
1323 let d = effective_density_scale();
1324 let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
1326 let cam = [400.0, 300.0];
1327 let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
1329 let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
1331 set_density_default(Density { scale: 1.0 });
1332 assert!(
1333 (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
1334 "dp fit, got {fit:?}"
1335 );
1336 assert!(
1337 (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
1338 "draw forward, got ({dx},{dy})"
1339 );
1340 assert!(
1341 (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
1342 "pick inverse, got ({wx},{wy})"
1343 );
1344 let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
1346 let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
1347 assert!(
1348 (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
1349 "shifted round trip, got {back:?}"
1350 );
1351 }
1352
1353 #[test]
1354 fn gpu_viewport_honors_cam_and_background() {
1355 use repose_core::{Color, Rect, Scene, SceneNode};
1358 use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
1359
1360 let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
1361 Ok(r) => r,
1362 Err(e) => {
1363 eprintln!("SKIP gpu viewport test (no GPU): {e}");
1364 return;
1365 }
1366 };
1367 let page = [255, 0, 255, 255].repeat(4);
1369 let uploads = vec![AtlasUpload {
1370 page: 0,
1371 x: 0,
1372 y: 0,
1373 w: 2,
1374 h: 2,
1375 rgba: page,
1376 }];
1377 let cam = Camera2d {
1378 center: Vec2::new(64.0, 64.0),
1379 offset: Vec2::ZERO,
1380 units_per_pixel: 0.5,
1381 zoom: 1.0,
1382 roll: 0.0,
1383 };
1384 let input = FrameInput {
1385 cam,
1386 world_size: [128.0, 128.0],
1387 viewport_dp: [256.0, 256.0],
1388 sprites: vec![SpriteInstance {
1389 center: Vec2::new(64.0, 64.0),
1390 size: Vec2::new(32.0, 32.0),
1391 uv_min: Vec2::new(0.0, 0.0),
1392 uv_max: Vec2::new(1.0, 1.0),
1393 color: [1.0, 1.0, 1.0, 1.0],
1394 ..Default::default()
1395 }],
1396 texts: Vec::new(),
1397 background: Some([0.0, 0.0, 1.0, 1.0]),
1398 overlay_color: None,
1399 chroma: 0.0,
1400 };
1401 let geom = Arc::new(Mutex::new(FrameGeom {
1402 fit: (1.0, 0.0, 0.0),
1403 look: [0.0, 0.0],
1404 density: 1.0,
1405 viewport_px: [256.0, 256.0],
1406 roll: 0.0,
1407 pivot: [64.0, 64.0],
1408 }));
1409 let payload = GpuViewport {
1410 input: Arc::new(input.clone()),
1411 geom,
1412 batch_id: "test.viewport2d.main".to_string(),
1413 uploads: Arc::from(uploads.into_boxed_slice()),
1414 desc: BatchDesc {
1415 layer_size: 2,
1416 layers: 1,
1417 filter: TextureFilter::Nearest,
1418 },
1419 bg: FullscreenPass::new(
1420 "test.viewport2d.background",
1421 fullscreen::SOLID_WGSL,
1422 FullscreenDesc {
1423 texture_slots: 0,
1424 filter: TextureFilter::Nearest,
1425 },
1426 ),
1427 overlay: FullscreenPass::new(
1428 "test.viewport2d.overlay",
1429 fullscreen::SOLID_WGSL,
1430 FullscreenDesc {
1431 texture_slots: 0,
1432 filter: TextureFilter::Nearest,
1433 },
1434 ),
1435 };
1436 let scene = Scene {
1437 clear_color: Color::from_rgba(0, 0, 0, 255),
1438 nodes: vec![SceneNode::Callback {
1439 rect: Rect {
1440 x: 0.0,
1441 y: 0.0,
1442 w: 256.0,
1443 h: 256.0,
1444 },
1445 payload: Callback::new(payload),
1446 }],
1447 };
1448 let px = renderer
1449 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1450 .expect("offscreen render");
1451 let at = |x: u32, y: u32| -> [u8; 4] {
1452 let i = ((y * 256 + x) * 4) as usize;
1453 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1454 };
1455 assert_eq!(at(8, 8), [0, 0, 255, 255], "background fill");
1457 assert_eq!(at(247, 247), [0, 0, 255, 255], "background fill");
1458 assert_eq!(at(128, 128), [255, 0, 255, 255], "cam-centered quad");
1459 let overlay_input = FrameInput {
1460 overlay_color: Some([1.0, 0.0, 0.0, 1.0]),
1461 ..input
1462 };
1463 let overlay_payload = GpuViewport {
1464 input: Arc::new(overlay_input),
1465 geom: Arc::new(Mutex::new(FrameGeom {
1466 fit: (1.0, 0.0, 0.0),
1467 look: [0.0, 0.0],
1468 density: 1.0,
1469 viewport_px: [256.0, 256.0],
1470 roll: 0.0,
1471 pivot: [64.0, 64.0],
1472 })),
1473 batch_id: "test.viewport2d.overlay".to_string(),
1474 uploads: Arc::from(
1475 vec![AtlasUpload {
1476 page: 0,
1477 x: 0,
1478 y: 0,
1479 w: 2,
1480 h: 2,
1481 rgba: [255, 0, 255, 255].repeat(4),
1482 }]
1483 .into_boxed_slice(),
1484 ),
1485 desc: BatchDesc {
1486 layer_size: 2,
1487 layers: 1,
1488 filter: TextureFilter::Nearest,
1489 },
1490 bg: FullscreenPass::new(
1491 "test.viewport2d.background",
1492 fullscreen::SOLID_WGSL,
1493 FullscreenDesc {
1494 texture_slots: 0,
1495 filter: TextureFilter::Nearest,
1496 },
1497 ),
1498 overlay: FullscreenPass::new(
1499 "test.viewport2d.overlay",
1500 fullscreen::SOLID_WGSL,
1501 FullscreenDesc {
1502 texture_slots: 0,
1503 filter: TextureFilter::Nearest,
1504 },
1505 ),
1506 };
1507 let scene = Scene {
1508 clear_color: Color::from_rgba(0, 0, 0, 255),
1509 nodes: vec![SceneNode::Callback {
1510 rect: Rect {
1511 x: 0.0,
1512 y: 0.0,
1513 w: 256.0,
1514 h: 256.0,
1515 },
1516 payload: Callback::new(overlay_payload),
1517 }],
1518 };
1519 let px = renderer
1520 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1521 .expect("offscreen render");
1522 let at = |x: u32, y: u32| -> [u8; 4] {
1523 let i = ((y * 256 + x) * 4) as usize;
1524 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1525 };
1526 assert_eq!(at(8, 8), [255, 0, 0, 255], "overlay covers background");
1527 assert_eq!(at(128, 128), [255, 0, 0, 255], "overlay covers sprite");
1528 }
1529
1530 #[test]
1531 fn surface_dp_matches_board_point_under_shake() {
1532 let geom = FrameGeom {
1535 fit: (1.0, 100.0, 0.0),
1536 look: [0.0, 0.0],
1537 density: 1.25,
1538 viewport_px: [1250.0, 750.0],
1539 roll: 0.0,
1540 pivot: [400.0, 300.0],
1541 };
1542 let ([ox, oy], [w, h]) = surface_dp([400.0, 300.0], [64.0, 80.0], geom);
1543 assert!(
1544 (ox - 468.0).abs() < 1e-4 && (oy - 260.0).abs() < 1e-4,
1545 "offset, got ({ox},{oy})"
1546 );
1547 assert!(
1548 (w - 64.0).abs() < 1e-4 && (h - 80.0).abs() < 1e-4,
1549 "size, got ({w},{h})"
1550 );
1551 let board = world_to_dp([400.0, 300.0], [800.0, 600.0], [400.0, 300.0], geom.fit);
1553 assert!(
1554 (ox + w * 0.5 - board[0]).abs() < 1e-4 && (oy + h * 0.5 - board[1]).abs() < 1e-4,
1555 "surface glued to board point"
1556 );
1557 let shaken = FrameGeom {
1558 look: [10.0, -10.0],
1559 ..geom
1560 };
1561 let ([sx, sy], _) = surface_dp([400.0, 300.0], [64.0, 80.0], shaken);
1562 let sboard = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], shaken.fit);
1563 assert!(
1564 (sx + w * 0.5 - sboard[0]).abs() < 1e-4 && (sy + h * 0.5 - sboard[1]).abs() < 1e-4,
1565 "surface glued under shake"
1566 );
1567 }
1568
1569 #[test]
1570 fn contain_fit_has_no_silent_clamp() {
1571 for (canvas, world) in [
1572 ([8000.0, 6000.0], [80.0, 60.0]),
1573 ([80.0, 60.0], [8000.0, 6000.0]),
1574 ([1000.0, 750.0], [800.0, 600.0]),
1575 ] {
1576 let fit = contain_fit(canvas, world);
1577 let cam = [world[0] * 0.5, world[1] * 0.5];
1578 let dp = world_to_dp([cam[0], cam[1]], world, cam, fit);
1579 let back = dp_to_world(dp, world, cam, fit);
1580 assert!(
1581 (back[0] - cam[0]).abs() < 1e-3 && (back[1] - cam[1]).abs() < 1e-3,
1582 "round trip for {canvas:?}/{world:?}, fit {fit:?} gave {back:?}"
1583 );
1584 }
1585 assert_eq!(contain_fit([0.0, 600.0], [800.0, 600.0]), (1.0, 0.0, 0.0));
1586 assert_eq!(
1587 dp_to_world(
1588 [10.0, 10.0],
1589 [800.0, 600.0],
1590 [400.0, 300.0],
1591 (0.0, 0.0, 0.0)
1592 ),
1593 [400.0, 300.0]
1594 );
1595 }
1596
1597 #[test]
1598 fn canvas_rotated_rect_matches_instance_rows() {
1599 let cam = Camera2d {
1603 center: Vec2::new(400.0, 300.0),
1604 offset: Vec2::ZERO,
1605 units_per_pixel: 1.0,
1606 zoom: 1.0,
1607 roll: 0.3,
1608 };
1609 let world_size = [800.0, 600.0];
1610 let canvas_dp = [800.0, 600.0];
1611 let fit = effective_fit(canvas_dp, world_size, &cam);
1612 let center = cam.effective_center();
1613 let project = |wx: f32, wy: f32| -> [f32; 2] {
1614 world_to_dp_with_roll([wx, wy], world_size, center, fit, cam.roll)
1615 };
1616 let k = fit.0;
1617 for (rotation, flip_x, flip_y, anchor) in [
1618 (0.7f32, false, false, [0.5, 0.5]),
1619 (0.7, true, false, [0.5, 0.5]),
1620 (0.7, false, true, [0.5, 0.5]),
1621 (0.7, true, true, [0.5, 0.5]),
1622 (-1.2, true, false, [0.0, 0.0]),
1623 (2.1, false, true, [1.0, 0.25]),
1624 (0.0, true, false, [0.5, 0.5]),
1625 ] {
1626 let size = [64.0f32, 40.0];
1627 let ctr = [400.0f32, 300.0];
1628 let (row0, row1) = batch::instance_rows(ctr, size, rotation, anchor, flip_x, flip_y);
1630 let mut expect = [[0.0f32; 2]; 4];
1631 for (i, corner) in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]
1632 .iter()
1633 .enumerate()
1634 {
1635 let wx = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
1636 let wy = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
1637 expect[i] = project(wx, wy);
1638 }
1639 let ax = if flip_x { 1.0 - anchor[0] } else { anchor[0] };
1643 let ay = if flip_y { 1.0 - anchor[1] } else { anchor[1] };
1644 let [px, py] = project(ctr[0], ctr[1]);
1645 let angle = cam.roll + rotation;
1646 let (c, s) = (angle.cos(), angle.sin());
1647 let (ox, oy) = (px - ax * size[0] * k, py - ay * size[1] * k);
1648 let w = size[0] * k;
1649 let h = size[1] * k;
1650 for (i, corner) in [[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]].iter().enumerate() {
1651 let rx = px + (ox + corner[0] - px) * c - (oy + corner[1] - py) * s;
1652 let ry = py + (ox + corner[0] - px) * s + (oy + corner[1] - py) * c;
1653 let mut best = f32::INFINITY;
1654 for e in expect {
1655 best = best.min((rx - e[0]).hypot(ry - e[1]));
1656 }
1657 assert!(
1658 best < 1e-2,
1659 "rot={rotation} flip=({flip_x},{flip_y}) anchor={anchor:?}: corner {i} ({rx},{ry}) matches none of {expect:?}"
1660 );
1661 }
1662 }
1663 }
1664
1665 #[test]
1666 #[allow(deprecated)]
1667 fn camera_guards_div_by_zero() {
1668 let bad = Camera2d {
1669 center: Vec2::ZERO,
1670 offset: Vec2::ZERO,
1671 units_per_pixel: 0.0,
1672 zoom: 0.0,
1673 roll: 0.0,
1674 };
1675 let m = bad.view_proj([0.0, 0.0]);
1676 assert!(m.is_finite(), "view_proj stays finite, got {m:?}");
1677 assert_eq!(bad.screen_to_world([0.0, 0.0], [5.0, 5.0]), Vec2::ZERO);
1678 }
1679
1680 #[test]
1681 fn fit_matrix_canvas_gpu_picks_agree() {
1682 let cam = Camera2d {
1683 center: Vec2::new(410.0, 290.0),
1684 offset: Vec2::ZERO,
1685 units_per_pixel: 1.0,
1686 zoom: 2.0,
1687 roll: 0.0,
1688 };
1689 let world_size = [800.0, 600.0];
1690 let density = 1.25;
1691 let vp_phys = [1250.0, 750.0];
1692 let canvas_dp = [vp_phys[0] / density, vp_phys[1] / density];
1693 let cam_center = cam.effective_center();
1694 let fit = effective_fit(canvas_dp, world_size, &cam);
1695 assert!((fit.0 - 2.0).abs() < 1e-4, "effective fit, got {fit:?}");
1696 let vp = cam.fit_matrix(canvas_dp, world_size);
1697 assert!(vp.is_finite());
1698 for world in [[400.0, 300.0], [410.0, 290.0], [0.0, 0.0], [800.0, 600.0]] {
1699 let [dx, dy] = world_to_dp(world, world_size, cam_center, fit);
1700 let px = [dx * density, dy * density];
1701 let ndc = vp.project_point3(glam::Vec3::new(world[0], world[1], 0.0));
1702 let gpu_px = [
1703 (ndc.x + 1.0) * 0.5 * vp_phys[0],
1704 (1.0 - ndc.y) * 0.5 * vp_phys[1],
1705 ];
1706 assert!(
1707 (px[0] - gpu_px[0]).abs() < 1e-2 && (px[1] - gpu_px[1]).abs() < 1e-2,
1708 "canvas/GPU disagree for {world:?}: canvas {px:?} vs gpu {gpu_px:?}"
1709 );
1710 let back =
1711 cam.dp_to_world_pt(canvas_dp, world_size, [px[0] / density, px[1] / density]);
1712 assert!(
1713 (back.x - world[0]).abs() < 1e-3 && (back.y - world[1]).abs() < 1e-3,
1714 "pick round trip for {world:?} gave {back:?}"
1715 );
1716 }
1717 }
1718
1719 #[test]
1720 fn letterbox_pick_matches_draw() {
1721 let cam = Camera2d {
1723 center: Vec2::new(400.0, 400.0),
1724 ..Default::default()
1725 };
1726 let fit = effective_fit([1600.0, 900.0], [800.0, 800.0], &cam);
1727 let dp = world_to_dp([400.0, 400.0], [800.0, 800.0], cam.effective_center(), fit);
1728 let back = dp_to_world(dp, [800.0, 800.0], cam.effective_center(), fit);
1729 assert!((back[0] - 400.0).abs() < 1e-3 && (back[1] - 400.0).abs() < 1e-3);
1730 let m = cam.fit_matrix([1600.0, 900.0], [800.0, 800.0]);
1732 let plain = fit_view_proj([1600.0, 900.0], [800.0, 800.0], cam.effective_center(), fit);
1733 let dm = m - plain;
1734 let max = dm
1735 .to_cols_array()
1736 .iter()
1737 .fold(0.0f32, |a, b| a.max(b.abs()));
1738 assert!(max < 1e-5);
1739 }
1740
1741 #[test]
1742 fn picks_ignore_viewport_origin() {
1743 let geom = FrameGeom {
1744 fit: (1.0, 100.0, 0.0),
1745 look: [0.0, 0.0],
1746 density: 1.25,
1747 viewport_px: [1250.0, 750.0],
1748 roll: 0.0,
1749 pivot: [400.0, 300.0],
1750 };
1751 let world_size = [800.0, 600.0];
1752 let a = pick_world([125.0, 75.0], geom, world_size);
1753 let b = pick_world([125.0, 75.0], geom, world_size);
1754 assert_eq!(a, b);
1755 let [wx, wy] = pick_world([125.0, 75.0], geom, world_size);
1756 assert!(
1757 (wx - 0.0).abs() < 1e-4 && (wy - 60.0).abs() < 1e-4,
1758 "local pick, got ({wx},{wy})"
1759 );
1760 }
1761
1762 #[test]
1763 fn offset_shifts_look_and_draw_together() {
1764 let world_size = [800.0, 600.0];
1767 let canvas_dp = [1000.0, 600.0];
1768 let mut cam = Camera2d {
1769 center: Vec2::new(400.0, 300.0),
1770 offset: Vec2::new(10.0, -10.0),
1771 units_per_pixel: 1.0,
1772 zoom: 1.0,
1773 roll: 0.0,
1774 };
1775 assert_eq!(cam.effective_center(), [410.0, 290.0]);
1776 let fit = effective_fit(canvas_dp, world_size, &cam);
1777 let dp = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
1778 let back = dp_to_world(dp, world_size, cam.effective_center(), fit);
1779 assert!((back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4);
1780 cam.offset = Vec2::ZERO;
1781 let dp2 = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
1782 assert!((dp[0] - dp2[0] + 10.0).abs() < 1e-4);
1784 assert!((dp[1] - dp2[1] - 10.0).abs() < 1e-4);
1785 }
1786
1787 #[test]
1788 fn touch_gate_and_screen_space() {
1789 use repose_core::Vec2 as RVec2;
1790 use repose_core::input::{
1791 Modifiers, PointerButton, PointerEvent, PointerEventKind, PointerId, PointerKind,
1792 };
1793 let ev_of = |kind: PointerKind| {
1794 let mut ev = PointerEvent::new(
1795 PointerId(3),
1796 kind,
1797 PointerEventKind::Down(PointerButton::Primary),
1798 RVec2 { x: 10.0, y: 20.0 },
1799 1.0,
1800 Modifiers::default(),
1801 );
1802 ev.origin = RVec2 { x: 5.0, y: 7.0 };
1803 ev
1804 };
1805 assert!(is_touch(&ev_of(PointerKind::Touch)));
1807 assert!(is_touch(&ev_of(PointerKind::Pen)));
1808 assert!(!is_touch(&ev_of(PointerKind::Mouse)));
1809 assert_eq!(screen_of(&ev_of(PointerKind::Touch)), [15.0, 27.0]);
1811 }
1812
1813 #[test]
1814 fn click_slop_cancels_drags() {
1815 assert!(click_within_slop([100.0, 100.0], [105.0, 105.0]));
1816 assert!(click_within_slop_dp([100.0, 100.0], [105.0, 105.0], 1.0));
1817 assert!(!click_within_slop_dp(
1818 [100.0, 100.0],
1819 [100.0 + CLICK_SLOP_DP + 1.0, 100.0],
1820 1.0
1821 ));
1822 }
1823
1824 #[test]
1825 fn click_slop_is_density_independent() {
1826 assert!(click_within_slop_dp([0.0, 0.0], [36.0, 0.0], 3.0));
1828 assert!(!click_within_slop_dp([0.0, 0.0], [37.0, 0.0], 3.0));
1829 assert!(click_within_slop_dp([0.0, 0.0], [12.0, 0.0], 1.0));
1830 assert!(!click_within_slop_dp([0.0, 0.0], [13.0, 0.0], 1.0));
1831 }
1832
1833 #[test]
1834 fn aim_ignores_shake_while_pick_follows_it() {
1835 use super::{aim_world_unshaken, pick_world};
1836 let world_size = [800.0, 600.0];
1837 let canvas_dp = [800.0, 600.0];
1838 let mut cam = Camera2d {
1839 center: Vec2::new(400.0, 300.0),
1840 offset: Vec2::new(10.0, -5.0),
1841 units_per_pixel: 1.0,
1842 zoom: 1.0,
1843 roll: 0.0,
1844 };
1845 let fit = effective_fit(canvas_dp, world_size, &cam);
1846 let shaken = FrameGeom {
1847 fit,
1848 look: [
1849 cam.effective_center()[0] - world_size[0] * 0.5,
1850 cam.effective_center()[1] - world_size[1] * 0.5,
1851 ],
1852 density: 1.0,
1853 viewport_px: [800.0, 600.0],
1854 roll: 0.0,
1855 pivot: cam.effective_center(),
1856 };
1857 let px = [400.0, 300.0];
1858 let pick = pick_world(px, shaken, world_size);
1859 let aim = aim_world_unshaken(px, &cam, canvas_dp, world_size, 1.0);
1860 assert!(
1861 (pick[0] - aim[0] - 10.0).abs() < 1e-3 && (pick[1] - aim[1] + 5.0).abs() < 1e-3,
1862 "pick follows shake, aim does not: pick {pick:?} aim {aim:?}"
1863 );
1864 cam.offset = Vec2::ZERO;
1865 let aim_still = aim_world_unshaken(px, &cam, canvas_dp, world_size, 1.0);
1866 assert!(
1867 (aim[0] - aim_still[0]).abs() < 1e-3 && (aim[1] - aim_still[1]).abs() < 1e-3,
1868 "aim independent of shake: {aim:?} vs {aim_still:?}"
1869 );
1870 }
1871
1872 #[test]
1873 fn roll_roundtrips_and_moves_pixels() {
1874 let cam = Camera2d {
1875 center: Vec2::new(400.0, 300.0),
1876 offset: Vec2::ZERO,
1877 units_per_pixel: 1.0,
1878 zoom: 1.0,
1879 roll: std::f32::consts::FRAC_PI_2,
1880 };
1881 let world = [800.0, 600.0];
1882 let dp = [1000.0, 600.0];
1883 let fit = effective_fit(dp, world, &cam);
1884 for p in [[400.0, 300.0], [500.0, 300.0], [0.0, 0.0]] {
1885 let fwd = world_to_dp_with_roll(p, world, cam.effective_center(), fit, cam.roll);
1886 let back = dp_to_world_with_roll(fwd, world, cam.effective_center(), fit, cam.roll);
1887 assert!(
1888 (back[0] - p[0]).abs() < 1e-3 && (back[1] - p[1]).abs() < 1e-3,
1889 "roll roundtrip for {p:?} gave {back:?}"
1890 );
1891 }
1892 let plain = world_to_dp([500.0, 300.0], world, cam.effective_center(), fit);
1893 let rolled =
1894 world_to_dp_with_roll([500.0, 300.0], world, cam.effective_center(), fit, cam.roll);
1895 assert!(
1896 (plain[0] - rolled[0]).abs() + (plain[1] - rolled[1]).abs() > 1.0,
1897 "roll must move pixels, plain {plain:?} rolled {rolled:?}"
1898 );
1899 let m0 = fit_view_proj_with_roll(dp, world, cam.effective_center(), fit, 0.0);
1900 let legacy = fit_view_proj(dp, world, cam.effective_center(), fit);
1901 assert_eq!(m0, legacy);
1902 }
1903
1904 #[test]
1905 fn surface_tracks_roll_about_pivot() {
1906 let geom = FrameGeom {
1907 fit: (1.0, 0.0, 0.0),
1908 look: [0.0, 0.0],
1909 density: 1.0,
1910 viewport_px: [800.0, 600.0],
1911 roll: std::f32::consts::FRAC_PI_2,
1912 pivot: [400.0, 300.0],
1913 };
1914 let ([ox, oy], _) = surface_dp([500.0, 300.0], [10.0, 10.0], geom);
1915 assert!(
1916 (ox - 395.0).abs() < 1e-3 && (oy - 395.0).abs() < 1e-3,
1917 "rolled surface, got ({ox},{oy})"
1918 );
1919 }
1920
1921 #[test]
1922 fn geom_handle_compat_wraps_legacy_handles() {
1923 use std::cell::Cell;
1924 use std::rc::Rc;
1925 let rc = Rc::new(Cell::new(FrameGeom {
1926 fit: (2.0, 1.0, 2.0),
1927 ..Default::default()
1928 }));
1929 let h: GeomHandle = rc.into();
1930 assert_eq!(h.get().fit, (2.0, 1.0, 2.0));
1931 let arc = Arc::new(Mutex::new(FrameGeom::default()));
1932 let h2: GeomHandle = arc.into();
1933 assert_eq!(h2.get().density, 1.0);
1934 }
1935}
1936
1937#[derive(Clone, Debug, PartialEq, Eq)]
1939pub struct CursorFrame {
1940 pub rgba: Vec<u8>,
1941 pub size: [u16; 2],
1942 pub hotspot: [u16; 2],
1943 pub key: (i32, [u32; 3], u64, u32),
1944}
1945
1946pub fn cursor_frame(
1947 strip: &[u8],
1948 strip_w: u32,
1949 strip_h: u32,
1950 frames: u32,
1951 frame: i32,
1952 cell: (u32, u32),
1953 origin: (f32, f32),
1954 tint: [f32; 4],
1955 mag: u32,
1956 strip_hash: u64,
1957) -> Option<CursorFrame> {
1958 let frames = frames.max(1) as i32;
1959 let frame = frame.clamp(0, frames - 1);
1960 let (cw, ch) = (cell.0.max(1), cell.1.max(1));
1961 if strip_w < cw * (frame as u32 + 1) || strip_h < ch {
1962 return None;
1963 }
1964 let mag = mag.clamp(1, 8);
1965 let tint_u8 = [
1966 (tint[0].clamp(0.0, 1.0) * 255.0) as u32,
1967 (tint[1].clamp(0.0, 1.0) * 255.0) as u32,
1968 (tint[2].clamp(0.0, 1.0) * 255.0) as u32,
1969 ];
1970 let key = (frame, tint_u8, strip_hash, mag);
1971 let fx = (frame as u32 * cw) as usize;
1972 let (dw, dh) = (cw * mag, ch * mag);
1973 if dw > 2048 || dh > 2048 {
1974 return None;
1975 }
1976 let mut px = Vec::with_capacity((dw * dh * 4) as usize);
1977 for row in 0..ch {
1978 let start = ((row * strip_w) as usize + fx) * 4;
1979 let end = start + (cw as usize) * 4;
1980 let cell = strip.get(start..end)?;
1981 let mut mag_row = Vec::with_capacity((cw * mag * 4) as usize);
1982 for pix in cell.chunks_exact(4) {
1983 let t = [
1984 (pix[0] as u32 * tint_u8[0] / 255) as u8,
1985 (pix[1] as u32 * tint_u8[1] / 255) as u8,
1986 (pix[2] as u32 * tint_u8[2] / 255) as u8,
1987 pix[3],
1988 ];
1989 for _ in 0..mag {
1990 mag_row.extend_from_slice(&t);
1991 }
1992 }
1993 for _ in 0..mag {
1994 px.extend_from_slice(&mag_row);
1995 }
1996 }
1997 let (w16, h16) = (dw.min(2048) as u16, dh.min(2048) as u16);
1998 Some(CursorFrame {
1999 rgba: px,
2000 size: [w16, h16],
2001 hotspot: [
2002 (origin.0 * mag as f32).round().clamp(0.0, w16.saturating_sub(1) as f32) as u16,
2003 (origin.1 * mag as f32).round().clamp(0.0, h16.saturating_sub(1) as f32) as u16,
2004 ],
2005 key,
2006 })
2007}
2008
2009pub fn unproject_px(
2011 px: Vec2,
2012 viewport_dp: [f32; 2],
2013 world_size: [f32; 2],
2014 density: f32,
2015 cam: &Camera2d,
2016) -> Vec2 {
2017 let d = if density.is_finite() && density > 1e-6 {
2018 density
2019 } else {
2020 1.0
2021 };
2022 let dp = [px.x / d, px.y / d];
2023 cam.dp_to_world_pt(viewport_dp, world_size, dp)
2024}
2025
2026#[cfg(test)]
2027mod unproject_tests {
2028 use super::*;
2029
2030 #[test]
2031 fn px_round_trips_through_fit() {
2032 let cam = Camera2d {
2033 center: Vec2::new(213.0, 120.0),
2034 offset: Vec2::ZERO,
2035 units_per_pixel: 1.0 / 3.0,
2036 zoom: 1.0,
2037 roll: 0.0,
2038 };
2039 let world = Vec2::new(100.0, 60.0);
2040 let viewport_dp = [1024.0, 600.0];
2041 let world_size = [1024.0, 600.0];
2042 let (s, ox, oy) = effective_fit(viewport_dp, world_size, &cam);
2043 let [dx, dy] = world_to_dp(
2044 [world.x, world.y],
2045 world_size,
2046 cam.effective_center(),
2047 (s, ox, oy),
2048 );
2049 let px = Vec2::new(dx * 1.25, dy * 1.25);
2050 let back = unproject_px(px, viewport_dp, world_size, 1.25, &cam);
2051 assert!(
2052 (back - world).length() < 0.01,
2053 "round trip, got {back:?} for {world:?}"
2054 );
2055 }
2056}