1use std::cell::Cell;
11use std::rc::Rc;
12use std::sync::{Arc, Mutex};
13
14use glam::{Mat4, Vec2, Vec3};
15use repose_canvas::{Canvas, DrawScope, Embedded};
16use repose_core::locals::effective_density_scale;
17use repose_core::{Color, Dp, Modifier, Px, Rect, View};
18use repose_render_wgpu::{Callback, CallbackResources, ScreenDescriptor, WgpuCallback};
19use repose_ui::Box as UiBox;
20use repose_ui::ViewExt;
21
22pub mod batch;
23pub use batch::{
24 AtlasUpload, BatchDesc, SpriteBatch, TextureFilter, draw_batch, draw_batch_with_id, frame_uv,
25 instance_rows, screen_camera, sprite_aabb,
26};
27
28pub mod fullscreen;
29pub use fullscreen::{FullscreenDesc, FullscreenPass, FullscreenTexture};
30
31pub mod post;
32
33#[derive(Clone, Copy, Debug)]
42pub struct Camera2d {
43 pub center: Vec2,
50 pub offset: Vec2,
58 pub units_per_pixel: f32,
66 pub zoom: f32,
72 pub roll: f32,
77}
78
79impl Default for Camera2d {
80 fn default() -> Self {
81 Self {
82 center: Vec2::ZERO,
83 offset: Vec2::ZERO,
84 units_per_pixel: 1.0,
85 zoom: 1.0,
86 roll: 0.0,
87 }
88 }
89}
90
91#[derive(Clone, Copy, Debug)]
98pub struct CameraLimits {
99 pub left: f32,
101 pub top: f32,
103 pub right: f32,
105 pub bottom: f32,
107 pub enabled: bool,
109}
110
111impl Default for CameraLimits {
112 fn default() -> Self {
113 Self {
114 left: 0.0,
115 top: 0.0,
116 right: 0.0,
117 bottom: 0.0,
118 enabled: false,
119 }
120 }
121}
122
123pub fn apply_limits(center: [f32; 2], limits: CameraLimits) -> [f32; 2] {
137 if !limits.enabled {
138 return center;
139 }
140 [
141 center[0].clamp(limits.left.min(limits.right), limits.left.max(limits.right)),
142 center[1].clamp(limits.top.min(limits.bottom), limits.top.max(limits.bottom)),
143 ]
144}
145
146pub fn smooth_toward(current: [f32; 2], target: [f32; 2], speed: f32, dt: f32) -> [f32; 2] {
163 if dt <= 0.0 {
164 return current;
165 }
166 if speed <= 0.0 || !speed.is_finite() {
167 return target;
168 }
169 let t = 1.0 - (-speed * dt).exp();
170 [
171 current[0] + (target[0] - current[0]) * t,
172 current[1] + (target[1] - current[1]) * t,
173 ]
174}
175
176impl Camera2d {
177 pub fn effective_center(&self) -> [f32; 2] {
183 [self.center.x + self.offset.x, self.center.y + self.offset.y]
184 }
185
186 pub fn fit_matrix(&self, canvas_dp: [f32; 2], world_size: [f32; 2]) -> Mat4 {
189 let fit = effective_fit(canvas_dp, world_size, self);
190 fit_view_proj_with_roll(
191 canvas_dp,
192 world_size,
193 self.effective_center(),
194 fit,
195 self.roll,
196 )
197 }
198
199 pub fn dp_to_world_pt(&self, canvas_dp: [f32; 2], world_size: [f32; 2], dp: [f32; 2]) -> Vec2 {
202 let fit = effective_fit(canvas_dp, world_size, self);
203 let w = dp_to_world_with_roll(dp, world_size, self.effective_center(), fit, self.roll);
204 Vec2::new(w[0], w[1])
205 }
206
207 pub fn view_proj_screen(&self, viewport_px: [f32; 2]) -> Mat4 {
210 let vp_w = viewport_px[0].max(1.0);
211 let vp_h = viewport_px[1].max(1.0);
212 let zoom = sane_positive(self.zoom);
213 let upp = sane_positive(self.units_per_pixel);
214 let w = vp_w * upp / zoom;
215 let h = vp_h * upp / zoom;
216 let proj = glam::camera::rh::proj::directx::orthographic(
217 -w / 2.0,
218 w / 2.0,
219 h / 2.0,
220 -h / 2.0,
221 -1000.0,
222 1000.0,
223 );
224 let view = Mat4::from_translation(Vec3::new(
225 -(self.center.x + self.offset.x),
226 -(self.center.y + self.offset.y),
227 0.0,
228 ));
229 proj * view
230 }
231
232 #[deprecated(
240 since = "0.1.8",
241 note = "ignores world_size; use fit_view_proj for the shared canvas/GPU/pick framing contract"
242 )]
243 pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
244 self.view_proj_screen(viewport_px)
245 }
246
247 #[deprecated(
253 since = "0.1.8",
254 note = "ignores world_size; use pick_world over the painted FrameGeom instead"
255 )]
256 #[allow(deprecated)]
257 pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
258 if viewport_px[0] <= 0.0 || viewport_px[1] <= 0.0 {
259 let [cx, cy] = self.effective_center();
260 return Vec2::new(cx, cy);
261 }
262 let ndc = Vec2::new(
263 (px[0] / viewport_px[0]) * 2.0 - 1.0,
264 1.0 - (px[1] / viewport_px[1]) * 2.0,
265 );
266 let inv = self.view_proj(viewport_px).inverse();
267 let world = inv.project_point3(ndc.extend(0.0));
268 Vec2::new(world.x, world.y)
269 }
270}
271
272#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
275pub enum SpriteBlend {
276 #[default]
277 Alpha,
278 Additive,
279 Multiply,
280}
281
282#[derive(Clone, Copy, Debug)]
284pub struct SpriteInstance {
285 pub center: Vec2,
287 pub rotation: f32,
288 pub size: Vec2,
289 pub anchor: Vec2,
293 pub flip_x: bool,
297 pub flip_y: bool,
298 pub uv_min: Vec2,
299 pub uv_max: Vec2,
300 pub color: [f32; 4],
303 pub page: u32,
305 pub z: f32,
308 pub blend: SpriteBlend,
309}
310
311impl Default for SpriteInstance {
312 fn default() -> Self {
313 Self {
314 center: Vec2::ZERO,
315 rotation: 0.0,
316 size: Vec2::ONE,
317 anchor: Vec2::new(0.5, 0.5),
318 flip_x: false,
319 flip_y: false,
320 uv_min: Vec2::ZERO,
321 uv_max: Vec2::ONE,
322 color: [1.0, 1.0, 1.0, 1.0],
323 page: 0,
324 z: 0.0,
325 blend: SpriteBlend::Alpha,
326 }
327 }
328}
329
330#[derive(Clone, Debug, Default)]
332pub struct WorldText {
333 pub text: String,
334 pub pos: Vec2,
336 pub color: [f32; 4],
338 pub size: f32,
340}
341
342#[derive(Clone, Default, Debug)]
350pub struct FrameInput {
351 pub cam: Camera2d,
352 pub world_size: [f32; 2],
354 pub viewport_dp: [f32; 2],
361 pub sprites: Vec<SpriteInstance>,
362 pub texts: Vec<WorldText>,
367 pub background: Option<[f32; 4]>,
369 pub overlay_color: Option<[f32; 4]>,
375 pub chroma: f32,
381}
382
383#[derive(Clone, Debug)]
389pub enum PickEvent {
390 Press {
391 world: Vec2,
392 screen: [f32; 2],
393 },
394 Click {
395 world: Vec2,
396 screen: [f32; 2],
397 },
398 Hover {
399 world: Vec2,
400 },
401 TouchDown {
405 id: u64,
406 screen: [f32; 2],
407 },
408 TouchMove {
410 id: u64,
411 screen: [f32; 2],
412 },
413 TouchUp {
415 id: u64,
416 },
417}
418
419fn is_touch(ev: &repose_core::input::PointerEvent) -> bool {
422 matches!(
423 ev.kind,
424 repose_core::input::PointerKind::Touch | repose_core::input::PointerKind::Pen
425 )
426}
427
428fn screen_of(ev: &repose_core::input::PointerEvent) -> [f32; 2] {
430 let p = ev.position_in_window();
431 [p.x, p.y]
432}
433
434fn sane_positive(v: f32) -> f32 {
438 if v.is_finite() && v > 1e-6 { v } else { 1.0 }
439}
440
441pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
446 if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
447 return (1.0, 0.0, 0.0);
448 }
449 let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
450 if !s.is_finite() || s <= 1e-6 {
451 return (1.0, 0.0, 0.0);
452 }
453 (
454 s,
455 (canvas_dp[0] - world[0] * s) * 0.5,
456 (canvas_dp[1] - world[1] * s) * 0.5,
457 )
458}
459
460pub fn effective_fit(canvas_dp: [f32; 2], world: [f32; 2], cam: &Camera2d) -> (f32, f32, f32) {
465 let (base, _, _) = contain_fit(canvas_dp, world);
466 let zoom = sane_positive(cam.zoom);
467 let upp = sane_positive(cam.units_per_pixel);
468 let s = base * zoom / upp;
469 if !s.is_finite() || s <= 1e-6 {
470 return (1.0, 0.0, 0.0);
471 }
472 (
473 s,
474 (canvas_dp[0] - world[0] * s) * 0.5,
475 (canvas_dp[1] - world[1] * s) * 0.5,
476 )
477}
478
479pub fn fit_view_proj(
484 canvas_dp: [f32; 2],
485 world_size: [f32; 2],
486 cam_center: [f32; 2],
487 fit: (f32, f32, f32),
488) -> Mat4 {
489 fit_view_proj_with_roll(canvas_dp, world_size, cam_center, fit, 0.0)
490}
491
492pub fn fit_view_proj_with_roll(
496 canvas_dp: [f32; 2],
497 world_size: [f32; 2],
498 cam_center: [f32; 2],
499 fit: (f32, f32, f32),
500 roll: f32,
501) -> Mat4 {
502 let w = canvas_dp[0].max(1.0);
503 let h = canvas_dp[1].max(1.0);
504 let (s, ox, oy) = fit;
505 let s = if s.is_finite() && s > 1e-6 { s } else { 1.0 };
506 let lx = cam_center[0] - world_size[0] * 0.5;
507 let ly = cam_center[1] - world_size[1] * 0.5;
508 let sx = 2.0 * s / w;
509 let sy = -2.0 * s / h;
510 let tx = 2.0 * (ox - lx * s) / w - 1.0;
511 let ty = 1.0 - 2.0 * (oy - ly * s) / h;
512 let base = Mat4::from_cols(
513 glam::Vec4::new(sx, 0.0, 0.0, 0.0),
514 glam::Vec4::new(0.0, sy, 0.0, 0.0),
515 glam::Vec4::new(0.0, 0.0, 0.001, 0.0),
516 glam::Vec4::new(tx, ty, 0.0, 1.0),
517 );
518 if !roll.is_finite() || roll.abs() < 1e-7 {
519 return base;
520 }
521 let (c, r) = (roll.cos(), roll.sin());
522 let rot = Mat4::from_cols(
523 glam::Vec4::new(c, r, 0.0, 0.0),
524 glam::Vec4::new(-r, c, 0.0, 0.0),
525 glam::Vec4::new(0.0, 0.0, 1.0, 0.0),
526 glam::Vec4::new(
527 cam_center[0] - c * cam_center[0] + r * cam_center[1],
528 cam_center[1] - r * cam_center[0] - c * cam_center[1],
529 0.0,
530 1.0,
531 ),
532 );
533 base * rot
534}
535
536pub fn world_to_dp(
540 world: [f32; 2],
541 world_size: [f32; 2],
542 cam_center: [f32; 2],
543 fit: (f32, f32, f32),
544) -> [f32; 2] {
545 world_to_dp_with_roll(world, world_size, cam_center, fit, 0.0)
546}
547
548pub fn world_to_dp_with_roll(
550 world: [f32; 2],
551 world_size: [f32; 2],
552 cam_center: [f32; 2],
553 fit: (f32, f32, f32),
554 roll: f32,
555) -> [f32; 2] {
556 let (s, ox, oy) = fit;
557 let [wx, wy] = rotate_about(world, cam_center, roll);
558 [
559 ox + (wx - (cam_center[0] - world_size[0] * 0.5)) * s,
560 oy + (wy - (cam_center[1] - world_size[1] * 0.5)) * s,
561 ]
562}
563
564pub fn dp_to_world(
567 dp: [f32; 2],
568 world_size: [f32; 2],
569 cam_center: [f32; 2],
570 fit: (f32, f32, f32),
571) -> [f32; 2] {
572 dp_to_world_with_roll(dp, world_size, cam_center, fit, 0.0)
573}
574
575pub fn dp_to_world_with_roll(
577 dp: [f32; 2],
578 world_size: [f32; 2],
579 cam_center: [f32; 2],
580 fit: (f32, f32, f32),
581 roll: f32,
582) -> [f32; 2] {
583 let (s, ox, oy) = fit;
584 if !s.is_finite() || s.abs() < 1e-6 {
585 return cam_center;
586 }
587 let base = [
588 (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
589 (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
590 ];
591 rotate_about(base, cam_center, -roll)
592}
593
594fn rotate_about(p: [f32; 2], center: [f32; 2], roll: f32) -> [f32; 2] {
595 if !roll.is_finite() || roll.abs() < 1e-7 {
596 return p;
597 }
598 let (c, r) = (roll.cos(), roll.sin());
599 let dx = p[0] - center[0];
600 let dy = p[1] - center[1];
601 [center[0] + c * dx - r * dy, center[1] + r * dx + c * dy]
602}
603
604pub fn pick_world(local_px: [f32; 2], geom: FrameGeom, world_size: [f32; 2]) -> [f32; 2] {
609 let d = if geom.density.is_finite() && geom.density > 1e-6 {
610 geom.density
611 } else {
612 1.0
613 };
614 dp_to_world_with_roll(
615 [local_px[0] / d, local_px[1] / d],
616 world_size,
617 geom.pivot,
618 geom.fit,
619 geom.roll,
620 )
621}
622
623#[derive(Clone, Copy, Debug)]
636pub struct FrameGeom {
637 pub fit: (f32, f32, f32),
640 pub look: [f32; 2],
643 pub density: f32,
645 pub viewport_px: [f32; 2],
647 pub roll: f32,
649 pub pivot: [f32; 2],
652}
653
654impl Default for FrameGeom {
655 fn default() -> Self {
656 Self {
657 fit: (1.0, 0.0, 0.0),
658 look: [0.0, 0.0],
659 density: 1.0,
660 viewport_px: [1.0, 1.0],
661 roll: 0.0,
662 pivot: [0.0, 0.0],
663 }
664 }
665}
666
667#[derive(Clone, Default)]
670pub struct GeomHandle(Arc<Mutex<FrameGeom>>);
671
672impl GeomHandle {
673 pub fn new() -> Self {
674 Self(Arc::new(Mutex::new(FrameGeom::default())))
675 }
676
677 pub fn get(&self) -> FrameGeom {
678 self.0.lock().map(|g| *g).unwrap_or_default()
679 }
680
681 pub fn set(&self, g: FrameGeom) {
682 if let Ok(mut slot) = self.0.lock() {
683 *slot = g;
684 }
685 }
686
687 pub fn arc(&self) -> Arc<Mutex<FrameGeom>> {
688 self.0.clone()
689 }
690}
691
692impl From<FrameGeom> for GeomHandle {
693 fn from(g: FrameGeom) -> Self {
694 Self(Arc::new(Mutex::new(g)))
695 }
696}
697
698impl From<std::sync::Arc<std::sync::Mutex<FrameGeom>>> for GeomHandle {
699 fn from(arc: std::sync::Arc<std::sync::Mutex<FrameGeom>>) -> Self {
701 Self(arc)
702 }
703}
704
705impl From<std::rc::Rc<std::cell::Cell<FrameGeom>>> for GeomHandle {
706 fn from(rc: std::rc::Rc<std::cell::Cell<FrameGeom>>) -> Self {
709 Self(Arc::new(Mutex::new(rc.get())))
710 }
711}
712
713pub const CLICK_SLOP_PX: f32 = 12.0;
716
717fn click_within_slop(a: [f32; 2], b: [f32; 2]) -> bool {
718 let dx = a[0] - b[0];
719 let dy = a[1] - b[1];
720 dx * dx + dy * dy <= CLICK_SLOP_PX * CLICK_SLOP_PX
721}
722
723pub fn surface_dp(center: [f32; 2], size: [f32; 2], geom: FrameGeom) -> ([f32; 2], [f32; 2]) {
728 let (s, ox, oy) = geom.fit;
729 let r = rotate_about(center, geom.pivot, geom.roll);
730 (
731 [
732 ox + (r[0] - size[0] * 0.5 - geom.look[0]) * s,
733 oy + (r[1] - size[1] * 0.5 - geom.look[1]) * s,
734 ],
735 [size[0] * s, size[1] * s],
736 )
737}
738
739fn rgba8(c: [f32; 4]) -> Color {
740 Color::from_rgba(
741 (c[0].clamp(0.0, 1.0) * 255.0) as u8,
742 (c[1].clamp(0.0, 1.0) * 255.0) as u8,
743 (c[2].clamp(0.0, 1.0) * 255.0) as u8,
744 (c[3].clamp(0.0, 1.0) * 255.0) as u8,
745 )
746}
747
748#[allow(non_snake_case)] pub fn Viewport2d(
767 input: FrameInput,
768 geom_out: GeomHandle,
769 on_event: impl Fn(PickEvent) + 'static,
770) -> View {
771 let input = Rc::new(input);
772 let world_size = input.world_size;
773 let pick_geom = geom_out.clone();
774 let move_geom = geom_out.clone();
775 let release_geom = geom_out.clone();
776 let draw_input = input.clone();
777 let draw_geom = geom_out.clone();
778 let on_event = Rc::new(on_event);
779 let on_down = on_event.clone();
780 let on_touch_down = on_event.clone();
781 let on_move = on_event.clone();
782 let on_touch_move = on_event.clone();
783 let on_up = on_event.clone();
784 let on_up_click = on_event.clone();
785 let on_leave = on_event;
786 let press: Rc<Cell<Option<[f32; 2]>>> = Rc::new(Cell::new(None));
787 let press_down = press.clone();
788 let press_up = press.clone();
789 let press_leave = press.clone();
790
791 let modifier = Modifier::new()
792 .fill_max_size()
793 .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
794 let p = ev.position;
795 let g = pick_geom.get();
796 let world = pick_world([p.x, p.y], g, world_size);
797 press_down.set(Some([p.x, p.y]));
798 on_down(PickEvent::Press {
799 world: Vec2::new(world[0], world[1]),
800 screen: [p.x, p.y],
801 });
802 if is_touch(&ev) {
803 on_touch_down(PickEvent::TouchDown {
804 id: ev.id.0,
805 screen: screen_of(&ev),
806 });
807 }
808 })
809 .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
810 let p = ev.position;
811 let g = move_geom.get();
812 let world = pick_world([p.x, p.y], g, world_size);
813 on_move(PickEvent::Hover {
814 world: Vec2::new(world[0], world[1]),
815 });
816 if is_touch(&ev) {
817 on_touch_move(PickEvent::TouchMove {
818 id: ev.id.0,
819 screen: screen_of(&ev),
820 });
821 }
822 })
823 .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
824 let p = ev.position;
825 if let Some(start) = press_up.take()
826 && click_within_slop(start, [p.x, p.y])
827 {
828 let g = release_geom.get();
829 let world = pick_world([p.x, p.y], g, world_size);
830 on_up_click(PickEvent::Click {
831 world: Vec2::new(world[0], world[1]),
832 screen: [p.x, p.y],
833 });
834 }
835 if is_touch(&ev) {
836 on_up(PickEvent::TouchUp { id: ev.id.0 });
837 }
838 })
839 .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
840 press_leave.set(None);
841 if is_touch(&ev) {
842 on_leave(PickEvent::TouchUp { id: ev.id.0 });
843 }
844 });
845 Canvas(modifier, move |scope: &mut DrawScope| {
846 let d = effective_density_scale();
847 let cam = draw_input.cam;
848 let cam_center = cam.effective_center();
849 let fit = effective_fit(
850 [scope.size.width / d, scope.size.height / d],
851 draw_input.world_size,
852 &cam,
853 );
854 draw_geom.set(FrameGeom {
855 fit,
856 look: [
857 cam_center[0] - draw_input.world_size[0] * 0.5,
858 cam_center[1] - draw_input.world_size[1] * 0.5,
859 ],
860 density: d,
861 viewport_px: [scope.size.width, scope.size.height],
862 roll: cam.roll,
863 pivot: cam_center,
864 });
865 let project = |wx: f32, wy: f32| -> [f32; 2] {
866 let [dx, dy] = world_to_dp_with_roll(
867 [wx, wy],
868 draw_input.world_size,
869 cam_center,
870 (fit.0, fit.1, fit.2),
871 cam.roll,
872 );
873 [dx * d, dy * d]
874 };
875 if let Some(bg) = draw_input.background {
876 scope.draw_rect(
877 Rect {
878 x: 0.0,
879 y: 0.0,
880 w: scope.size.width,
881 h: scope.size.height,
882 },
883 rgba8(bg),
884 Px(0.0),
885 );
886 }
887 for spr in draw_input.sprites.iter() {
888 if spr.rotation.abs() > 1e-4 {
889 debug_assert!(
890 false,
891 "canvas viewport draws rotated sprites as AABB; use GPU viewport for exact rotation"
892 );
893 }
894 let (row0, row1) = batch::instance_rows(
895 [spr.center.x, spr.center.y],
896 [spr.size.x, spr.size.y],
897 spr.rotation,
898 [spr.anchor.x, spr.anchor.y],
899 spr.flip_x,
900 spr.flip_y,
901 );
902 let mut min_x = f32::INFINITY;
903 let mut min_y = f32::INFINITY;
904 let mut max_x = f32::NEG_INFINITY;
905 let mut max_y = f32::NEG_INFINITY;
906 for corner in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]] {
907 let wx = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
908 let wy = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
909 let [px, py] = project(wx, wy);
910 min_x = min_x.min(px);
911 min_y = min_y.min(py);
912 max_x = max_x.max(px);
913 max_y = max_y.max(py);
914 }
915 scope.draw_rect(
916 Rect {
917 x: min_x,
918 y: min_y,
919 w: (max_x - min_x).max(0.0),
920 h: (max_y - min_y).max(0.0),
921 },
922 rgba8(spr.color),
923 Px(0.0),
924 );
925 }
926 for t in draw_input.texts.iter() {
927 let [tx, ty] = project(t.pos.x, t.pos.y);
928 scope.draw_text(
929 t.text.clone(),
930 repose_core::Vec2 { x: tx, y: ty },
931 rgba8(t.color),
932 Px(t.size * fit.0 * d),
933 );
934 }
935 if let Some(tint) = draw_input.overlay_color {
936 scope.draw_rect(
937 Rect {
938 x: 0.0,
939 y: 0.0,
940 w: scope.size.width,
941 h: scope.size.height,
942 },
943 rgba8(tint),
944 Px(0.0),
945 );
946 }
947 })
948}
949
950#[allow(non_snake_case)]
980pub fn Viewport2dGpu(
981 input: FrameInput,
982 geom_out: GeomHandle,
983 uploads: Vec<AtlasUpload>,
984 desc: BatchDesc,
985 on_event: impl Fn(PickEvent) + 'static,
986) -> View {
987 Viewport2dGpuWithId(input, geom_out, uploads, desc, "viewport2d.main", on_event)
988}
989
990#[allow(non_snake_case)]
994pub fn Viewport2dGpuWithId(
995 input: FrameInput,
996 geom_out: GeomHandle,
997 uploads: Vec<AtlasUpload>,
998 desc: BatchDesc,
999 batch_id: impl Into<String>,
1000 on_event: impl Fn(PickEvent) + 'static,
1001) -> View {
1002 let batch_id: String = batch_id.into();
1003 let bg_id = format!("{batch_id}.background");
1004 let overlay_id = format!("{batch_id}.overlay");
1005 let input = Arc::new(input);
1006 let world_size = input.world_size;
1007 let pick_geom = geom_out.clone();
1008 let move_geom = geom_out.clone();
1009 let release_geom = geom_out.clone();
1010 let size_geom = geom_out.clone();
1011 let size_input = input.clone();
1012 let geom_for_payload = geom_out.clone();
1013 let on_event = Arc::new(on_event);
1014 let on_down = on_event.clone();
1015 let on_touch_down = on_event.clone();
1016 let on_move = on_event.clone();
1017 let on_touch_move = on_event.clone();
1018 let on_up = on_event.clone();
1019 let on_up_click = on_event.clone();
1020 let on_leave = on_event;
1021 let press: Arc<Mutex<Option<[f32; 2]>>> = Arc::new(Mutex::new(None));
1022 let press_down = press.clone();
1023 let press_up = press.clone();
1024 let press_leave = press.clone();
1025 let payload = GpuViewport {
1026 input: input.clone(),
1027 geom: geom_for_payload.arc(),
1028 batch_id,
1029 uploads: Arc::from(uploads.into_boxed_slice()),
1030 desc,
1031 bg: FullscreenPass::new(
1032 bg_id,
1033 fullscreen::SOLID_WGSL,
1034 FullscreenDesc {
1035 texture_slots: 0,
1036 filter: TextureFilter::Nearest,
1037 },
1038 ),
1039 overlay: FullscreenPass::new(
1040 overlay_id,
1041 fullscreen::SOLID_WGSL,
1042 FullscreenDesc {
1043 texture_slots: 0,
1044 filter: TextureFilter::Nearest,
1045 },
1046 ),
1047 };
1048 let modifier = Modifier::new()
1049 .fill_max_size()
1050 .on_size_changed(move |dp: repose_core::Vec2| {
1051 let d = effective_density_scale();
1052 let d = if d.is_finite() && d > 1e-6 { d } else { 1.0 };
1053 let fit = effective_fit([dp.x, dp.y], size_input.world_size, &size_input.cam);
1054 let center = size_input.cam.effective_center();
1055 size_geom.set(FrameGeom {
1056 viewport_px: [dp.x * d, dp.y * d],
1057 density: d,
1058 fit,
1059 look: [
1060 center[0] - size_input.world_size[0] * 0.5,
1061 center[1] - size_input.world_size[1] * 0.5,
1062 ],
1063 roll: size_input.cam.roll,
1064 pivot: center,
1065 });
1066 })
1067 .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
1068 let p = ev.position;
1069 let g = pick_geom.get();
1070 let world = pick_world([p.x, p.y], g, world_size);
1071 if let Ok(mut slot) = press_down.lock() {
1072 *slot = Some([p.x, p.y]);
1073 }
1074 on_down(PickEvent::Press {
1075 world: Vec2::new(world[0], world[1]),
1076 screen: [p.x, p.y],
1077 });
1078 if is_touch(&ev) {
1079 on_touch_down(PickEvent::TouchDown {
1080 id: ev.id.0,
1081 screen: screen_of(&ev),
1082 });
1083 }
1084 })
1085 .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
1086 let p = ev.position;
1087 let g = move_geom.get();
1088 let world = pick_world([p.x, p.y], g, world_size);
1089 on_move(PickEvent::Hover {
1090 world: Vec2::new(world[0], world[1]),
1091 });
1092 if is_touch(&ev) {
1093 on_touch_move(PickEvent::TouchMove {
1094 id: ev.id.0,
1095 screen: screen_of(&ev),
1096 });
1097 }
1098 })
1099 .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
1100 let p = ev.position;
1101 let start = press_up.lock().ok().and_then(|mut s| s.take());
1102 if let Some(start) = start
1103 && click_within_slop(start, [p.x, p.y])
1104 {
1105 let g = release_geom.get();
1106 let world = pick_world([p.x, p.y], g, world_size);
1107 on_up_click(PickEvent::Click {
1108 world: Vec2::new(world[0], world[1]),
1109 screen: [p.x, p.y],
1110 });
1111 }
1112 if is_touch(&ev) {
1113 on_up(PickEvent::TouchUp { id: ev.id.0 });
1114 }
1115 })
1116 .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
1117 if let Ok(mut s) = press_leave.lock() {
1118 *s = None;
1119 }
1120 if is_touch(&ev) {
1121 on_leave(PickEvent::TouchUp { id: ev.id.0 });
1122 }
1123 });
1124 Embedded(modifier, Callback::new(payload))
1125}
1126
1127struct GpuViewport {
1128 input: Arc<FrameInput>,
1129 geom: Arc<Mutex<FrameGeom>>,
1130 batch_id: String,
1131 uploads: Arc<[AtlasUpload]>,
1132 desc: BatchDesc,
1133 bg: FullscreenPass,
1134 overlay: FullscreenPass,
1135}
1136
1137impl WgpuCallback for GpuViewport {
1138 fn prepare(
1139 &self,
1140 device: &wgpu::Device,
1141 queue: &wgpu::Queue,
1142 encoder: &mut wgpu::CommandEncoder,
1143 screen: &ScreenDescriptor,
1144 resources: &mut CallbackResources,
1145 ) -> Vec<wgpu::CommandBuffer> {
1146 let (vp_phys, density) = self
1147 .geom
1148 .lock()
1149 .map(|g| {
1150 let d = if g.density.is_finite() && g.density > 1e-6 {
1151 g.density
1152 } else {
1153 1.0
1154 };
1155 (g.viewport_px, d)
1156 })
1157 .unwrap_or(([1.0, 1.0], 1.0));
1158 let dp = if vp_phys[0] > 1.0 && vp_phys[1] > 1.0 {
1159 [vp_phys[0] / density, vp_phys[1] / density]
1160 } else {
1161 self.input.viewport_dp
1162 };
1163 let mut batch = SpriteBatch::with_id(self.batch_id.clone(), self.desc);
1164 batch.set_camera(self.input.cam.fit_matrix(dp, self.input.world_size));
1165 for s in &self.input.sprites {
1166 batch.push_sprite(s);
1167 }
1168 batch.extend_uploads(self.uploads.iter().cloned());
1169 batch.prepare(device, queue, encoder, screen, resources);
1170 let composite = post::use_composite(self.input.chroma);
1171 if let Some(bg) = self.input.background
1174 && !composite
1175 {
1176 let words = [bg[0], bg[1], bg[2], bg[3]];
1177 self.bg.prepare_with(
1178 device,
1179 queue,
1180 screen,
1181 resources,
1182 bytemuck::cast_slice(&words),
1183 &[],
1184 );
1185 }
1186 if let Some(ov) = self.input.overlay_color {
1187 let words = [ov[0], ov[1], ov[2], ov[3]];
1188 self.overlay.prepare_with(
1189 device,
1190 queue,
1191 screen,
1192 resources,
1193 bytemuck::cast_slice(&words),
1194 &[],
1195 );
1196 }
1197 if post::use_composite(self.input.chroma) {
1198 let w = vp_phys[0].max(1.0) as u32;
1199 let h = vp_phys[1].max(1.0) as u32;
1200 post::prepare_composite(
1201 device,
1202 queue,
1203 encoder,
1204 screen,
1205 resources,
1206 self.batch_id.as_str(),
1207 w,
1208 h,
1209 self.input.chroma,
1210 self.input.background,
1211 );
1212 }
1213 Vec::new()
1214 }
1215
1216 fn paint(
1217 &self,
1218 info: repose_core::PaintCallbackInfo,
1219 rpass: &mut wgpu::RenderPass<'static>,
1220 resources: &CallbackResources,
1221 ) {
1222 let d = if info.pixels_per_point.is_finite() && info.pixels_per_point > 1e-6 {
1223 info.pixels_per_point
1224 } else {
1225 1.0
1226 };
1227 let vp = [info.viewport.w, info.viewport.h];
1228 let fit = effective_fit(
1229 [vp[0] / d, vp[1] / d],
1230 self.input.world_size,
1231 &self.input.cam,
1232 );
1233 let cam = self.input.cam.effective_center();
1234 let roll = self.input.cam.roll;
1235 if let Ok(mut g) = self.geom.lock() {
1236 *g = FrameGeom {
1237 fit,
1238 look: [
1239 cam[0] - self.input.world_size[0] * 0.5,
1240 cam[1] - self.input.world_size[1] * 0.5,
1241 ],
1242 density: d,
1243 viewport_px: vp,
1244 roll,
1245 pivot: cam,
1246 };
1247 }
1248 if post::use_composite(self.input.chroma) {
1249 post::paint_composite(self.batch_id.as_str(), rpass, resources);
1250 } else {
1251 if self.input.background.is_some() {
1252 self.bg.paint(info, rpass, resources);
1253 }
1254 draw_batch_with_id(self.batch_id.as_str(), rpass, resources);
1255 }
1256 if self.input.overlay_color.is_some() {
1257 self.overlay.paint(info, rpass, resources);
1258 }
1259 }
1260}
1261
1262#[allow(non_snake_case)]
1269pub fn Viewport2dGpuWithHud(
1270 input: FrameInput,
1271 geom: GeomHandle,
1272 uploads: Vec<AtlasUpload>,
1273 desc: BatchDesc,
1274 on_event: impl Fn(PickEvent) + 'static,
1275) -> View {
1276 use repose_ui::ViewExt as _;
1277 let texts = input.texts.clone();
1278 let overlay = input.overlay_color;
1279 let cam = input.cam;
1280 let world_size = input.world_size;
1281 let hud_input = std::rc::Rc::new((cam, world_size, texts, overlay));
1282 let hud_geom = geom.clone();
1283 let hud = Canvas(
1284 Modifier::new().fill_max_size().hit_passthrough(),
1285 move |scope: &mut DrawScope| {
1286 let (cam, world_size, texts, overlay) =
1287 (hud_input.0, hud_input.1, &hud_input.2, hud_input.3);
1288 let d = effective_density_scale();
1289 let cam_center = cam.effective_center();
1290 let fit = effective_fit(
1291 [scope.size.width / d, scope.size.height / d],
1292 world_size,
1293 &cam,
1294 );
1295 hud_geom.set(FrameGeom {
1296 fit,
1297 look: [
1298 cam_center[0] - world_size[0] * 0.5,
1299 cam_center[1] - world_size[1] * 0.5,
1300 ],
1301 density: d,
1302 viewport_px: [scope.size.width, scope.size.height],
1303 roll: cam.roll,
1304 pivot: cam_center,
1305 });
1306 let project = |wx: f32, wy: f32| -> [f32; 2] {
1307 let [dx, dy] = world_to_dp_with_roll(
1308 [wx, wy],
1309 world_size,
1310 cam_center,
1311 (fit.0, fit.1, fit.2),
1312 cam.roll,
1313 );
1314 [dx * d, dy * d]
1315 };
1316 for t in texts.iter() {
1317 let [tx, ty] = project(t.pos.x, t.pos.y);
1318 scope.draw_text(
1319 t.text.clone(),
1320 repose_core::Vec2 { x: tx, y: ty },
1321 rgba8(t.color),
1322 Px(t.size * fit.0 * d),
1323 );
1324 }
1325 if let Some(tint) = overlay {
1326 scope.draw_rect(
1327 Rect {
1328 x: 0.0,
1329 y: 0.0,
1330 w: scope.size.width,
1331 h: scope.size.height,
1332 },
1333 rgba8(tint),
1334 Px(0.0),
1335 );
1336 }
1337 },
1338 );
1339 repose_ui::ZStack(Modifier::new().fill_max_size())
1340 .child(Viewport2dGpu(input, geom, uploads, desc, on_event))
1341 .child(hud)
1342}
1343#[allow(non_snake_case)]
1355pub fn ActorFrame(
1356 center: [f32; 2],
1357 size: [f32; 2],
1358 geom: GeomHandle,
1359 mirror_x: bool,
1360 child: View,
1361) -> View {
1362 let ([ox, oy], [w, h]) = surface_dp(center, size, geom.get());
1363 let mut modifier = Modifier::new().size(Dp(w), Dp(h)).absolute().offset(
1366 Some(Dp(ox)),
1367 Some(Dp(oy)),
1368 None,
1369 None,
1370 );
1371 if mirror_x {
1372 modifier = modifier.scale2(-1.0, 1.0);
1373 }
1374 UiBox(modifier).child(child)
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379 use super::*;
1380 use repose_core::locals::{Density, set_density_default};
1381
1382 #[test]
1383 fn contain_fit_roundtrip_at_density_125() {
1384 set_density_default(Density { scale: 1.25 });
1387 let d = effective_density_scale();
1388 let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
1390 let cam = [400.0, 300.0];
1391 let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
1393 let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
1395 set_density_default(Density { scale: 1.0 });
1396 assert!(
1397 (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
1398 "dp fit, got {fit:?}"
1399 );
1400 assert!(
1401 (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
1402 "draw forward, got ({dx},{dy})"
1403 );
1404 assert!(
1405 (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
1406 "pick inverse, got ({wx},{wy})"
1407 );
1408 let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
1410 let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
1411 assert!(
1412 (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
1413 "shifted round trip, got {back:?}"
1414 );
1415 }
1416
1417 #[test]
1418 fn gpu_viewport_honors_cam_and_background() {
1419 use repose_core::{Color, Rect, Scene, SceneNode};
1424 use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
1425
1426 let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
1427 Ok(r) => r,
1428 Err(e) => {
1429 eprintln!("SKIP gpu viewport test (no GPU): {e}");
1430 return;
1431 }
1432 };
1433 let page = [255, 0, 255, 255].repeat(4);
1435 let uploads = vec![AtlasUpload {
1436 page: 0,
1437 x: 0,
1438 y: 0,
1439 w: 2,
1440 h: 2,
1441 rgba: page,
1442 }];
1443 let cam = Camera2d {
1444 center: Vec2::new(64.0, 64.0),
1445 offset: Vec2::ZERO,
1446 units_per_pixel: 0.5,
1447 zoom: 1.0,
1448 roll: 0.0,
1449 };
1450 let input = FrameInput {
1451 cam,
1452 world_size: [128.0, 128.0],
1453 viewport_dp: [256.0, 256.0],
1454 sprites: vec![SpriteInstance {
1455 center: Vec2::new(64.0, 64.0),
1456 size: Vec2::new(32.0, 32.0),
1457 uv_min: Vec2::new(0.0, 0.0),
1458 uv_max: Vec2::new(1.0, 1.0),
1459 color: [1.0, 1.0, 1.0, 1.0],
1460 ..Default::default()
1461 }],
1462 texts: Vec::new(),
1463 background: Some([0.0, 0.0, 1.0, 1.0]),
1464 overlay_color: None,
1465 chroma: 0.0,
1466 };
1467 let geom = Arc::new(Mutex::new(FrameGeom {
1468 fit: (1.0, 0.0, 0.0),
1469 look: [0.0, 0.0],
1470 density: 1.0,
1471 viewport_px: [256.0, 256.0],
1472 roll: 0.0,
1473 pivot: [64.0, 64.0],
1474 }));
1475 let payload = GpuViewport {
1476 input: Arc::new(input.clone()),
1477 geom,
1478 batch_id: "test.viewport2d.main".to_string(),
1479 uploads: Arc::from(uploads.into_boxed_slice()),
1480 desc: BatchDesc {
1481 layer_size: 2,
1482 layers: 1,
1483 filter: TextureFilter::Nearest,
1484 },
1485 bg: FullscreenPass::new(
1486 "test.viewport2d.background",
1487 fullscreen::SOLID_WGSL,
1488 FullscreenDesc {
1489 texture_slots: 0,
1490 filter: TextureFilter::Nearest,
1491 },
1492 ),
1493 overlay: FullscreenPass::new(
1494 "test.viewport2d.overlay",
1495 fullscreen::SOLID_WGSL,
1496 FullscreenDesc {
1497 texture_slots: 0,
1498 filter: TextureFilter::Nearest,
1499 },
1500 ),
1501 };
1502 let scene = Scene {
1503 clear_color: Color::from_rgba(0, 0, 0, 255),
1504 nodes: vec![SceneNode::Callback {
1505 rect: Rect {
1506 x: 0.0,
1507 y: 0.0,
1508 w: 256.0,
1509 h: 256.0,
1510 },
1511 payload: Callback::new(payload),
1512 }],
1513 };
1514 let px = renderer
1515 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1516 .expect("offscreen render");
1517 let at = |x: u32, y: u32| -> [u8; 4] {
1518 let i = ((y * 256 + x) * 4) as usize;
1519 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1520 };
1521 assert_eq!(at(8, 8), [0, 0, 255, 255], "background fill");
1523 assert_eq!(at(247, 247), [0, 0, 255, 255], "background fill");
1524 assert_eq!(at(128, 128), [255, 0, 255, 255], "cam-centered quad");
1525 let overlay_input = FrameInput {
1526 overlay_color: Some([1.0, 0.0, 0.0, 1.0]),
1527 ..input
1528 };
1529 let overlay_payload = GpuViewport {
1530 input: Arc::new(overlay_input),
1531 geom: Arc::new(Mutex::new(FrameGeom {
1532 fit: (1.0, 0.0, 0.0),
1533 look: [0.0, 0.0],
1534 density: 1.0,
1535 viewport_px: [256.0, 256.0],
1536 roll: 0.0,
1537 pivot: [64.0, 64.0],
1538 })),
1539 batch_id: "test.viewport2d.overlay".to_string(),
1540 uploads: Arc::from(
1541 vec![AtlasUpload {
1542 page: 0,
1543 x: 0,
1544 y: 0,
1545 w: 2,
1546 h: 2,
1547 rgba: [255, 0, 255, 255].repeat(4),
1548 }]
1549 .into_boxed_slice(),
1550 ),
1551 desc: BatchDesc {
1552 layer_size: 2,
1553 layers: 1,
1554 filter: TextureFilter::Nearest,
1555 },
1556 bg: FullscreenPass::new(
1557 "test.viewport2d.background",
1558 fullscreen::SOLID_WGSL,
1559 FullscreenDesc {
1560 texture_slots: 0,
1561 filter: TextureFilter::Nearest,
1562 },
1563 ),
1564 overlay: FullscreenPass::new(
1565 "test.viewport2d.overlay",
1566 fullscreen::SOLID_WGSL,
1567 FullscreenDesc {
1568 texture_slots: 0,
1569 filter: TextureFilter::Nearest,
1570 },
1571 ),
1572 };
1573 let scene = Scene {
1574 clear_color: Color::from_rgba(0, 0, 0, 255),
1575 nodes: vec![SceneNode::Callback {
1576 rect: Rect {
1577 x: 0.0,
1578 y: 0.0,
1579 w: 256.0,
1580 h: 256.0,
1581 },
1582 payload: Callback::new(overlay_payload),
1583 }],
1584 };
1585 let px = renderer
1586 .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1587 .expect("offscreen render");
1588 let at = |x: u32, y: u32| -> [u8; 4] {
1589 let i = ((y * 256 + x) * 4) as usize;
1590 [px[i], px[i + 1], px[i + 2], px[i + 3]]
1591 };
1592 assert_eq!(at(8, 8), [255, 0, 0, 255], "overlay covers background");
1593 assert_eq!(at(128, 128), [255, 0, 0, 255], "overlay covers sprite");
1594 }
1595
1596 #[test]
1597 fn surface_dp_matches_board_point_under_shake() {
1598 let geom = FrameGeom {
1602 fit: (1.0, 100.0, 0.0),
1603 look: [0.0, 0.0],
1604 density: 1.25,
1605 viewport_px: [1250.0, 750.0],
1606 roll: 0.0,
1607 pivot: [400.0, 300.0],
1608 };
1609 let ([ox, oy], [w, h]) = surface_dp([400.0, 300.0], [64.0, 80.0], geom);
1610 assert!(
1611 (ox - 468.0).abs() < 1e-4 && (oy - 260.0).abs() < 1e-4,
1612 "offset, got ({ox},{oy})"
1613 );
1614 assert!(
1615 (w - 64.0).abs() < 1e-4 && (h - 80.0).abs() < 1e-4,
1616 "size, got ({w},{h})"
1617 );
1618 let board = world_to_dp([400.0, 300.0], [800.0, 600.0], [400.0, 300.0], geom.fit);
1620 assert!(
1621 (ox + w * 0.5 - board[0]).abs() < 1e-4 && (oy + h * 0.5 - board[1]).abs() < 1e-4,
1622 "surface glued to board point"
1623 );
1624 let shaken = FrameGeom {
1625 look: [10.0, -10.0],
1626 ..geom
1627 };
1628 let ([sx, sy], _) = surface_dp([400.0, 300.0], [64.0, 80.0], shaken);
1629 let sboard = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], shaken.fit);
1630 assert!(
1631 (sx + w * 0.5 - sboard[0]).abs() < 1e-4 && (sy + h * 0.5 - sboard[1]).abs() < 1e-4,
1632 "surface glued under shake"
1633 );
1634 }
1635
1636 #[test]
1637 fn contain_fit_has_no_silent_clamp() {
1638 for (canvas, world) in [
1639 ([8000.0, 6000.0], [80.0, 60.0]),
1640 ([80.0, 60.0], [8000.0, 6000.0]),
1641 ([1000.0, 750.0], [800.0, 600.0]),
1642 ] {
1643 let fit = contain_fit(canvas, world);
1644 let cam = [world[0] * 0.5, world[1] * 0.5];
1645 let dp = world_to_dp([cam[0], cam[1]], world, cam, fit);
1646 let back = dp_to_world(dp, world, cam, fit);
1647 assert!(
1648 (back[0] - cam[0]).abs() < 1e-3 && (back[1] - cam[1]).abs() < 1e-3,
1649 "round trip for {canvas:?}/{world:?}, fit {fit:?} gave {back:?}"
1650 );
1651 }
1652 assert_eq!(contain_fit([0.0, 600.0], [800.0, 600.0]), (1.0, 0.0, 0.0));
1653 assert_eq!(
1654 dp_to_world(
1655 [10.0, 10.0],
1656 [800.0, 600.0],
1657 [400.0, 300.0],
1658 (0.0, 0.0, 0.0)
1659 ),
1660 [400.0, 300.0]
1661 );
1662 }
1663
1664 #[test]
1665 #[allow(deprecated)]
1666 fn camera_guards_never_div_by_zero() {
1667 let bad = Camera2d {
1668 center: Vec2::ZERO,
1669 offset: Vec2::ZERO,
1670 units_per_pixel: 0.0,
1671 zoom: 0.0,
1672 roll: 0.0,
1673 };
1674 let m = bad.view_proj([0.0, 0.0]);
1675 assert!(m.is_finite(), "view_proj must stay finite, got {m:?}");
1676 assert_eq!(bad.screen_to_world([0.0, 0.0], [5.0, 5.0]), Vec2::ZERO);
1677 }
1678
1679 #[test]
1680 fn fit_matrix_canvas_gpu_picks_agree() {
1681 let cam = Camera2d {
1682 center: Vec2::new(410.0, 290.0),
1683 offset: Vec2::ZERO,
1684 units_per_pixel: 1.0,
1685 zoom: 2.0,
1686 roll: 0.0,
1687 };
1688 let world_size = [800.0, 600.0];
1689 let density = 1.25;
1690 let vp_phys = [1250.0, 750.0];
1691 let canvas_dp = [vp_phys[0] / density, vp_phys[1] / density];
1692 let cam_center = cam.effective_center();
1693 let fit = effective_fit(canvas_dp, world_size, &cam);
1694 assert!((fit.0 - 2.0).abs() < 1e-4, "effective fit, got {fit:?}");
1695 let vp = cam.fit_matrix(canvas_dp, world_size);
1696 assert!(vp.is_finite());
1697 for world in [[400.0, 300.0], [410.0, 290.0], [0.0, 0.0], [800.0, 600.0]] {
1698 let [dx, dy] = world_to_dp(world, world_size, cam_center, fit);
1699 let px = [dx * density, dy * density];
1700 let ndc = vp.project_point3(glam::Vec3::new(world[0], world[1], 0.0));
1701 let gpu_px = [
1702 (ndc.x + 1.0) * 0.5 * vp_phys[0],
1703 (1.0 - ndc.y) * 0.5 * vp_phys[1],
1704 ];
1705 assert!(
1706 (px[0] - gpu_px[0]).abs() < 1e-2 && (px[1] - gpu_px[1]).abs() < 1e-2,
1707 "canvas/GPU disagree for {world:?}: canvas {px:?} vs gpu {gpu_px:?}"
1708 );
1709 let back =
1710 cam.dp_to_world_pt(canvas_dp, world_size, [px[0] / density, px[1] / density]);
1711 assert!(
1712 (back.x - world[0]).abs() < 1e-3 && (back.y - world[1]).abs() < 1e-3,
1713 "pick round trip for {world:?} gave {back:?}"
1714 );
1715 }
1716 }
1717
1718 #[test]
1719 fn letterbox_pick_matches_draw() {
1720 let cam = Camera2d {
1722 center: Vec2::new(400.0, 400.0),
1723 ..Default::default()
1724 };
1725 let fit = effective_fit([1600.0, 900.0], [800.0, 800.0], &cam);
1726 let dp = world_to_dp([400.0, 400.0], [800.0, 800.0], cam.effective_center(), fit);
1727 let back = dp_to_world(dp, [800.0, 800.0], cam.effective_center(), fit);
1728 assert!((back[0] - 400.0).abs() < 1e-3 && (back[1] - 400.0).abs() < 1e-3);
1729 let m = cam.fit_matrix([1600.0, 900.0], [800.0, 800.0]);
1731 let free = fit_view_proj([1600.0, 900.0], [800.0, 800.0], cam.effective_center(), fit);
1732 let dm = m - free;
1733 let max = dm
1734 .to_cols_array()
1735 .iter()
1736 .fold(0.0f32, |a, b| a.max(b.abs()));
1737 assert!(max < 1e-5);
1738 }
1739
1740 #[test]
1741 fn picks_ignore_viewport_origin() {
1742 let geom = FrameGeom {
1743 fit: (1.0, 100.0, 0.0),
1744 look: [0.0, 0.0],
1745 density: 1.25,
1746 viewport_px: [1250.0, 750.0],
1747 roll: 0.0,
1748 pivot: [400.0, 300.0],
1749 };
1750 let world_size = [800.0, 600.0];
1751 let a = pick_world([125.0, 75.0], geom, world_size);
1752 let b = pick_world([125.0, 75.0], geom, world_size);
1753 assert_eq!(a, b);
1754 let [wx, wy] = pick_world([125.0, 75.0], geom, world_size);
1755 assert!(
1756 (wx - 0.0).abs() < 1e-4 && (wy - 60.0).abs() < 1e-4,
1757 "local pick, got ({wx},{wy})"
1758 );
1759 }
1760
1761 #[test]
1762 fn offset_shifts_look_and_draw_together() {
1763 let world_size = [800.0, 600.0];
1766 let canvas_dp = [1000.0, 600.0];
1767 let mut cam = Camera2d {
1768 center: Vec2::new(400.0, 300.0),
1769 offset: Vec2::new(10.0, -10.0),
1770 units_per_pixel: 1.0,
1771 zoom: 1.0,
1772 roll: 0.0,
1773 };
1774 assert_eq!(cam.effective_center(), [410.0, 290.0]);
1775 let fit = effective_fit(canvas_dp, world_size, &cam);
1776 let dp = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
1777 let back = dp_to_world(dp, world_size, cam.effective_center(), fit);
1778 assert!((back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4);
1779 cam.offset = Vec2::ZERO;
1780 let dp2 = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
1781 assert!((dp[0] - dp2[0] + 10.0).abs() < 1e-4);
1783 assert!((dp[1] - dp2[1] - 10.0).abs() < 1e-4);
1784 }
1785
1786 #[test]
1787 fn limits_clamp_center_not_offset() {
1788 let limits = CameraLimits {
1789 left: 100.0,
1790 top: 100.0,
1791 right: 700.0,
1792 bottom: 500.0,
1793 enabled: true,
1794 };
1795 assert_eq!(apply_limits([50.0, 300.0], limits), [100.0, 300.0]);
1796 assert_eq!(apply_limits([400.0, 900.0], limits), [400.0, 500.0]);
1797 assert_eq!(apply_limits([400.0, 300.0], limits), [400.0, 300.0]);
1798 assert_eq!(
1799 apply_limits(
1800 [50.0, 50.0],
1801 CameraLimits {
1802 enabled: false,
1803 ..limits
1804 }
1805 ),
1806 [50.0, 50.0]
1807 );
1808 }
1809
1810 #[test]
1811 fn smoothing_converges_without_overshoot() {
1812 let target = [100.0, 0.0];
1813 let p1 = smooth_toward([0.0, 0.0], target, 5.0, 0.016);
1814 assert!(p1[0] > 0.0 && p1[0] < 100.0, "eases forward, got {p1:?}");
1815 let mut p = [0.0, 0.0];
1816 for _ in 0..600 {
1817 p = smooth_toward(p, target, 5.0, 0.016);
1818 }
1819 assert!((p[0] - 100.0).abs() < 1e-2, "settles, got {p:?}");
1820 assert_eq!(smooth_toward([1.0, 2.0], target, 0.0, 0.016), target);
1821 assert_eq!(smooth_toward([1.0, 2.0], target, 5.0, 0.0), [1.0, 2.0]);
1822 }
1823
1824 #[test]
1825 fn touch_gate_and_screen_space() {
1826 use repose_core::Vec2 as RVec2;
1827 use repose_core::input::{
1828 Modifiers, PointerButton, PointerEvent, PointerEventKind, PointerId, PointerKind,
1829 };
1830 let ev_of = |kind: PointerKind| {
1831 let mut ev = PointerEvent::new(
1832 PointerId(3),
1833 kind,
1834 PointerEventKind::Down(PointerButton::Primary),
1835 RVec2 { x: 10.0, y: 20.0 },
1836 1.0,
1837 Modifiers::default(),
1838 );
1839 ev.origin = RVec2 { x: 5.0, y: 7.0 };
1840 ev
1841 };
1842 assert!(is_touch(&ev_of(PointerKind::Touch)));
1844 assert!(is_touch(&ev_of(PointerKind::Pen)));
1845 assert!(!is_touch(&ev_of(PointerKind::Mouse)));
1846 assert_eq!(screen_of(&ev_of(PointerKind::Touch)), [15.0, 27.0]);
1848 }
1849
1850 #[test]
1851 fn click_slop_cancels_drags() {
1852 assert!(click_within_slop([100.0, 100.0], [105.0, 105.0]));
1853 assert!(!click_within_slop(
1854 [100.0, 100.0],
1855 [100.0 + CLICK_SLOP_PX + 1.0, 100.0]
1856 ));
1857 }
1858
1859 #[test]
1860 fn roll_roundtrips_and_moves_pixels() {
1861 let cam = Camera2d {
1862 center: Vec2::new(400.0, 300.0),
1863 offset: Vec2::ZERO,
1864 units_per_pixel: 1.0,
1865 zoom: 1.0,
1866 roll: std::f32::consts::FRAC_PI_2,
1867 };
1868 let world = [800.0, 600.0];
1869 let dp = [1000.0, 600.0];
1870 let fit = effective_fit(dp, world, &cam);
1871 for p in [[400.0, 300.0], [500.0, 300.0], [0.0, 0.0]] {
1872 let fwd = world_to_dp_with_roll(p, world, cam.effective_center(), fit, cam.roll);
1873 let back = dp_to_world_with_roll(fwd, world, cam.effective_center(), fit, cam.roll);
1874 assert!(
1875 (back[0] - p[0]).abs() < 1e-3 && (back[1] - p[1]).abs() < 1e-3,
1876 "roll roundtrip for {p:?} gave {back:?}"
1877 );
1878 }
1879 let plain = world_to_dp([500.0, 300.0], world, cam.effective_center(), fit);
1880 let rolled =
1881 world_to_dp_with_roll([500.0, 300.0], world, cam.effective_center(), fit, cam.roll);
1882 assert!(
1883 (plain[0] - rolled[0]).abs() + (plain[1] - rolled[1]).abs() > 1.0,
1884 "roll must move pixels, plain {plain:?} rolled {rolled:?}"
1885 );
1886 let m0 = fit_view_proj_with_roll(dp, world, cam.effective_center(), fit, 0.0);
1887 let legacy = fit_view_proj(dp, world, cam.effective_center(), fit);
1888 assert_eq!(m0, legacy);
1889 }
1890
1891 #[test]
1892 fn surface_tracks_roll_about_pivot() {
1893 let geom = FrameGeom {
1894 fit: (1.0, 0.0, 0.0),
1895 look: [0.0, 0.0],
1896 density: 1.0,
1897 viewport_px: [800.0, 600.0],
1898 roll: std::f32::consts::FRAC_PI_2,
1899 pivot: [400.0, 300.0],
1900 };
1901 let ([ox, oy], _) = surface_dp([500.0, 300.0], [10.0, 10.0], geom);
1902 assert!(
1903 (ox - 395.0).abs() < 1e-3 && (oy - 395.0).abs() < 1e-3,
1904 "rolled surface, got ({ox},{oy})"
1905 );
1906 }
1907
1908 #[test]
1909 fn geom_handle_compat_wraps_legacy_handles() {
1910 use std::cell::Cell;
1911 use std::rc::Rc;
1912 let rc = Rc::new(Cell::new(FrameGeom {
1913 fit: (2.0, 1.0, 2.0),
1914 ..Default::default()
1915 }));
1916 let h: GeomHandle = rc.into();
1917 assert_eq!(h.get().fit, (2.0, 1.0, 2.0));
1918 let arc = Arc::new(Mutex::new(FrameGeom::default()));
1919 let h2: GeomHandle = arc.into();
1920 assert_eq!(h2.get().density, 1.0);
1921 }
1922}