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 light2d;
38pub use light2d::{Light2d, LightStop, Occluder2d, angular_shadow_map};
39
40pub mod post;
41
42pub mod sprite_image;
43pub use sprite_image::{
44 SpriteImage, SpriteImageAsset, SpriteImageDef, SpriteImageError, SpriteImageFrameDef,
45};
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub enum FitMode {
51 #[default]
52 Keep,
53 Expand,
57}
58
59#[derive(Clone, Copy, Debug)]
63pub struct Camera2d {
64 pub center: Vec2,
67 pub offset: Vec2,
70 pub units_per_pixel: f32,
74 pub zoom: f32,
77 pub roll: f32,
80 pub fit: FitMode,
83}
84
85impl Default for Camera2d {
86 fn default() -> Self {
87 Self {
88 center: Vec2::ZERO,
89 offset: Vec2::ZERO,
90 units_per_pixel: 1.0,
91 zoom: 1.0,
92 roll: 0.0,
93 fit: FitMode::Keep,
94 }
95 }
96}
97
98impl Camera2d {
101 pub fn effective_center(&self) -> [f32; 2] {
104 [self.center.x + self.offset.x, self.center.y + self.offset.y]
105 }
106
107 pub fn fit_matrix(&self, canvas_dp: [f32; 2], world_size: [f32; 2]) -> Mat4 {
110 let fit = effective_fit(canvas_dp, world_size, self);
111 fit_view_proj_with_roll(
112 canvas_dp,
113 world_size,
114 self.effective_center(),
115 fit,
116 self.roll,
117 )
118 }
119
120 pub fn dp_to_world_pt(&self, canvas_dp: [f32; 2], world_size: [f32; 2], dp: [f32; 2]) -> Vec2 {
123 let fit = effective_fit(canvas_dp, world_size, self);
124 let w = dp_to_world_with_roll(dp, world_size, self.effective_center(), fit, self.roll);
125 Vec2::new(w[0], w[1])
126 }
127
128 pub fn view_proj_screen(&self, viewport_px: [f32; 2]) -> Mat4 {
131 let vp_w = viewport_px[0].max(1.0);
132 let vp_h = viewport_px[1].max(1.0);
133 let zoom = sane_positive(self.zoom);
134 let upp = sane_positive(self.units_per_pixel);
135 let w = vp_w * upp / zoom;
136 let h = vp_h * upp / zoom;
137 let proj = glam::camera::rh::proj::directx::orthographic(
138 -w / 2.0,
139 w / 2.0,
140 h / 2.0,
141 -h / 2.0,
142 -1000.0,
143 1000.0,
144 );
145 let view = Mat4::from_translation(Vec3::new(
146 -(self.center.x + self.offset.x),
147 -(self.center.y + self.offset.y),
148 0.0,
149 ));
150 proj * view
151 }
152
153 #[deprecated(
158 since = "0.1.8",
159 note = "ignores world_size; use fit_view_proj for the shared canvas/GPU/pick framing contract"
160 )]
161 pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
162 self.view_proj_screen(viewport_px)
163 }
164
165 #[deprecated(
169 since = "0.1.8",
170 note = "ignores world_size; use pick_world over the painted FrameGeom instead"
171 )]
172 #[allow(deprecated)]
173 pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
174 if viewport_px[0] <= 0.0 || viewport_px[1] <= 0.0 {
175 let [cx, cy] = self.effective_center();
176 return Vec2::new(cx, cy);
177 }
178 let ndc = Vec2::new(
179 (px[0] / viewport_px[0]) * 2.0 - 1.0,
180 1.0 - (px[1] / viewport_px[1]) * 2.0,
181 );
182 let inv = self.view_proj(viewport_px).inverse();
183 let world = inv.project_point3(ndc.extend(0.0));
184 Vec2::new(world.x, world.y)
185 }
186}
187
188#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
192pub enum SpriteBlend {
193 #[default]
194 Alpha,
195 Additive,
196 Multiply,
197}
198
199#[derive(Clone, Copy, Debug)]
201pub struct SpriteInstance {
202 pub center: Vec2,
204 pub rotation: f32,
205 pub size: Vec2,
206 pub anchor: Vec2,
209 pub flip_x: bool,
212 pub flip_y: bool,
213 pub uv_min: Vec2,
214 pub uv_max: Vec2,
215 pub color: [f32; 4],
217 pub page: u32,
219 pub z: f32,
224 pub blend: SpriteBlend,
225}
226
227impl Default for SpriteInstance {
228 fn default() -> Self {
229 Self {
230 center: Vec2::ZERO,
231 rotation: 0.0,
232 size: Vec2::ONE,
233 anchor: Vec2::new(0.5, 0.5),
234 flip_x: false,
235 flip_y: false,
236 uv_min: Vec2::ZERO,
237 uv_max: Vec2::ONE,
238 color: [1.0, 1.0, 1.0, 1.0],
239 page: 0,
240 z: 0.0,
241 blend: SpriteBlend::Alpha,
242 }
243 }
244}
245
246#[derive(Clone, Debug, Default)]
248pub struct WorldText {
249 pub text: String,
250 pub pos: Vec2,
252 pub color: [f32; 4],
254 pub size: f32,
256 pub font_family: Option<&'static str>,
260}
261
262#[derive(Clone, Default, Debug)]
267pub struct FrameInput {
268 pub cam: Camera2d,
269 pub world_size: [f32; 2],
271 pub viewport_dp: [f32; 2],
275 pub sprites: Vec<SpriteInstance>,
276 pub texts: Vec<WorldText>,
279 pub background: Option<[f32; 4]>,
281 pub overlay_color: Option<[f32; 4]>,
284 pub chroma: f32,
288 pub lights: Vec<Light2d>,
293 pub occluders: std::sync::Arc<[Occluder2d]>,
296 pub ambient: Option<[f32; 3]>,
301}
302
303#[derive(Clone, Debug)]
307pub enum PickEvent {
308 Press {
309 world: Vec2,
310 screen: [f32; 2],
311 button: repose_core::input::PointerButton,
312 },
313 Click {
314 world: Vec2,
315 screen: [f32; 2],
316 button: repose_core::input::PointerButton,
317 },
318 Hover {
319 world: Vec2,
320 screen: [f32; 2],
326 },
327 TouchDown { id: u64, screen: [f32; 2] },
330 TouchMove { id: u64, screen: [f32; 2] },
332 TouchUp { id: u64 },
334}
335
336fn is_touch(ev: &repose_core::input::PointerEvent) -> bool {
342 matches!(
343 ev.kind,
344 repose_core::input::PointerKind::Touch | repose_core::input::PointerKind::Pen
345 )
346}
347
348fn screen_of(ev: &repose_core::input::PointerEvent) -> [f32; 2] {
350 let p = ev.position_in_window();
351 [p.x, p.y]
352}
353
354fn button_of(ev: &repose_core::input::PointerEvent) -> repose_core::input::PointerButton {
357 match ev.event {
358 repose_core::input::PointerEventKind::Down(b)
359 | repose_core::input::PointerEventKind::Up(b) => b,
360 _ => repose_core::input::PointerButton::Primary,
361 }
362}
363
364fn sane_positive(v: f32) -> f32 {
367 if v.is_finite() && v > 1e-6 { v } else { 1.0 }
368}
369
370fn fitted_box_scissor(
374 info: &repose_core::PaintCallbackInfo,
375 world: [f32; 2],
376 fit: (f32, f32, f32),
377 roll: f32,
378) -> Option<(u32, u32, u32, u32)> {
379 fitted_box_scissor_in(
380 world,
381 fit,
382 roll,
383 info.pixels_per_point,
384 [info.viewport.x, info.viewport.y],
385 [
386 info.clip_rect.x,
387 info.clip_rect.y,
388 info.clip_rect.w,
389 info.clip_rect.h,
390 ],
391 )
392}
393
394fn fitted_box_scissor_in(
399 world: [f32; 2],
400 fit: (f32, f32, f32),
401 roll: f32,
402 density: f32,
403 origin: [f32; 2],
404 clip: [f32; 4],
405) -> Option<(u32, u32, u32, u32)> {
406 let d = if density.is_finite() && density > 1e-6 {
407 density
408 } else {
409 1.0
410 };
411 let (s, ox, oy) = fit;
412 if !(s.is_finite() && s > 1e-6) {
413 return None;
414 }
415 let (bw, bh) = (world[0] * s * d, world[1] * s * d);
418 let cx = origin[0] + ox * d + bw * 0.5;
419 let cy = origin[1] + oy * d + bh * 0.5;
420 let (hw, hh) = (bw * 0.5, bh * 0.5);
421 let (c, sn) = (roll.cos().abs(), roll.sin().abs());
422 let (ex, ey) = (hw * c + hh * sn, hw * sn + hh * c);
423 let lx = (cx - ex).max(clip[0]);
429 let ty = (cy - ey).max(clip[1]);
430 let rx = (cx + ex).min(clip[0] + clip[2]);
431 let by = (cy + ey).min(clip[1] + clip[3]);
432 if !(rx > lx && by > ty) {
433 return None;
434 }
435 let sx = lx.ceil().max(0.0) as u32;
436 let sy = ty.ceil().max(0.0) as u32;
437 let ex2 = rx.floor().max(0.0) as u32;
438 let ey2 = by.floor().max(0.0) as u32;
439 if ex2 <= sx || ey2 <= sy {
440 return None;
441 }
442 Some((sx, sy, ex2 - sx, ey2 - sy))
443}
444
445pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
449 if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
450 return (1.0, 0.0, 0.0);
451 }
452 let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
453 if !s.is_finite() || s <= 1e-6 {
454 return (1.0, 0.0, 0.0);
455 }
456 (
457 s,
458 (canvas_dp[0] - world[0] * s) * 0.5,
459 (canvas_dp[1] - world[1] * s) * 0.5,
460 )
461}
462
463pub fn effective_fit(canvas_dp: [f32; 2], world: [f32; 2], cam: &Camera2d) -> (f32, f32, f32) {
472 let (base, _, _) = contain_fit(canvas_dp, world);
473 let zoom = sane_positive(cam.zoom);
474 let upp = sane_positive(cam.units_per_pixel);
475 let s = base * zoom / upp;
476 if !s.is_finite() || s <= 1e-6 {
477 return (1.0, 0.0, 0.0);
478 }
479 let (ox, oy) = match cam.fit {
480 FitMode::Keep => (
481 (canvas_dp[0] - world[0] * s) * 0.5,
482 (canvas_dp[1] - world[1] * s) * 0.5,
483 ),
484 FitMode::Expand => (0.0, 0.0),
485 };
486 (s, ox, oy)
487}
488
489pub fn fit_view_proj(
492 canvas_dp: [f32; 2],
493 world_size: [f32; 2],
494 cam_center: [f32; 2],
495 fit: (f32, f32, f32),
496) -> Mat4 {
497 fit_view_proj_with_roll(canvas_dp, world_size, cam_center, fit, 0.0)
498}
499
500pub fn fit_view_proj_with_roll(
503 canvas_dp: [f32; 2],
504 world_size: [f32; 2],
505 cam_center: [f32; 2],
506 fit: (f32, f32, f32),
507 roll: f32,
508) -> Mat4 {
509 let w = canvas_dp[0].max(1.0);
510 let h = canvas_dp[1].max(1.0);
511 let (s, ox, oy) = fit;
512 let s = if s.is_finite() && s > 1e-6 { s } else { 1.0 };
513 let lx = cam_center[0] - world_size[0] * 0.5;
514 let ly = cam_center[1] - world_size[1] * 0.5;
515 let sx = 2.0 * s / w;
516 let sy = -2.0 * s / h;
517 let tx = 2.0 * (ox - lx * s) / w - 1.0;
518 let ty = 1.0 - 2.0 * (oy - ly * s) / h;
519 let base = Mat4::from_cols(
520 glam::Vec4::new(sx, 0.0, 0.0, 0.0),
521 glam::Vec4::new(0.0, sy, 0.0, 0.0),
522 glam::Vec4::new(0.0, 0.0, 0.001, 0.0),
523 glam::Vec4::new(tx, ty, 0.0, 1.0),
524 );
525 if !roll.is_finite() || roll.abs() < 1e-7 {
526 return base;
527 }
528 let (c, r) = (roll.cos(), roll.sin());
529 let rot = Mat4::from_cols(
530 glam::Vec4::new(c, r, 0.0, 0.0),
531 glam::Vec4::new(-r, c, 0.0, 0.0),
532 glam::Vec4::new(0.0, 0.0, 1.0, 0.0),
533 glam::Vec4::new(
534 cam_center[0] - c * cam_center[0] + r * cam_center[1],
535 cam_center[1] - r * cam_center[0] - c * cam_center[1],
536 0.0,
537 1.0,
538 ),
539 );
540 base * rot
541}
542
543pub fn world_to_dp(
546 world: [f32; 2],
547 world_size: [f32; 2],
548 cam_center: [f32; 2],
549 fit: (f32, f32, f32),
550) -> [f32; 2] {
551 world_to_dp_with_roll(world, world_size, cam_center, fit, 0.0)
552}
553
554pub fn world_to_dp_with_roll(
556 world: [f32; 2],
557 world_size: [f32; 2],
558 cam_center: [f32; 2],
559 fit: (f32, f32, f32),
560 roll: f32,
561) -> [f32; 2] {
562 let (s, ox, oy) = fit;
563 let [wx, wy] = rotate_about(world, cam_center, roll);
564 [
565 ox + (wx - (cam_center[0] - world_size[0] * 0.5)) * s,
566 oy + (wy - (cam_center[1] - world_size[1] * 0.5)) * s,
567 ]
568}
569
570pub fn dp_to_world(
572 dp: [f32; 2],
573 world_size: [f32; 2],
574 cam_center: [f32; 2],
575 fit: (f32, f32, f32),
576) -> [f32; 2] {
577 dp_to_world_with_roll(dp, world_size, cam_center, fit, 0.0)
578}
579
580pub fn dp_to_world_with_roll(
582 dp: [f32; 2],
583 world_size: [f32; 2],
584 cam_center: [f32; 2],
585 fit: (f32, f32, f32),
586 roll: f32,
587) -> [f32; 2] {
588 let (s, ox, oy) = fit;
589 if !s.is_finite() || s.abs() < 1e-6 {
590 return cam_center;
591 }
592 let base = [
593 (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
594 (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
595 ];
596 rotate_about(base, cam_center, -roll)
597}
598
599fn rotate_about(p: [f32; 2], center: [f32; 2], roll: f32) -> [f32; 2] {
600 if !roll.is_finite() || roll.abs() < 1e-7 {
601 return p;
602 }
603 let (c, r) = (roll.cos(), roll.sin());
604 let dx = p[0] - center[0];
605 let dy = p[1] - center[1];
606 [center[0] + c * dx - r * dy, center[1] + r * dx + c * dy]
607}
608
609pub fn pick_world(local_px: [f32; 2], geom: FrameGeom, world_size: [f32; 2]) -> [f32; 2] {
620 let d = if geom.density.is_finite() && geom.density > 1e-6 {
621 geom.density
622 } else {
623 1.0
624 };
625 dp_to_world_with_roll(
626 [local_px[0] / d, local_px[1] / d],
627 world_size,
628 geom.pivot,
629 geom.fit,
630 geom.roll,
631 )
632}
633
634pub fn aim_world(
640 local_px: [f32; 2],
641 look_unshaken: [f32; 2],
642 roll_unshaken: f32,
643 fit: (f32, f32, f32),
644 density: f32,
645 world_size: [f32; 2],
646) -> [f32; 2] {
647 let d = if density.is_finite() && density > 1e-6 {
648 density
649 } else {
650 1.0
651 };
652 dp_to_world_with_roll(
653 [local_px[0] / d, local_px[1] / d],
654 world_size,
655 look_unshaken,
656 fit,
657 roll_unshaken,
658 )
659}
660
661pub fn aim_world_unshaken(
666 local_px: [f32; 2],
667 cam: &Camera2d,
668 canvas_dp: [f32; 2],
669 world_size: [f32; 2],
670 density: f32,
671) -> [f32; 2] {
672 let fit = effective_fit(canvas_dp, world_size, cam);
673 aim_world(
674 local_px,
675 [cam.center.x, cam.center.y],
676 0.0,
677 fit,
678 density,
679 world_size,
680 )
681}
682
683#[derive(Clone, Copy, Debug)]
693pub struct FrameGeom {
694 pub fit: (f32, f32, f32),
697 pub look: [f32; 2],
700 pub density: f32,
702 pub viewport_px: [f32; 2],
704 pub roll: f32,
706 pub pivot: [f32; 2],
709}
710
711impl Default for FrameGeom {
712 fn default() -> Self {
713 Self {
714 fit: (1.0, 0.0, 0.0),
715 look: [0.0, 0.0],
716 density: 1.0,
717 viewport_px: [1.0, 1.0],
718 roll: 0.0,
719 pivot: [0.0, 0.0],
720 }
721 }
722}
723
724#[derive(Clone, Default)]
727pub struct GeomHandle(Arc<Mutex<FrameGeom>>);
728
729impl GeomHandle {
730 pub fn new() -> Self {
731 Self(Arc::new(Mutex::new(FrameGeom::default())))
732 }
733
734 pub fn get(&self) -> FrameGeom {
735 self.0.lock().map(|g| *g).unwrap_or_default()
736 }
737
738 pub fn set(&self, g: FrameGeom) {
739 if let Ok(mut slot) = self.0.lock() {
740 *slot = g;
741 }
742 }
743
744 pub fn arc(&self) -> Arc<Mutex<FrameGeom>> {
745 self.0.clone()
746 }
747}
748
749impl From<FrameGeom> for GeomHandle {
750 fn from(g: FrameGeom) -> Self {
751 Self(Arc::new(Mutex::new(g)))
752 }
753}
754
755impl From<std::sync::Arc<std::sync::Mutex<FrameGeom>>> for GeomHandle {
756 fn from(arc: std::sync::Arc<std::sync::Mutex<FrameGeom>>) -> Self {
758 Self(arc)
759 }
760}
761
762impl From<std::rc::Rc<std::cell::Cell<FrameGeom>>> for GeomHandle {
763 fn from(rc: std::rc::Rc<std::cell::Cell<FrameGeom>>) -> Self {
766 Self(Arc::new(Mutex::new(rc.get())))
767 }
768}
769
770pub const CLICK_SLOP_DP: f32 = 12.0;
775pub const CLICK_SLOP_PX: f32 = CLICK_SLOP_DP;
778
779#[allow(dead_code)]
782fn click_within_slop(a: [f32; 2], b: [f32; 2]) -> bool {
783 click_within_slop_dp(a, b, 1.0)
784}
785
786pub fn click_within_slop_dp(a: [f32; 2], b: [f32; 2], density: f32) -> bool {
789 let d = if density.is_finite() && density > 1e-6 {
790 density
791 } else {
792 1.0
793 };
794 let dx = (a[0] - b[0]) / d;
795 let dy = (a[1] - b[1]) / d;
796 dx * dx + dy * dy <= CLICK_SLOP_DP * CLICK_SLOP_DP
797}
798
799pub fn surface_dp(center: [f32; 2], size: [f32; 2], geom: FrameGeom) -> ([f32; 2], [f32; 2]) {
803 let (s, ox, oy) = geom.fit;
804 let r = rotate_about(center, geom.pivot, geom.roll);
805 (
806 [
807 ox + (r[0] - size[0] * 0.5 - geom.look[0]) * s,
808 oy + (r[1] - size[1] * 0.5 - geom.look[1]) * s,
809 ],
810 [size[0] * s, size[1] * s],
811 )
812}
813
814fn rgba8(c: [f32; 4]) -> Color {
815 Color::from_rgba(
816 (c[0].clamp(0.0, 1.0) * 255.0) as u8,
817 (c[1].clamp(0.0, 1.0) * 255.0) as u8,
818 (c[2].clamp(0.0, 1.0) * 255.0) as u8,
819 (c[3].clamp(0.0, 1.0) * 255.0) as u8,
820 )
821}
822
823#[allow(non_snake_case)] pub fn Viewport2d(
833 input: FrameInput,
834 geom_out: GeomHandle,
835 on_event: impl Fn(PickEvent) + 'static,
836) -> View {
837 Viewport2dShared(Arc::new(input), geom_out, on_event)
838}
839
840#[allow(non_snake_case)]
841pub fn Viewport2dShared(
842 input: Arc<FrameInput>,
843 geom_out: GeomHandle,
844 on_event: impl Fn(PickEvent) + 'static,
845) -> View {
846 let world_size = input.world_size;
847 let pick_geom = geom_out.clone();
848 let move_geom = geom_out.clone();
849 let release_geom = geom_out.clone();
850 let draw_input = input.clone();
851 let draw_geom = geom_out.clone();
852 let on_event = Rc::new(on_event);
853 let on_down = on_event.clone();
854 let on_touch_down = on_event.clone();
855 let on_move = on_event.clone();
856 let on_touch_move = on_event.clone();
857 let on_up = on_event.clone();
858 let on_up_click = on_event.clone();
859 let on_leave = on_event;
860 let press: Rc<RefCell<HashMap<u64, [f32; 2]>>> = Rc::new(RefCell::new(HashMap::new()));
861 let press_down = press.clone();
862 let press_up = press.clone();
863 let press_leave = press.clone();
864
865 let modifier = Modifier::new()
866 .fill_max_size()
867 .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
868 let p = ev.position;
869 let g = pick_geom.get();
870 let world = pick_world([p.x, p.y], g, world_size);
871 press_down.borrow_mut().insert(ev.id.0, [p.x, p.y]);
872 let w = ev.position_in_window();
873 let button = button_of(&ev);
874 on_down(PickEvent::Press {
875 world: Vec2::new(world[0], world[1]),
876 screen: [w.x, w.y],
877 button,
878 });
879 if is_touch(&ev) {
880 on_touch_down(PickEvent::TouchDown {
881 id: ev.id.0,
882 screen: screen_of(&ev),
883 });
884 }
885 })
886 .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
887 let p = ev.position;
888 let g = move_geom.get();
889 let world = pick_world([p.x, p.y], g, world_size);
890 let w = ev.position_in_window();
891 on_move(PickEvent::Hover {
892 world: Vec2::new(world[0], world[1]),
893 screen: [w.x, w.y],
894 });
895 if is_touch(&ev) {
896 on_touch_move(PickEvent::TouchMove {
897 id: ev.id.0,
898 screen: screen_of(&ev),
899 });
900 }
901 })
902 .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
903 let p = ev.position;
904 let start = press_up.borrow_mut().remove(&ev.id.0);
905 if let Some(start) = start
906 && click_within_slop_dp(start, [p.x, p.y], release_geom.get().density)
907 {
908 let g = release_geom.get();
909 let world = pick_world([p.x, p.y], g, world_size);
910 let w = ev.position_in_window();
911 let button = button_of(&ev);
912 on_up_click(PickEvent::Click {
913 world: Vec2::new(world[0], world[1]),
914 screen: [w.x, w.y],
915 button,
916 });
917 }
918 if is_touch(&ev) {
919 on_up(PickEvent::TouchUp { id: ev.id.0 });
920 }
921 })
922 .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
923 press_leave.borrow_mut().remove(&ev.id.0);
924 if is_touch(&ev) {
925 on_leave(PickEvent::TouchUp { id: ev.id.0 });
926 }
927 });
928 Canvas(modifier, move |scope: &mut DrawScope| {
929 let d = effective_density_scale();
930 let cam = draw_input.cam;
931 let cam_center = cam.effective_center();
932 let fit = effective_fit(
933 [scope.size.width / d, scope.size.height / d],
934 draw_input.world_size,
935 &cam,
936 );
937 draw_geom.set(FrameGeom {
938 fit,
939 look: [
940 cam_center[0] - draw_input.world_size[0] * 0.5,
941 cam_center[1] - draw_input.world_size[1] * 0.5,
942 ],
943 density: d,
944 viewport_px: [scope.size.width, scope.size.height],
945 roll: cam.roll,
946 pivot: cam_center,
947 });
948 let project = |wx: f32, wy: f32| -> [f32; 2] {
949 let [dx, dy] = world_to_dp_with_roll(
950 [wx, wy],
951 draw_input.world_size,
952 cam_center,
953 (fit.0, fit.1, fit.2),
954 cam.roll,
955 );
956 [dx * d, dy * d]
957 };
958 if let Some(bg) = draw_input.background {
959 scope.draw_rect(
960 Rect {
961 x: 0.0,
962 y: 0.0,
963 w: scope.size.width,
964 h: scope.size.height,
965 },
966 rgba8(bg),
967 Px(0.0),
968 );
969 }
970 for spr in draw_input.sprites.iter() {
971 let px_per_world = fit.0 * d;
976 let [px, py] = project(spr.center.x, spr.center.y);
977 let ax = if spr.flip_x {
978 1.0 - spr.anchor.x
979 } else {
980 spr.anchor.x
981 };
982 let ay = if spr.flip_y {
983 1.0 - spr.anchor.y
984 } else {
985 spr.anchor.y
986 };
987 scope.draw_rect_rotated(
988 Rect {
989 x: px - ax * spr.size.x * px_per_world,
990 y: py - ay * spr.size.y * px_per_world,
991 w: (spr.size.x * px_per_world).max(0.0),
992 h: (spr.size.y * px_per_world).max(0.0),
993 },
994 rgba8(spr.color),
995 Px(0.0),
996 cam.roll + spr.rotation,
997 repose_core::Vec2 { x: px, y: py },
998 );
999 }
1000 for t in draw_input.texts.iter() {
1001 let [tx, ty] = project(t.pos.x, t.pos.y);
1002 scope.draw_text_with_family(
1003 t.text.clone(),
1004 repose_core::Vec2 { x: tx, y: ty },
1005 rgba8(t.color),
1006 Px(t.size * fit.0 * d),
1007 t.font_family,
1008 );
1009 }
1010 if let Some(tint) = draw_input.overlay_color {
1011 scope.draw_rect(
1012 Rect {
1013 x: 0.0,
1014 y: 0.0,
1015 w: scope.size.width,
1016 h: scope.size.height,
1017 },
1018 rgba8(tint),
1019 Px(0.0),
1020 );
1021 }
1022 })
1023}
1024
1025#[allow(non_snake_case)]
1035pub fn Viewport2dGpu(
1036 input: FrameInput,
1037 geom_out: GeomHandle,
1038 uploads: Arc<[AtlasUpload]>,
1039 desc: BatchDesc,
1040 on_event: impl Fn(PickEvent) + 'static,
1041) -> View {
1042 Viewport2dGpuWithIdShared(
1043 Arc::new(input),
1044 geom_out,
1045 uploads,
1046 desc,
1047 "viewport2d.main",
1048 on_event,
1049 )
1050}
1051
1052#[allow(non_snake_case)]
1055pub fn Viewport2dGpuWithId(
1056 input: FrameInput,
1057 geom_out: GeomHandle,
1058 uploads: Arc<[AtlasUpload]>,
1059 desc: BatchDesc,
1060 batch_id: impl Into<String>,
1061 on_event: impl Fn(PickEvent) + 'static,
1062) -> View {
1063 Viewport2dGpuWithIdShared(Arc::new(input), geom_out, uploads, desc, batch_id, on_event)
1064}
1065
1066#[allow(non_snake_case)]
1067pub fn Viewport2dGpuWithIdShared(
1068 input: Arc<FrameInput>,
1069 geom_out: GeomHandle,
1070 uploads: Arc<[AtlasUpload]>,
1071 desc: BatchDesc,
1072 batch_id: impl Into<String>,
1073 on_event: impl Fn(PickEvent) + 'static,
1074) -> View {
1075 viewport2d_gpu_with_id_shared(input, geom_out, uploads, desc, batch_id, on_event, false)
1076}
1077
1078fn viewport2d_gpu_with_id_shared(
1079 input: Arc<FrameInput>,
1080 geom_out: GeomHandle,
1081 uploads: Arc<[AtlasUpload]>,
1082 desc: BatchDesc,
1083 batch_id: impl Into<String>,
1084 on_event: impl Fn(PickEvent) + 'static,
1085 overlay_separate: bool,
1086) -> View {
1087 let batch_id: String = batch_id.into();
1088 let bg_id = format!("{batch_id}.background");
1089 let overlay_id = format!("{batch_id}.overlay");
1090 let world_size = input.world_size;
1091 let pick_geom = geom_out.clone();
1092 let move_geom = geom_out.clone();
1093 let release_geom = geom_out.clone();
1094 let size_geom = geom_out.clone();
1095 let size_input = input.clone();
1096 let geom_for_payload = geom_out.clone();
1097 let on_event = Arc::new(on_event);
1098 let on_down = on_event.clone();
1099 let on_touch_down = on_event.clone();
1100 let on_move = on_event.clone();
1101 let on_touch_move = on_event.clone();
1102 let on_up = on_event.clone();
1103 let on_up_click = on_event.clone();
1104 let on_leave = on_event;
1105 let press: Arc<Mutex<HashMap<u64, [f32; 2]>>> = Arc::new(Mutex::new(HashMap::new()));
1106 let press_down = press.clone();
1107 let press_up = press.clone();
1108 let press_leave = press.clone();
1109 let payload = GpuViewport {
1110 input: input.clone(),
1111 geom: geom_for_payload.arc(),
1112 batch_id,
1113 uploads,
1114 desc,
1115 overlay_separate,
1116 bg: FullscreenPass::new(
1117 bg_id,
1118 fullscreen::SOLID_WGSL,
1119 FullscreenDesc {
1120 texture_slots: 0,
1121 filter: TextureFilter::Nearest,
1122 },
1123 ),
1124 overlay: FullscreenPass::new(
1125 overlay_id,
1126 fullscreen::SOLID_WGSL,
1127 FullscreenDesc {
1128 texture_slots: 0,
1129 filter: TextureFilter::Nearest,
1130 },
1131 ),
1132 };
1133 let modifier = Modifier::new()
1134 .fill_max_size()
1135 .on_size_changed(move |dp: repose_core::Vec2| {
1136 let d = effective_density_scale();
1137 let d = if d.is_finite() && d > 1e-6 { d } else { 1.0 };
1138 let fit = effective_fit([dp.x, dp.y], size_input.world_size, &size_input.cam);
1139 let center = size_input.cam.effective_center();
1140 size_geom.set(FrameGeom {
1141 viewport_px: [dp.x * d, dp.y * d],
1142 density: d,
1143 fit,
1144 look: [
1145 center[0] - size_input.world_size[0] * 0.5,
1146 center[1] - size_input.world_size[1] * 0.5,
1147 ],
1148 roll: size_input.cam.roll,
1149 pivot: center,
1150 });
1151 })
1152 .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
1153 let p = ev.position;
1154 let g = pick_geom.get();
1155 let world = pick_world([p.x, p.y], g, world_size);
1156 if let Ok(mut slots) = press_down.lock() {
1157 slots.insert(ev.id.0, [p.x, p.y]);
1158 }
1159 let w = ev.position_in_window();
1160 let button = button_of(&ev);
1161 on_down(PickEvent::Press {
1162 world: Vec2::new(world[0], world[1]),
1163 screen: [w.x, w.y],
1164 button,
1165 });
1166 if is_touch(&ev) {
1167 on_touch_down(PickEvent::TouchDown {
1168 id: ev.id.0,
1169 screen: screen_of(&ev),
1170 });
1171 }
1172 })
1173 .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
1174 let p = ev.position;
1175 let g = move_geom.get();
1176 let world = pick_world([p.x, p.y], g, world_size);
1177 let w = ev.position_in_window();
1178 on_move(PickEvent::Hover {
1179 world: Vec2::new(world[0], world[1]),
1180 screen: [w.x, w.y],
1181 });
1182 if is_touch(&ev) {
1183 on_touch_move(PickEvent::TouchMove {
1184 id: ev.id.0,
1185 screen: screen_of(&ev),
1186 });
1187 }
1188 })
1189 .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
1190 let p = ev.position;
1191 let start = press_up.lock().ok().and_then(|mut s| s.remove(&ev.id.0));
1192 if let Some(start) = start
1193 && click_within_slop_dp(start, [p.x, p.y], release_geom.get().density)
1194 {
1195 let g = release_geom.get();
1196 let world = pick_world([p.x, p.y], g, world_size);
1197 let w = ev.position_in_window();
1198 let button = button_of(&ev);
1199 on_up_click(PickEvent::Click {
1200 world: Vec2::new(world[0], world[1]),
1201 screen: [w.x, w.y],
1202 button,
1203 });
1204 }
1205 if is_touch(&ev) {
1206 on_up(PickEvent::TouchUp { id: ev.id.0 });
1207 }
1208 })
1209 .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
1210 if let Ok(mut s) = press_leave.lock() {
1211 s.remove(&ev.id.0);
1212 }
1213 if is_touch(&ev) {
1214 on_leave(PickEvent::TouchUp { id: ev.id.0 });
1215 }
1216 });
1217 Embedded(modifier, Callback::new(payload))
1218}
1219
1220struct GpuViewport {
1221 input: Arc<FrameInput>,
1222 geom: Arc<Mutex<FrameGeom>>,
1223 batch_id: String,
1224 uploads: Arc<[AtlasUpload]>,
1225 desc: BatchDesc,
1226 overlay_separate: bool,
1227 bg: FullscreenPass,
1228 overlay: FullscreenPass,
1229}
1230
1231impl WgpuCallback for GpuViewport {
1232 fn resource_key(&self) -> Option<&str> {
1233 Some("repame.viewport2d.resources")
1234 }
1235
1236 fn prepare(
1237 &self,
1238 device: &wgpu::Device,
1239 queue: &wgpu::Queue,
1240 encoder: &mut wgpu::CommandEncoder,
1241 screen: &ScreenDescriptor,
1242 resources: &mut CallbackResources,
1243 ) -> Vec<wgpu::CommandBuffer> {
1244 let (vp_phys, density) = self
1245 .geom
1246 .lock()
1247 .map(|g| {
1248 let d = if g.density.is_finite() && g.density > 1e-6 {
1249 g.density
1250 } else {
1251 1.0
1252 };
1253 (g.viewport_px, d)
1254 })
1255 .unwrap_or(([1.0, 1.0], 1.0));
1256 let dp = if vp_phys[0] > 1.0 && vp_phys[1] > 1.0 {
1257 [vp_phys[0] / density, vp_phys[1] / density]
1258 } else {
1259 self.input.viewport_dp
1260 };
1261 let mut batch = SpriteBatch::with_id(self.batch_id.clone(), self.desc);
1262 batch.set_camera(self.input.cam.fit_matrix(dp, self.input.world_size));
1263 for s in &self.input.sprites {
1264 batch.push_sprite(s);
1265 }
1266 batch.prepare_with_uploads(
1267 device,
1268 queue,
1269 encoder,
1270 screen,
1271 resources,
1272 self.uploads.as_ref(),
1273 );
1274 let composite = post::needs_post(&self.input);
1275 if let Some(bg) = self.input.background
1277 && !composite
1278 {
1279 let words = [bg[0], bg[1], bg[2], bg[3]];
1280 self.bg.prepare_with(
1281 device,
1282 queue,
1283 screen,
1284 resources,
1285 bytemuck::cast_slice(&words),
1286 &[] as &[FullscreenTexture],
1287 );
1288 }
1289 if !self.overlay_separate
1290 && let Some(ov) = self.input.overlay_color
1291 {
1292 let words = [ov[0], ov[1], ov[2], ov[3]];
1293 self.overlay.prepare_with(
1294 device,
1295 queue,
1296 screen,
1297 resources,
1298 bytemuck::cast_slice(&words),
1299 &[] as &[FullscreenTexture],
1300 );
1301 }
1302 if composite {
1303 let w = vp_phys[0].max(1.0) as u32;
1304 let h = vp_phys[1].max(1.0) as u32;
1305 let fit = effective_fit(dp, self.input.world_size, &self.input.cam);
1309 let roll = self.input.cam.roll;
1310 let center = self.input.cam.effective_center();
1311 let mut shadow_maps: Vec<Vec<[f32; 2]>> = Vec::new();
1314 let lights: &[Light2d] = if self.input.lights.is_empty() {
1315 &[]
1316 } else {
1317 let n = self.input.lights.len().min(light2d::MAX_LIGHTS);
1318 for (light, bins) in self.input.lights[..n]
1319 .iter()
1320 .zip(light2d::resolve_shadow_bins(&self.input.lights))
1321 {
1322 if bins == 0 {
1323 shadow_maps.push(Vec::new());
1324 continue;
1325 }
1326 let mut resolved = *light;
1327 resolved.shadow_bins = bins;
1328 let mut map = Vec::new();
1329 light2d::angular_shadow_map(&resolved, &self.input.occluders, &mut map);
1330 shadow_maps.push(map);
1331 }
1332 &self.input.lights[..n]
1333 };
1334 post::prepare_composite(
1335 device,
1336 queue,
1337 encoder,
1338 screen,
1339 resources,
1340 w,
1341 h,
1342 &post::CompositeDesc {
1343 batch_id: self.batch_id.as_str(),
1344 amount: self.input.chroma,
1345 background: self.input.background,
1346 ambient: self.input.ambient.unwrap_or([0.0, 0.0, 0.0]),
1347 lights,
1348 shadow_maps: &shadow_maps,
1349 fit,
1350 roll,
1351 world: self.input.world_size,
1352 center,
1353 density,
1354 scissor: fitted_box_scissor_in(
1355 self.input.world_size,
1356 fit,
1357 roll,
1358 density,
1359 [0.0, 0.0],
1360 [0.0, 0.0, w as f32, h as f32],
1361 ),
1362 },
1363 );
1364 }
1365 Vec::new()
1366 }
1367
1368 fn paint(
1369 &self,
1370 info: repose_core::PaintCallbackInfo,
1371 rpass: &mut CallbackRenderPass<'_, '_>,
1372 resources: &CallbackResources,
1373 ) {
1374 let d = if info.pixels_per_point.is_finite() && info.pixels_per_point > 1e-6 {
1375 info.pixels_per_point
1376 } else {
1377 1.0
1378 };
1379 let vp = [info.viewport.w, info.viewport.h];
1380 let fit = effective_fit(
1381 [vp[0] / d, vp[1] / d],
1382 self.input.world_size,
1383 &self.input.cam,
1384 );
1385 let cam = self.input.cam.effective_center();
1386 let roll = self.input.cam.roll;
1387 if let Ok(mut g) = self.geom.lock() {
1388 *g = FrameGeom {
1389 fit,
1390 look: [
1391 cam[0] - self.input.world_size[0] * 0.5,
1392 cam[1] - self.input.world_size[1] * 0.5,
1393 ],
1394 density: d,
1395 viewport_px: vp,
1396 roll,
1397 pivot: cam,
1398 };
1399 }
1400 if post::needs_post(&self.input) {
1401 post::paint_composite(self.batch_id.as_str(), rpass, resources);
1402 } else {
1403 if self.input.background.is_some() {
1404 self.bg.paint(info, rpass, resources);
1405 }
1406 let restore = (
1414 info.clip_rect.x.ceil().max(0.0) as u32,
1415 info.clip_rect.y.ceil().max(0.0) as u32,
1416 ((info.clip_rect.x + info.clip_rect.w).floor().max(0.0) as u32)
1417 .saturating_sub(info.clip_rect.x.ceil().max(0.0) as u32),
1418 ((info.clip_rect.y + info.clip_rect.h).floor().max(0.0) as u32)
1419 .saturating_sub(info.clip_rect.y.ceil().max(0.0) as u32),
1420 );
1421 match fitted_box_scissor(&info, self.input.world_size, fit, roll) {
1422 Some(sc) => {
1423 rpass.set_scissor_rect(sc.0, sc.1, sc.2, sc.3);
1424 batch::draw_batch_with_id_callback(self.batch_id.as_str(), rpass, resources);
1425 rpass.set_scissor_rect(restore.0, restore.1, restore.2, restore.3);
1426 }
1427 None => {
1428 batch::draw_batch_with_id_callback(self.batch_id.as_str(), rpass, resources);
1429 }
1430 }
1431 }
1432 if !self.overlay_separate && self.input.overlay_color.is_some() {
1433 self.overlay.paint(info, rpass, resources);
1434 }
1435 }
1436}
1437
1438#[allow(non_snake_case)]
1450pub fn Viewport2dGpuWithHud(
1451 input: FrameInput,
1452 geom: GeomHandle,
1453 uploads: Arc<[AtlasUpload]>,
1454 desc: BatchDesc,
1455 on_event: impl Fn(PickEvent) + 'static,
1456) -> View {
1457 Viewport2dGpuWithHudShared(Arc::new(input), geom, uploads, desc, on_event)
1458}
1459
1460#[allow(non_snake_case)]
1461pub fn Viewport2dGpuWithHudShared(
1462 input: Arc<FrameInput>,
1463 geom: GeomHandle,
1464 uploads: Arc<[AtlasUpload]>,
1465 desc: BatchDesc,
1466 on_event: impl Fn(PickEvent) + 'static,
1467) -> View {
1468 use repose_ui::ViewExt as _;
1469 let hud_input = input.clone();
1470 let hud_geom = geom.clone();
1471 let hud = Canvas(
1472 Modifier::new().fill_max_size().hit_passthrough(),
1473 move |scope: &mut DrawScope| {
1474 let input = hud_input.as_ref();
1475 let cam = input.cam;
1476 let world_size = input.world_size;
1477 let d = effective_density_scale();
1478 let cam_center = cam.effective_center();
1479 let fit = effective_fit(
1480 [scope.size.width / d, scope.size.height / d],
1481 world_size,
1482 &cam,
1483 );
1484 hud_geom.set(FrameGeom {
1485 fit,
1486 look: [
1487 cam_center[0] - world_size[0] * 0.5,
1488 cam_center[1] - world_size[1] * 0.5,
1489 ],
1490 density: d,
1491 viewport_px: [scope.size.width, scope.size.height],
1492 roll: cam.roll,
1493 pivot: cam_center,
1494 });
1495 let project = |wx: f32, wy: f32| -> [f32; 2] {
1496 let [dx, dy] = world_to_dp_with_roll(
1497 [wx, wy],
1498 world_size,
1499 cam_center,
1500 (fit.0, fit.1, fit.2),
1501 cam.roll,
1502 );
1503 [dx * d, dy * d]
1504 };
1505 for t in input.texts.iter() {
1506 let [tx, ty] = project(t.pos.x, t.pos.y);
1507 scope.draw_text_with_family(
1508 t.text.clone(),
1509 repose_core::Vec2 { x: tx, y: ty },
1510 rgba8(t.color),
1511 Px(t.size * fit.0 * d),
1512 t.font_family,
1513 );
1514 }
1515 if let Some(tint) = input.overlay_color {
1516 scope.draw_rect(
1517 Rect {
1518 x: 0.0,
1519 y: 0.0,
1520 w: scope.size.width,
1521 h: scope.size.height,
1522 },
1523 rgba8(tint),
1524 Px(0.0),
1525 );
1526 }
1527 },
1528 );
1529 repose_ui::ZStack(Modifier::new().fill_max_size())
1530 .child(viewport2d_gpu_with_id_shared(
1531 input,
1532 geom,
1533 uploads,
1534 desc,
1535 "viewport2d.main",
1536 on_event,
1537 true,
1538 ))
1539 .child(hud)
1540}
1541#[allow(non_snake_case)]
1549pub fn ActorFrame(
1550 center: [f32; 2],
1551 size: [f32; 2],
1552 geom: GeomHandle,
1553 mirror_x: bool,
1554 child: View,
1555) -> View {
1556 ActorFrameWithOrigin(center, size, geom, mirror_x, [0.5, 0.5], child)
1557}
1558
1559#[allow(non_snake_case)]
1565pub fn ActorFrameWithOrigin(
1566 center: [f32; 2],
1567 size: [f32; 2],
1568 geom: GeomHandle,
1569 mirror_x: bool,
1570 origin: [f32; 2],
1571 child: View,
1572) -> View {
1573 let ([ox, oy], [w, h]) = surface_dp(center, size, geom.get());
1574 let mut modifier = Modifier::new().size(Dp(w), Dp(h)).absolute().offset(
1577 Some(Dp(ox)),
1578 Some(Dp(oy)),
1579 None,
1580 None,
1581 );
1582 if mirror_x {
1583 modifier = modifier
1584 .transform_origin(origin[0].clamp(0.0, 1.0), origin[1].clamp(0.0, 1.0))
1585 .scale2(-1.0, 1.0);
1586 }
1587 UiBox(modifier).child(child)
1588}
1589
1590#[cfg(test)]
1591mod tests {
1592 use super::*;
1593 use repose_core::locals::{Density, set_density_default};
1594
1595 #[test]
1596 fn contain_fit_roundtrip_at_density_125() {
1597 set_density_default(Density { scale: 1.25 });
1599 let d = effective_density_scale();
1600 let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
1602 let cam = [400.0, 300.0];
1603 let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
1605 let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
1607 set_density_default(Density { scale: 1.0 });
1608 assert!(
1609 (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
1610 "dp fit, got {fit:?}"
1611 );
1612 assert!(
1613 (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
1614 "draw forward, got ({dx},{dy})"
1615 );
1616 assert!(
1617 (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
1618 "pick inverse, got ({wx},{wy})"
1619 );
1620 let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
1622 let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
1623 assert!(
1624 (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
1625 "shifted round trip, got {back:?}"
1626 );
1627 }
1628
1629 #[test]
1630 fn gpu_viewport_honors_cam_and_background() {
1631 use repose_core::{Color, Rect, Scene, SceneNode};
1634 use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
1635
1636 let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
1637 Ok(r) => r,
1638 Err(e) => {
1639 eprintln!("SKIP gpu viewport test (no GPU): {e}");
1640 return;
1641 }
1642 };
1643 let page = [255, 0, 255, 255].repeat(4);
1645 let uploads = vec![AtlasUpload {
1646 page: 0,
1647 x: 0,
1648 y: 0,
1649 w: 2,
1650 h: 2,
1651 rgba: page,
1652 }];
1653 let cam = Camera2d {
1654 center: Vec2::new(64.0, 64.0),
1655 offset: Vec2::ZERO,
1656 units_per_pixel: 0.5,
1657 zoom: 1.0,
1658 roll: 0.0,
1659 fit: FitMode::Keep,
1660 };
1661 let input = FrameInput {
1662 cam,
1663 world_size: [128.0, 128.0],
1664 viewport_dp: [256.0, 256.0],
1665 sprites: vec![SpriteInstance {
1666 center: Vec2::new(64.0, 64.0),
1667 size: Vec2::new(32.0, 32.0),
1668 uv_min: Vec2::new(0.0, 0.0),
1669 uv_max: Vec2::new(1.0, 1.0),
1670 color: [1.0, 1.0, 1.0, 1.0],
1671 ..Default::default()
1672 }],
1673 texts: Vec::new(),
1674 background: Some([0.0, 0.0, 1.0, 1.0]),
1675 overlay_color: None,
1676 chroma: 0.0,
1677 ..Default::default()
1678 };
1679 let geom = Arc::new(Mutex::new(FrameGeom {
1680 fit: (1.0, 0.0, 0.0),
1681 look: [0.0, 0.0],
1682 density: 1.0,
1683 viewport_px: [256.0, 256.0],
1684 roll: 0.0,
1685 pivot: [64.0, 64.0],
1686 }));
1687 let payload = GpuViewport {
1688 input: Arc::new(input.clone()),
1689 geom,
1690 batch_id: "test.viewport2d.main".to_string(),
1691 uploads: Arc::from(uploads),
1692 desc: BatchDesc {
1693 layer_size: 2,
1694 layers: 1,
1695 filter: TextureFilter::Nearest,
1696 ..Default::default()
1697 },
1698 overlay_separate: false,
1699 bg: FullscreenPass::new(
1700 "test.viewport2d.background",
1701 fullscreen::SOLID_WGSL,
1702 FullscreenDesc {
1703 texture_slots: 0,
1704 filter: TextureFilter::Nearest,
1705 },
1706 ),
1707 overlay: FullscreenPass::new(
1708 "test.viewport2d.overlay",
1709 fullscreen::SOLID_WGSL,
1710 FullscreenDesc {
1711 texture_slots: 0,
1712 filter: TextureFilter::Nearest,
1713 },
1714 ),
1715 };
1716 let scene = Scene {
1717 clear_color: Color::from_rgba(0, 0, 0, 255),
1718 nodes: vec![SceneNode::Callback {
1719 rect: Rect {
1720 x: 0.0,
1721 y: 0.0,
1722 w: 256.0,
1723 h: 256.0,
1724 },
1725 payload: Callback::new(payload),
1726 }],
1727 };
1728 let px = renderer
1729 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1730 .expect("offscreen render");
1731 let at = |x: u32, y: u32| -> [u8; 4] {
1732 let i = ((y * 256 + x) * 4) as usize;
1733 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1734 };
1735 assert_eq!(at(8, 8), [0, 0, 255, 255], "background fill");
1737 assert_eq!(at(247, 247), [0, 0, 255, 255], "background fill");
1738 assert_eq!(at(128, 128), [255, 0, 255, 255], "cam-centered quad");
1739 let overlay_input = FrameInput {
1740 overlay_color: Some([1.0, 0.0, 0.0, 1.0]),
1741 ..input
1742 };
1743 let overlay_payload = GpuViewport {
1744 input: Arc::new(overlay_input),
1745 geom: Arc::new(Mutex::new(FrameGeom {
1746 fit: (1.0, 0.0, 0.0),
1747 look: [0.0, 0.0],
1748 density: 1.0,
1749 viewport_px: [256.0, 256.0],
1750 roll: 0.0,
1751 pivot: [64.0, 64.0],
1752 })),
1753 batch_id: "test.viewport2d.overlay".to_string(),
1754 uploads: Arc::from(vec![AtlasUpload {
1755 page: 0,
1756 x: 0,
1757 y: 0,
1758 w: 2,
1759 h: 2,
1760 rgba: [255, 0, 255, 255].repeat(4),
1761 }]),
1762 desc: BatchDesc {
1763 layer_size: 2,
1764 layers: 1,
1765 filter: TextureFilter::Nearest,
1766 ..Default::default()
1767 },
1768 overlay_separate: false,
1769 bg: FullscreenPass::new(
1770 "test.viewport2d.background",
1771 fullscreen::SOLID_WGSL,
1772 FullscreenDesc {
1773 texture_slots: 0,
1774 filter: TextureFilter::Nearest,
1775 },
1776 ),
1777 overlay: FullscreenPass::new(
1778 "test.viewport2d.overlay",
1779 fullscreen::SOLID_WGSL,
1780 FullscreenDesc {
1781 texture_slots: 0,
1782 filter: TextureFilter::Nearest,
1783 },
1784 ),
1785 };
1786 let scene = Scene {
1787 clear_color: Color::from_rgba(0, 0, 0, 255),
1788 nodes: vec![SceneNode::Callback {
1789 rect: Rect {
1790 x: 0.0,
1791 y: 0.0,
1792 w: 256.0,
1793 h: 256.0,
1794 },
1795 payload: Callback::new(overlay_payload),
1796 }],
1797 };
1798 let px = renderer
1799 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1800 .expect("offscreen render");
1801 let at = |x: u32, y: u32| -> [u8; 4] {
1802 let i = ((y * 256 + x) * 4) as usize;
1803 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1804 };
1805 assert_eq!(at(8, 8), [255, 0, 0, 255], "overlay covers background");
1806 assert_eq!(at(128, 128), [255, 0, 0, 255], "overlay covers sprite");
1807 }
1808
1809 #[test]
1810 fn surface_dp_matches_board_point_under_shake() {
1811 let geom = FrameGeom {
1814 fit: (1.0, 100.0, 0.0),
1815 look: [0.0, 0.0],
1816 density: 1.25,
1817 viewport_px: [1250.0, 750.0],
1818 roll: 0.0,
1819 pivot: [400.0, 300.0],
1820 };
1821 let ([ox, oy], [w, h]) = surface_dp([400.0, 300.0], [64.0, 80.0], geom);
1822 assert!(
1823 (ox - 468.0).abs() < 1e-4 && (oy - 260.0).abs() < 1e-4,
1824 "offset, got ({ox},{oy})"
1825 );
1826 assert!(
1827 (w - 64.0).abs() < 1e-4 && (h - 80.0).abs() < 1e-4,
1828 "size, got ({w},{h})"
1829 );
1830 let board = world_to_dp([400.0, 300.0], [800.0, 600.0], [400.0, 300.0], geom.fit);
1832 assert!(
1833 (ox + w * 0.5 - board[0]).abs() < 1e-4 && (oy + h * 0.5 - board[1]).abs() < 1e-4,
1834 "surface glued to board point"
1835 );
1836 let shaken = FrameGeom {
1837 look: [10.0, -10.0],
1838 ..geom
1839 };
1840 let ([sx, sy], _) = surface_dp([400.0, 300.0], [64.0, 80.0], shaken);
1841 let sboard = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], shaken.fit);
1842 assert!(
1843 (sx + w * 0.5 - sboard[0]).abs() < 1e-4 && (sy + h * 0.5 - sboard[1]).abs() < 1e-4,
1844 "surface glued under shake"
1845 );
1846 }
1847
1848 #[test]
1849 fn contain_fit_has_no_silent_clamp() {
1850 for (canvas, world) in [
1851 ([8000.0, 6000.0], [80.0, 60.0]),
1852 ([80.0, 60.0], [8000.0, 6000.0]),
1853 ([1000.0, 750.0], [800.0, 600.0]),
1854 ] {
1855 let fit = contain_fit(canvas, world);
1856 let cam = [world[0] * 0.5, world[1] * 0.5];
1857 let dp = world_to_dp([cam[0], cam[1]], world, cam, fit);
1858 let back = dp_to_world(dp, world, cam, fit);
1859 assert!(
1860 (back[0] - cam[0]).abs() < 1e-3 && (back[1] - cam[1]).abs() < 1e-3,
1861 "round trip for {canvas:?}/{world:?}, fit {fit:?} gave {back:?}"
1862 );
1863 }
1864 assert_eq!(contain_fit([0.0, 600.0], [800.0, 600.0]), (1.0, 0.0, 0.0));
1865 assert_eq!(
1866 dp_to_world(
1867 [10.0, 10.0],
1868 [800.0, 600.0],
1869 [400.0, 300.0],
1870 (0.0, 0.0, 0.0)
1871 ),
1872 [400.0, 300.0]
1873 );
1874 }
1875
1876 #[test]
1879 fn fit_mode_keep_is_contain_fit() {
1880 for (canvas, world) in [
1881 ([1600.0, 900.0], [800.0, 800.0]),
1882 ([900.0, 1600.0], [800.0, 800.0]),
1883 ([1000.0, 1000.0], [800.0, 600.0]),
1884 ] {
1885 let cam = Camera2d {
1886 center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
1887 fit: FitMode::Keep,
1888 ..Default::default()
1889 };
1890 let fit = effective_fit(canvas, world, &cam);
1891 assert_eq!(fit, contain_fit(canvas, world), "{canvas:?}/{world:?}");
1892 let (s, ox, oy) = fit;
1893 assert!((ox - (canvas[0] - world[0] * s) * 0.5).abs() < 1e-4);
1895 assert!((oy - (canvas[1] - world[1] * s) * 0.5).abs() < 1e-4);
1896 assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
1897 }
1898 }
1899
1900 #[test]
1904 fn fit_mode_expand_reveals_more_and_never_crops() {
1905 let canvas = [1600.0, 900.0];
1906 let world = [800.0, 800.0];
1907 let cam = Camera2d {
1908 center: Vec2::new(world[0] * 0.5, world[1] * 0.5),
1909 fit: FitMode::Expand,
1910 ..Default::default()
1911 };
1912 let (s, ox, oy) = effective_fit(canvas, world, &cam);
1913 let keep = effective_fit(
1914 canvas,
1915 world,
1916 &Camera2d {
1917 fit: FitMode::Keep,
1918 ..cam
1919 },
1920 );
1921 assert_eq!(s, keep.0, "expand must not change the uniform scale");
1922 assert_eq!((ox, oy), (0.0, 0.0), "expand anchors at the origin");
1923 assert!(world[0] * s <= canvas[0] + 1e-4 && world[1] * s <= canvas[1] + 1e-4);
1925 let visible = [canvas[0] / s, canvas[1] / s];
1928 let exact = [
1929 (visible[0] - world[0]).abs() < 1e-3,
1930 (visible[1] - world[1]).abs() < 1e-3,
1931 ];
1932 assert!(exact[0] ^ exact[1], "one axis exact: {visible:?}");
1933 if exact[0] {
1934 assert!(visible[1] > world[1], "y reveals: {visible:?}");
1935 } else {
1936 assert!(visible[0] > world[0], "x reveals: {visible:?}");
1937 }
1938 let dp = world_to_dp([100.0, 700.0], world, cam.effective_center(), (s, ox, oy));
1940 let back = dp_to_world(dp, world, cam.effective_center(), (s, ox, oy));
1941 assert!((back[0] - 100.0).abs() < 1e-3 && (back[1] - 700.0).abs() < 1e-3);
1942 }
1943
1944 #[test]
1945 fn canvas_rotated_rect_matches_instance_rows() {
1946 let cam = Camera2d {
1950 center: Vec2::new(400.0, 300.0),
1951 offset: Vec2::ZERO,
1952 units_per_pixel: 1.0,
1953 zoom: 1.0,
1954 roll: 0.3,
1955 fit: FitMode::Keep,
1956 };
1957 let world_size = [800.0, 600.0];
1958 let canvas_dp = [800.0, 600.0];
1959 let fit = effective_fit(canvas_dp, world_size, &cam);
1960 let center = cam.effective_center();
1961 let project = |wx: f32, wy: f32| -> [f32; 2] {
1962 world_to_dp_with_roll([wx, wy], world_size, center, fit, cam.roll)
1963 };
1964 let k = fit.0;
1965 for (rotation, flip_x, flip_y, anchor) in [
1966 (0.7f32, false, false, [0.5, 0.5]),
1967 (0.7, true, false, [0.5, 0.5]),
1968 (0.7, false, true, [0.5, 0.5]),
1969 (0.7, true, true, [0.5, 0.5]),
1970 (-1.2, true, false, [0.0, 0.0]),
1971 (2.1, false, true, [1.0, 0.25]),
1972 (0.0, true, false, [0.5, 0.5]),
1973 ] {
1974 let size = [64.0f32, 40.0];
1975 let ctr = [400.0f32, 300.0];
1976 let (row0, row1) = batch::instance_rows(ctr, size, rotation, anchor, flip_x, flip_y);
1978 let mut expect = [[0.0f32; 2]; 4];
1979 for (i, corner) in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]
1980 .iter()
1981 .enumerate()
1982 {
1983 let wx = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
1984 let wy = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
1985 expect[i] = project(wx, wy);
1986 }
1987 let ax = if flip_x { 1.0 - anchor[0] } else { anchor[0] };
1991 let ay = if flip_y { 1.0 - anchor[1] } else { anchor[1] };
1992 let [px, py] = project(ctr[0], ctr[1]);
1993 let angle = cam.roll + rotation;
1994 let (c, s) = (angle.cos(), angle.sin());
1995 let (ox, oy) = (px - ax * size[0] * k, py - ay * size[1] * k);
1996 let w = size[0] * k;
1997 let h = size[1] * k;
1998 for (i, corner) in [[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]].iter().enumerate() {
1999 let rx = px + (ox + corner[0] - px) * c - (oy + corner[1] - py) * s;
2000 let ry = py + (ox + corner[0] - px) * s + (oy + corner[1] - py) * c;
2001 let mut best = f32::INFINITY;
2002 for e in expect {
2003 best = best.min((rx - e[0]).hypot(ry - e[1]));
2004 }
2005 assert!(
2006 best < 1e-2,
2007 "rot={rotation} flip=({flip_x},{flip_y}) anchor={anchor:?}: corner {i} ({rx},{ry}) matches none of {expect:?}"
2008 );
2009 }
2010 }
2011 }
2012
2013 #[test]
2014 #[allow(deprecated)]
2015 fn camera_guards_div_by_zero() {
2016 let bad = Camera2d {
2017 center: Vec2::ZERO,
2018 offset: Vec2::ZERO,
2019 units_per_pixel: 0.0,
2020 zoom: 0.0,
2021 roll: 0.0,
2022 fit: FitMode::Keep,
2023 };
2024 let m = bad.view_proj([0.0, 0.0]);
2025 assert!(m.is_finite(), "view_proj stays finite, got {m:?}");
2026 assert_eq!(bad.screen_to_world([0.0, 0.0], [5.0, 5.0]), Vec2::ZERO);
2027 }
2028
2029 #[test]
2030 fn fit_matrix_canvas_gpu_picks_agree() {
2031 let cam = Camera2d {
2032 center: Vec2::new(410.0, 290.0),
2033 offset: Vec2::ZERO,
2034 units_per_pixel: 1.0,
2035 zoom: 2.0,
2036 roll: 0.0,
2037 fit: FitMode::Keep,
2038 };
2039 let world_size = [800.0, 600.0];
2040 let density = 1.25;
2041 let vp_phys = [1250.0, 750.0];
2042 let canvas_dp = [vp_phys[0] / density, vp_phys[1] / density];
2043 let cam_center = cam.effective_center();
2044 let fit = effective_fit(canvas_dp, world_size, &cam);
2045 assert!((fit.0 - 2.0).abs() < 1e-4, "effective fit, got {fit:?}");
2046 let vp = cam.fit_matrix(canvas_dp, world_size);
2047 assert!(vp.is_finite());
2048 for world in [[400.0, 300.0], [410.0, 290.0], [0.0, 0.0], [800.0, 600.0]] {
2049 let [dx, dy] = world_to_dp(world, world_size, cam_center, fit);
2050 let px = [dx * density, dy * density];
2051 let ndc = vp.project_point3(glam::Vec3::new(world[0], world[1], 0.0));
2052 let gpu_px = [
2053 (ndc.x + 1.0) * 0.5 * vp_phys[0],
2054 (1.0 - ndc.y) * 0.5 * vp_phys[1],
2055 ];
2056 assert!(
2057 (px[0] - gpu_px[0]).abs() < 1e-2 && (px[1] - gpu_px[1]).abs() < 1e-2,
2058 "canvas/GPU disagree for {world:?}: canvas {px:?} vs gpu {gpu_px:?}"
2059 );
2060 let back =
2061 cam.dp_to_world_pt(canvas_dp, world_size, [px[0] / density, px[1] / density]);
2062 assert!(
2063 (back.x - world[0]).abs() < 1e-3 && (back.y - world[1]).abs() < 1e-3,
2064 "pick round trip for {world:?} gave {back:?}"
2065 );
2066 }
2067 }
2068
2069 #[test]
2070 fn letterbox_pick_matches_draw() {
2071 let cam = Camera2d {
2073 center: Vec2::new(400.0, 400.0),
2074 ..Default::default()
2075 };
2076 let fit = effective_fit([1600.0, 900.0], [800.0, 800.0], &cam);
2077 let dp = world_to_dp([400.0, 400.0], [800.0, 800.0], cam.effective_center(), fit);
2078 let back = dp_to_world(dp, [800.0, 800.0], cam.effective_center(), fit);
2079 assert!((back[0] - 400.0).abs() < 1e-3 && (back[1] - 400.0).abs() < 1e-3);
2080 let m = cam.fit_matrix([1600.0, 900.0], [800.0, 800.0]);
2082 let plain = fit_view_proj([1600.0, 900.0], [800.0, 800.0], cam.effective_center(), fit);
2083 let dm = m - plain;
2084 let max = dm
2085 .to_cols_array()
2086 .iter()
2087 .fold(0.0f32, |a, b| a.max(b.abs()));
2088 assert!(max < 1e-5);
2089 }
2090
2091 #[test]
2092 fn picks_ignore_viewport_origin() {
2093 let geom = FrameGeom {
2094 fit: (1.0, 100.0, 0.0),
2095 look: [0.0, 0.0],
2096 density: 1.25,
2097 viewport_px: [1250.0, 750.0],
2098 roll: 0.0,
2099 pivot: [400.0, 300.0],
2100 };
2101 let world_size = [800.0, 600.0];
2102 let a = pick_world([125.0, 75.0], geom, world_size);
2103 let b = pick_world([125.0, 75.0], geom, world_size);
2104 assert_eq!(a, b);
2105 let [wx, wy] = pick_world([125.0, 75.0], geom, world_size);
2106 assert!(
2107 (wx - 0.0).abs() < 1e-4 && (wy - 60.0).abs() < 1e-4,
2108 "local pick, got ({wx},{wy})"
2109 );
2110 }
2111
2112 #[test]
2113 fn offset_shifts_look_and_draw_together() {
2114 let world_size = [800.0, 600.0];
2117 let canvas_dp = [1000.0, 600.0];
2118 let mut cam = Camera2d {
2119 center: Vec2::new(400.0, 300.0),
2120 offset: Vec2::new(10.0, -10.0),
2121 units_per_pixel: 1.0,
2122 zoom: 1.0,
2123 roll: 0.0,
2124 fit: FitMode::Keep,
2125 };
2126 assert_eq!(cam.effective_center(), [410.0, 290.0]);
2127 let fit = effective_fit(canvas_dp, world_size, &cam);
2128 let dp = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
2129 let back = dp_to_world(dp, world_size, cam.effective_center(), fit);
2130 assert!((back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4);
2131 cam.offset = Vec2::ZERO;
2132 let dp2 = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
2133 assert!((dp[0] - dp2[0] + 10.0).abs() < 1e-4);
2135 assert!((dp[1] - dp2[1] - 10.0).abs() < 1e-4);
2136 }
2137
2138 #[test]
2139 fn touch_gate_and_screen_space() {
2140 use repose_core::Vec2 as RVec2;
2141 use repose_core::input::{
2142 Modifiers, PointerButton, PointerEvent, PointerEventKind, PointerId, PointerKind,
2143 };
2144 let ev_of = |kind: PointerKind| {
2145 let mut ev = PointerEvent::new(
2146 PointerId(3),
2147 kind,
2148 PointerEventKind::Down(PointerButton::Primary),
2149 RVec2 { x: 15.0, y: 27.0 },
2150 1.0,
2151 Modifiers::default(),
2152 );
2153 ev.origin = RVec2 { x: 5.0, y: 7.0 };
2154 ev.position = RVec2 { x: 10.0, y: 20.0 };
2155 ev
2156 };
2157 assert!(is_touch(&ev_of(PointerKind::Touch)));
2159 assert!(is_touch(&ev_of(PointerKind::Pen)));
2160 assert!(!is_touch(&ev_of(PointerKind::Mouse)));
2161 let ev = ev_of(PointerKind::Touch);
2165 assert_eq!(ev.origin + ev.position, ev.position_in_window());
2166 assert_eq!(screen_of(&ev), [15.0, 27.0]);
2167 }
2168
2169 #[test]
2170 fn presses_track_per_pointer_id() {
2171 use std::cell::RefCell;
2172 use std::collections::HashMap;
2173 use std::rc::Rc;
2174 let presses: Rc<RefCell<HashMap<u64, [f32; 2]>>> = Rc::new(RefCell::new(HashMap::new()));
2175 let down = presses.clone();
2176 let up = presses.clone();
2177 let leave = presses.clone();
2178 let press_down = move |id: u64, at: [f32; 2]| {
2179 down.borrow_mut().insert(id, at);
2180 };
2181 let press_up = move |id: u64| up.borrow_mut().remove(&id);
2182 let press_leave = move |id: u64| {
2183 leave.borrow_mut().remove(&id);
2184 };
2185 press_down(1, [10.0, 10.0]);
2186 press_down(2, [200.0, 200.0]);
2187 assert_eq!(press_up(1), Some([10.0, 10.0]));
2188 assert_eq!(
2189 presses.borrow().get(&2),
2190 Some(&[200.0, 200.0]),
2191 "second finger press survives first finger lift"
2192 );
2193 press_leave(2);
2194 assert!(presses.borrow().is_empty());
2195 }
2196
2197 #[test]
2198 fn click_slop_cancels_drags() {
2199 assert!(click_within_slop([100.0, 100.0], [105.0, 105.0]));
2200 assert!(click_within_slop_dp([100.0, 100.0], [105.0, 105.0], 1.0));
2201 assert!(!click_within_slop_dp(
2202 [100.0, 100.0],
2203 [100.0 + CLICK_SLOP_DP + 1.0, 100.0],
2204 1.0
2205 ));
2206 }
2207
2208 #[test]
2209 fn click_slop_is_density_independent() {
2210 assert!(click_within_slop_dp([0.0, 0.0], [36.0, 0.0], 3.0));
2212 assert!(!click_within_slop_dp([0.0, 0.0], [37.0, 0.0], 3.0));
2213 assert!(click_within_slop_dp([0.0, 0.0], [12.0, 0.0], 1.0));
2214 assert!(!click_within_slop_dp([0.0, 0.0], [13.0, 0.0], 1.0));
2215 }
2216
2217 #[test]
2218 fn aim_ignores_shake_while_pick_follows_it() {
2219 use super::{aim_world_unshaken, pick_world};
2220 let world_size = [800.0, 600.0];
2221 let canvas_dp = [800.0, 600.0];
2222 let mut cam = Camera2d {
2223 center: Vec2::new(400.0, 300.0),
2224 offset: Vec2::new(10.0, -5.0),
2225 units_per_pixel: 1.0,
2226 zoom: 1.0,
2227 roll: 0.0,
2228 fit: FitMode::Keep,
2229 };
2230 let fit = effective_fit(canvas_dp, world_size, &cam);
2231 let shaken = FrameGeom {
2232 fit,
2233 look: [
2234 cam.effective_center()[0] - world_size[0] * 0.5,
2235 cam.effective_center()[1] - world_size[1] * 0.5,
2236 ],
2237 density: 1.0,
2238 viewport_px: [800.0, 600.0],
2239 roll: 0.0,
2240 pivot: cam.effective_center(),
2241 };
2242 let px = [400.0, 300.0];
2243 let pick = pick_world(px, shaken, world_size);
2244 let aim = aim_world_unshaken(px, &cam, canvas_dp, world_size, 1.0);
2245 assert!(
2246 (pick[0] - aim[0] - 10.0).abs() < 1e-3 && (pick[1] - aim[1] + 5.0).abs() < 1e-3,
2247 "pick follows shake, aim does not: pick {pick:?} aim {aim:?}"
2248 );
2249 cam.offset = Vec2::ZERO;
2250 let aim_still = aim_world_unshaken(px, &cam, canvas_dp, world_size, 1.0);
2251 assert!(
2252 (aim[0] - aim_still[0]).abs() < 1e-3 && (aim[1] - aim_still[1]).abs() < 1e-3,
2253 "aim independent of shake: {aim:?} vs {aim_still:?}"
2254 );
2255 }
2256
2257 #[test]
2258 fn roll_roundtrips_and_moves_pixels() {
2259 let cam = Camera2d {
2260 center: Vec2::new(400.0, 300.0),
2261 offset: Vec2::ZERO,
2262 units_per_pixel: 1.0,
2263 zoom: 1.0,
2264 roll: std::f32::consts::FRAC_PI_2,
2265 fit: FitMode::Keep,
2266 };
2267 let world = [800.0, 600.0];
2268 let dp = [1000.0, 600.0];
2269 let fit = effective_fit(dp, world, &cam);
2270 for p in [[400.0, 300.0], [500.0, 300.0], [0.0, 0.0]] {
2271 let fwd = world_to_dp_with_roll(p, world, cam.effective_center(), fit, cam.roll);
2272 let back = dp_to_world_with_roll(fwd, world, cam.effective_center(), fit, cam.roll);
2273 assert!(
2274 (back[0] - p[0]).abs() < 1e-3 && (back[1] - p[1]).abs() < 1e-3,
2275 "roll roundtrip for {p:?} gave {back:?}"
2276 );
2277 }
2278 let plain = world_to_dp([500.0, 300.0], world, cam.effective_center(), fit);
2279 let rolled =
2280 world_to_dp_with_roll([500.0, 300.0], world, cam.effective_center(), fit, cam.roll);
2281 assert!(
2282 (plain[0] - rolled[0]).abs() + (plain[1] - rolled[1]).abs() > 1.0,
2283 "roll must move pixels, plain {plain:?} rolled {rolled:?}"
2284 );
2285 let m0 = fit_view_proj_with_roll(dp, world, cam.effective_center(), fit, 0.0);
2286 let legacy = fit_view_proj(dp, world, cam.effective_center(), fit);
2287 assert_eq!(m0, legacy);
2288 }
2289
2290 #[test]
2291 fn surface_tracks_roll_about_pivot() {
2292 let geom = FrameGeom {
2293 fit: (1.0, 0.0, 0.0),
2294 look: [0.0, 0.0],
2295 density: 1.0,
2296 viewport_px: [800.0, 600.0],
2297 roll: std::f32::consts::FRAC_PI_2,
2298 pivot: [400.0, 300.0],
2299 };
2300 let ([ox, oy], _) = surface_dp([500.0, 300.0], [10.0, 10.0], geom);
2301 assert!(
2302 (ox - 395.0).abs() < 1e-3 && (oy - 395.0).abs() < 1e-3,
2303 "rolled surface, got ({ox},{oy})"
2304 );
2305 }
2306
2307 #[test]
2308 fn geom_handle_compat_wraps_legacy_handles() {
2309 use std::cell::Cell;
2310 use std::rc::Rc;
2311 let rc = Rc::new(Cell::new(FrameGeom {
2312 fit: (2.0, 1.0, 2.0),
2313 ..Default::default()
2314 }));
2315 let h: GeomHandle = rc.into();
2316 assert_eq!(h.get().fit, (2.0, 1.0, 2.0));
2317 let arc = Arc::new(Mutex::new(FrameGeom::default()));
2318 let h2: GeomHandle = arc.into();
2319 assert_eq!(h2.get().density, 1.0);
2320 }
2321}
2322
2323#[derive(Clone, Debug, PartialEq, Eq)]
2325pub struct CursorFrame {
2326 pub rgba: Vec<u8>,
2327 pub size: [u16; 2],
2328 pub hotspot: [u16; 2],
2329 pub key: (i32, [u32; 3], u64, u32),
2330}
2331
2332pub fn cursor_frame(
2333 strip: &[u8],
2334 strip_w: u32,
2335 strip_h: u32,
2336 frames: u32,
2337 frame: i32,
2338 cell: (u32, u32),
2339 origin: (f32, f32),
2340 tint: [f32; 4],
2341 mag: u32,
2342 strip_hash: u64,
2343) -> Option<CursorFrame> {
2344 let frames = frames.max(1) as i32;
2345 let frame = frame.clamp(0, frames - 1);
2346 let (cw, ch) = (cell.0.max(1), cell.1.max(1));
2347 if strip_w < cw * (frame as u32 + 1) || strip_h < ch {
2348 return None;
2349 }
2350 let mag = mag.clamp(1, 8);
2351 let tint_u8 = [
2352 (tint[0].clamp(0.0, 1.0) * 255.0) as u32,
2353 (tint[1].clamp(0.0, 1.0) * 255.0) as u32,
2354 (tint[2].clamp(0.0, 1.0) * 255.0) as u32,
2355 ];
2356 let key = (frame, tint_u8, strip_hash, mag);
2357 let fx = (frame as u32 * cw) as usize;
2358 let (dw, dh) = (cw * mag, ch * mag);
2359 if dw > 2048 || dh > 2048 {
2360 return None;
2361 }
2362 let mut px = Vec::with_capacity((dw * dh * 4) as usize);
2363 for row in 0..ch {
2364 let start = ((row * strip_w) as usize + fx) * 4;
2365 let end = start + (cw as usize) * 4;
2366 let cell = strip.get(start..end)?;
2367 let mut mag_row = Vec::with_capacity((cw * mag * 4) as usize);
2368 for pix in cell.chunks_exact(4) {
2369 let t = [
2370 (pix[0] as u32 * tint_u8[0] / 255) as u8,
2371 (pix[1] as u32 * tint_u8[1] / 255) as u8,
2372 (pix[2] as u32 * tint_u8[2] / 255) as u8,
2373 pix[3],
2374 ];
2375 for _ in 0..mag {
2376 mag_row.extend_from_slice(&t);
2377 }
2378 }
2379 for _ in 0..mag {
2380 px.extend_from_slice(&mag_row);
2381 }
2382 }
2383 let (w16, h16) = (dw.min(2048) as u16, dh.min(2048) as u16);
2384 Some(CursorFrame {
2385 rgba: px,
2386 size: [w16, h16],
2387 hotspot: [
2388 (origin.0 * mag as f32)
2389 .round()
2390 .clamp(0.0, w16.saturating_sub(1) as f32) as u16,
2391 (origin.1 * mag as f32)
2392 .round()
2393 .clamp(0.0, h16.saturating_sub(1) as f32) as u16,
2394 ],
2395 key,
2396 })
2397}
2398
2399pub fn unproject_px(
2401 px: Vec2,
2402 viewport_dp: [f32; 2],
2403 world_size: [f32; 2],
2404 density: f32,
2405 cam: &Camera2d,
2406) -> Vec2 {
2407 let d = if density.is_finite() && density > 1e-6 {
2408 density
2409 } else {
2410 1.0
2411 };
2412 let dp = [px.x / d, px.y / d];
2413 cam.dp_to_world_pt(viewport_dp, world_size, dp)
2414}
2415
2416#[cfg(test)]
2417mod unproject_tests {
2418 use super::*;
2419
2420 #[test]
2421 fn px_round_trips_through_fit() {
2422 let cam = Camera2d {
2423 center: Vec2::new(213.0, 120.0),
2424 offset: Vec2::ZERO,
2425 units_per_pixel: 1.0 / 3.0,
2426 zoom: 1.0,
2427 roll: 0.0,
2428 fit: FitMode::Keep,
2429 };
2430 let world = Vec2::new(100.0, 60.0);
2431 let viewport_dp = [1024.0, 600.0];
2432 let world_size = [1024.0, 600.0];
2433 let (s, ox, oy) = effective_fit(viewport_dp, world_size, &cam);
2434 let [dx, dy] = world_to_dp(
2435 [world.x, world.y],
2436 world_size,
2437 cam.effective_center(),
2438 (s, ox, oy),
2439 );
2440 let px = Vec2::new(dx * 1.25, dy * 1.25);
2441 let back = unproject_px(px, viewport_dp, world_size, 1.25, &cam);
2442 assert!(
2443 (back - world).length() < 0.01,
2444 "round trip, got {back:?} for {world:?}"
2445 );
2446 }
2447}