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