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