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