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