Skip to main content

repame_sprite/
lib.rs

1//! 2D sprite viewport: snapshot in, pixels out.
2//!
3//! The UI builds a [`FrameInput`] per frame (plain data, rebuilt
4//! during composition) and mounts [`Viewport2d`] as a Repose view, which
5//! draws the snapshot through a dp-space contain-fit and reports pointer
6//! picks back in world coords. Unit discipline lives here, once:
7//! games work in world/dp units and do not touch physical px.
8//! Camera state lives in Repose signals, not in the renderer.
9
10use std::cell::{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::{Callback, CallbackResources, ScreenDescriptor, WgpuCallback};
20use repose_ui::Box as UiBox;
21use repose_ui::ViewExt;
22
23pub mod batch;
24pub use batch::{
25    AtlasUpload, BatchDesc, SpriteBatch, TextureFilter, draw_batch, draw_batch_with_id, frame_uv,
26    instance_rows, screen_camera, sprite_aabb,
27};
28
29pub mod fullscreen;
30pub use fullscreen::{FullscreenDesc, FullscreenPass, FullscreenTexture};
31
32pub mod post;
33
34/// 2D orthographic camera. Owned by the UI (signals), copied into the
35/// snapshot per frame. `effective_center` is `center + offset`.
36/// Canvas, GPU batch, and picks derive from these fields together.
37#[derive(Clone, Copy, Debug)]
38pub struct Camera2d {
39    /// Follow target in world units. Defaults to `(0, 0)`.
40    /// Scroll limits clamp this field; shake goes in `offset`.
41    pub center: Vec2,
42    /// Momentary displacement added to `center`. Defaults to `(0, 0)`.
43    /// Applied after scroll limits; game code decays it toward zero.
44    pub offset: Vec2,
45    /// World units per screen pixel at zoom 1. Defaults to `1.0`.
46    /// Visible width is `viewport_dp * units_per_pixel / zoom`.
47    /// Non-positive values fall back to `1.0` (guards division).
48    pub units_per_pixel: f32,
49    /// Magnification. Defaults to `1.0`; higher zooms in.
50    /// Non-positive values fall back to `1.0`.
51    pub zoom: f32,
52    /// Roll in radians about the look point. Defaults to `0.0`.
53    /// Applied on canvas, GPU, picks, and actor surfaces alike.
54    pub roll: f32,
55}
56
57impl Default for Camera2d {
58    fn default() -> Self {
59        Self {
60            center: Vec2::ZERO,
61            offset: Vec2::ZERO,
62            units_per_pixel: 1.0,
63            zoom: 1.0,
64            roll: 0.0,
65        }
66    }
67}
68
69/// Scroll limits and follow smoothing live in `game_utils_repame::feel`.
70/// They run game-side when producing the snapshot; this crate only
71impl Camera2d {
72    /// Effective look point: `center + offset`.
73    /// Canvas, GPU batch, picks, and actor surfaces all use this value.
74    pub fn effective_center(&self) -> [f32; 2] {
75        [self.center.x + self.offset.x, self.center.y + self.offset.y]
76    }
77
78    /// Framing matrix used by GPU + picks. Prefer this over raw ortho.
79    /// Includes [`roll`](Camera2d::roll) around the look point.
80    pub fn fit_matrix(&self, canvas_dp: [f32; 2], world_size: [f32; 2]) -> Mat4 {
81        let fit = effective_fit(canvas_dp, world_size, self);
82        fit_view_proj_with_roll(
83            canvas_dp,
84            world_size,
85            self.effective_center(),
86            fit,
87            self.roll,
88        )
89    }
90
91    /// World point under a dp-space cursor (density divided out).
92    /// Includes roll.
93    pub fn dp_to_world_pt(&self, canvas_dp: [f32; 2], world_size: [f32; 2], dp: [f32; 2]) -> Vec2 {
94        let fit = effective_fit(canvas_dp, world_size, self);
95        let w = dp_to_world_with_roll(dp, world_size, self.effective_center(), fit, self.roll);
96        Vec2::new(w[0], w[1])
97    }
98
99    /// Raw ortho ignoring contain-fit letterbox. Kept for HUD overlays in
100    /// screen space; viewport code must use [`fit_matrix`](Camera2d::fit_matrix).
101    pub fn view_proj_screen(&self, viewport_px: [f32; 2]) -> Mat4 {
102        let vp_w = viewport_px[0].max(1.0);
103        let vp_h = viewport_px[1].max(1.0);
104        let zoom = sane_positive(self.zoom);
105        let upp = sane_positive(self.units_per_pixel);
106        let w = vp_w * upp / zoom;
107        let h = vp_h * upp / zoom;
108        let proj = glam::camera::rh::proj::directx::orthographic(
109            -w / 2.0,
110            w / 2.0,
111            h / 2.0,
112            -h / 2.0,
113            -1000.0,
114            1000.0,
115        );
116        let view = Mat4::from_translation(Vec3::new(
117            -(self.center.x + self.offset.x),
118            -(self.center.y + self.offset.y),
119            0.0,
120        ));
121        proj * view
122    }
123
124    /// Legacy y-down view-projection: world +y points screen-down.
125    /// Frames from `viewport * units_per_pixel / zoom` and ignores
126    /// `world_size`, so it disagrees with canvas, GPU, and picks
127    /// under letterbox. Viewport code must use [`fit_view_proj`].
128    #[deprecated(
129        since = "0.1.8",
130        note = "ignores world_size; use fit_view_proj for the shared canvas/GPU/pick framing contract"
131    )]
132    pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
133        self.view_proj_screen(viewport_px)
134    }
135
136    /// Legacy position under a viewport-pixel cursor.
137    /// Inverts `view_proj` with the same letterbox divergence.
138    /// Picks must use [`pick_world`] over the painted [`FrameGeom`].
139    #[deprecated(
140        since = "0.1.8",
141        note = "ignores world_size; use pick_world over the painted FrameGeom instead"
142    )]
143    #[allow(deprecated)]
144    pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
145        if viewport_px[0] <= 0.0 || viewport_px[1] <= 0.0 {
146            let [cx, cy] = self.effective_center();
147            return Vec2::new(cx, cy);
148        }
149        let ndc = Vec2::new(
150            (px[0] / viewport_px[0]) * 2.0 - 1.0,
151            1.0 - (px[1] / viewport_px[1]) * 2.0,
152        );
153        let inv = self.view_proj(viewport_px).inverse();
154        let world = inv.project_point3(ndc.extend(0.0));
155        Vec2::new(world.x, world.y)
156    }
157}
158
159/// Blend mode per sprite. Additive uses `SrcAlpha + One`.
160/// Sorted into ranges (alpha, then multiply, then additive): push order
161/// across blend modes is NOT preserved, see the batch module docs.
162#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
163pub enum SpriteBlend {
164    #[default]
165    Alpha,
166    Additive,
167    Multiply,
168}
169
170/// One batched sprite. `uv` is in atlas texels normalized to 0..1.
171#[derive(Clone, Copy, Debug)]
172pub struct SpriteInstance {
173    /// World-space center, rotation radians, size in world units.
174    pub center: Vec2,
175    pub rotation: f32,
176    pub size: Vec2,
177    /// Normalized anchor: `[0.5, 0.5]` centers the quad on `center`,
178    /// `[0, 0]` pins the top-left corner. Y-down.
179    pub anchor: Vec2,
180    /// Mirror about the anchor axes. Canvas fills are flip-invariant;
181    /// the GPU batch mirrors geometry (UVs untouched).
182    pub flip_x: bool,
183    pub flip_y: bool,
184    pub uv_min: Vec2,
185    pub uv_max: Vec2,
186    /// RGBA tint, `(c * 255) as u8` per channel.
187    pub color: [f32; 4],
188    /// Atlas page index for multi-texture batches.
189    pub page: u32,
190    /// Draw key within one blend range; higher draws on top. Default 0.
191    /// Stable-sorted by `z` inside the range, but blend ranges draw
192    /// alpha-then-multiply-then-additive regardless of `z` (see
193    /// [`SpriteBlend`]).
194    pub z: f32,
195    pub blend: SpriteBlend,
196}
197
198impl Default for SpriteInstance {
199    fn default() -> Self {
200        Self {
201            center: Vec2::ZERO,
202            rotation: 0.0,
203            size: Vec2::ONE,
204            anchor: Vec2::new(0.5, 0.5),
205            flip_x: false,
206            flip_y: false,
207            uv_min: Vec2::ZERO,
208            uv_max: Vec2::ONE,
209            color: [1.0, 1.0, 1.0, 1.0],
210            page: 0,
211            z: 0.0,
212            blend: SpriteBlend::Alpha,
213        }
214    }
215}
216
217/// World-anchored text floater (damage numbers, `+25`).
218#[derive(Clone, Debug, Default)]
219pub struct WorldText {
220    pub text: String,
221    /// World-space anchor.
222    pub pos: Vec2,
223    /// RGBA, `(c * 255) as u8` per channel.
224    pub color: [f32; 4],
225    /// Font size in world units.
226    pub size: f32,
227}
228
229/// Everything the viewport draws this frame. Plain snapshot data.
230///
231/// Framing contract: canvas, GPU, and picks all derive from `world_size`
232/// + `cam` through [`effective_fit`] / [`fit_view_proj`] / [`world_to_dp`].
233#[derive(Clone, Default, Debug)]
234pub struct FrameInput {
235    pub cam: Camera2d,
236    /// World-space extent the contain-fit maps into the viewport.
237    pub world_size: [f32; 2],
238    /// Cold-start viewport size in dp (physical px / density), used for
239    /// the GPU camera until the first painted [`FrameGeom`] arrives.
240    /// Divide physical px by density before storing here.
241    pub viewport_dp: [f32; 2],
242    pub sprites: Vec<SpriteInstance>,
243    /// World-anchored text, drawn after the sprite pass on canvas.
244    /// GPU viewports ignore this field; compose texts as sibling views.
245    pub texts: Vec<WorldText>,
246    /// Full-viewport fill under everything (letterbox included).
247    pub background: Option<[f32; 4]>,
248    /// Optional fullscreen tint, applied after the sprite pass on
249    /// canvas and GPU (`(c * 255) as u8` per channel, alpha-blended).
250    pub overlay_color: Option<[f32; 4]>,
251    /// Chromatic aberration amount. GPU renders offscreen and composites
252    /// back with an RGB split; `0.0` draws the batch into the main pass.
253    /// Canvas ignores it (vector path has no pixels to sample).
254    pub chroma: f32,
255}
256
257/// UI-facing pointer events from the viewport.
258/// `Click` fires on pointer up when the press started inside and the
259/// pointer stayed within slop. `Press` is the down-edge.
260#[derive(Clone, Debug)]
261pub enum PickEvent {
262    Press {
263        world: Vec2,
264        screen: [f32; 2],
265        button: repose_core::input::PointerButton,
266    },
267    Click {
268        world: Vec2,
269        screen: [f32; 2],
270        button: repose_core::input::PointerButton,
271    },
272    Hover {
273        world: Vec2,
274        /// Window-physical px (y-down), same space as `Press.screen`.
275        /// Games stage this raw and unproject through the live camera
276        /// each frame (screen-anchored aim); the `world` point is baked
277        /// through the camera at event time and goes stale as the
278        /// camera moves.
279        screen: [f32; 2],
280    },
281    /// Touch/pen contact began: pointer id + window-physical px.
282    /// Mouse does not emit these; taps still emit `Click` too.
283    TouchDown {
284        id: u64,
285        screen: [f32; 2],
286    },
287    /// Touch/pen contact moved (same coordinate space as `TouchDown`).
288    TouchMove {
289        id: u64,
290        screen: [f32; 2],
291    },
292    /// Touch/pen contact ended (up / leave).
293    TouchUp {
294        id: u64,
295    },
296}
297
298/// Touch/pen pointers drive game touch zones;
299/// mouse stays on the click/hover path.
300/// All `screen` fields below are window-physical px (y-down)
301/// (`position_in_window`): region-local `position` would offset picks
302/// by the region origin on any non-fullscreen viewport.
303fn is_touch(ev: &repose_core::input::PointerEvent) -> bool {
304    matches!(
305        ev.kind,
306        repose_core::input::PointerKind::Touch | repose_core::input::PointerKind::Pen
307    )
308}
309
310/// Window-physical px (y-down) for touch-zone sampling.
311fn screen_of(ev: &repose_core::input::PointerEvent) -> [f32; 2] {
312    let p = ev.position_in_window();
313    [p.x, p.y]
314}
315
316/// Button carried by a down/up pointer event; move/hover/leave have no
317/// button, so they report Primary (callers ignore it there).
318fn button_of(ev: &repose_core::input::PointerEvent) -> repose_core::input::PointerButton {
319    match ev.event {
320        repose_core::input::PointerEventKind::Down(b)
321        | repose_core::input::PointerEventKind::Up(b) => b,
322        _ => repose_core::input::PointerButton::Primary,
323    }
324}
325
326/// Clamp helper: finite positive values pass through, the rest fall
327/// back to 1.0 so framing math holds a valid divisor.
328fn sane_positive(v: f32) -> f32 {
329    if v.is_finite() && v > 1e-6 { v } else { 1.0 }
330}
331
332/// Contain-fit of a `world` extent into a dp-space canvas: uniform
333/// scale plus centering offsets. `world_to_dp` / `dp_to_world` invert
334/// each other for any positive scale.
335pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
336    if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
337        return (1.0, 0.0, 0.0);
338    }
339    let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
340    if !s.is_finite() || s <= 1e-6 {
341        return (1.0, 0.0, 0.0);
342    }
343    (
344        s,
345        (canvas_dp[0] - world[0] * s) * 0.5,
346        (canvas_dp[1] - world[1] * s) * 0.5,
347    )
348}
349
350/// Effective fit for one frame: base contain-fit scaled by
351/// `zoom / units_per_pixel`, re-centered. Default camera matches
352/// [`contain_fit`]; zoom 2 doubles on-screen size on both backends.
353pub fn effective_fit(canvas_dp: [f32; 2], world: [f32; 2], cam: &Camera2d) -> (f32, f32, f32) {
354    let (base, _, _) = contain_fit(canvas_dp, world);
355    let zoom = sane_positive(cam.zoom);
356    let upp = sane_positive(cam.units_per_pixel);
357    let s = base * zoom / upp;
358    if !s.is_finite() || s <= 1e-6 {
359        return (1.0, 0.0, 0.0);
360    }
361    (
362        s,
363        (canvas_dp[0] - world[0] * s) * 0.5,
364        (canvas_dp[1] - world[1] * s) * 0.5,
365    )
366}
367
368/// GPU camera for the shared framing contract: matrix form of
369/// [`world_to_dp`] over `canvas_dp` (dp = physical px / density).
370pub fn fit_view_proj(
371    canvas_dp: [f32; 2],
372    world_size: [f32; 2],
373    cam_center: [f32; 2],
374    fit: (f32, f32, f32),
375) -> Mat4 {
376    fit_view_proj_with_roll(canvas_dp, world_size, cam_center, fit, 0.0)
377}
378
379/// [`fit_view_proj`] plus camera roll about the look point.
380/// `0.0` matches [`fit_view_proj`].
381pub fn fit_view_proj_with_roll(
382    canvas_dp: [f32; 2],
383    world_size: [f32; 2],
384    cam_center: [f32; 2],
385    fit: (f32, f32, f32),
386    roll: f32,
387) -> Mat4 {
388    let w = canvas_dp[0].max(1.0);
389    let h = canvas_dp[1].max(1.0);
390    let (s, ox, oy) = fit;
391    let s = if s.is_finite() && s > 1e-6 { s } else { 1.0 };
392    let lx = cam_center[0] - world_size[0] * 0.5;
393    let ly = cam_center[1] - world_size[1] * 0.5;
394    let sx = 2.0 * s / w;
395    let sy = -2.0 * s / h;
396    let tx = 2.0 * (ox - lx * s) / w - 1.0;
397    let ty = 1.0 - 2.0 * (oy - ly * s) / h;
398    let base = Mat4::from_cols(
399        glam::Vec4::new(sx, 0.0, 0.0, 0.0),
400        glam::Vec4::new(0.0, sy, 0.0, 0.0),
401        glam::Vec4::new(0.0, 0.0, 0.001, 0.0),
402        glam::Vec4::new(tx, ty, 0.0, 1.0),
403    );
404    if !roll.is_finite() || roll.abs() < 1e-7 {
405        return base;
406    }
407    let (c, r) = (roll.cos(), roll.sin());
408    let rot = Mat4::from_cols(
409        glam::Vec4::new(c, r, 0.0, 0.0),
410        glam::Vec4::new(-r, c, 0.0, 0.0),
411        glam::Vec4::new(0.0, 0.0, 1.0, 0.0),
412        glam::Vec4::new(
413            cam_center[0] - c * cam_center[0] + r * cam_center[1],
414            cam_center[1] - r * cam_center[0] - c * cam_center[1],
415            0.0,
416            1.0,
417        ),
418    );
419    base * rot
420}
421
422/// World point to dp canvas point through the fit. At the default
423/// center (`world / 2`) this is `offset + world * scale`.
424pub fn world_to_dp(
425    world: [f32; 2],
426    world_size: [f32; 2],
427    cam_center: [f32; 2],
428    fit: (f32, f32, f32),
429) -> [f32; 2] {
430    world_to_dp_with_roll(world, world_size, cam_center, fit, 0.0)
431}
432
433/// [`world_to_dp`] plus camera roll around `cam_center`.
434pub fn world_to_dp_with_roll(
435    world: [f32; 2],
436    world_size: [f32; 2],
437    cam_center: [f32; 2],
438    fit: (f32, f32, f32),
439    roll: f32,
440) -> [f32; 2] {
441    let (s, ox, oy) = fit;
442    let [wx, wy] = rotate_about(world, cam_center, roll);
443    [
444        ox + (wx - (cam_center[0] - world_size[0] * 0.5)) * s,
445        oy + (wy - (cam_center[1] - world_size[1] * 0.5)) * s,
446    ]
447}
448
449/// Inverse of [`world_to_dp`]. Degenerate scales return the look point.
450pub fn dp_to_world(
451    dp: [f32; 2],
452    world_size: [f32; 2],
453    cam_center: [f32; 2],
454    fit: (f32, f32, f32),
455) -> [f32; 2] {
456    dp_to_world_with_roll(dp, world_size, cam_center, fit, 0.0)
457}
458
459/// Inverse of [`world_to_dp_with_roll`].
460pub fn dp_to_world_with_roll(
461    dp: [f32; 2],
462    world_size: [f32; 2],
463    cam_center: [f32; 2],
464    fit: (f32, f32, f32),
465    roll: f32,
466) -> [f32; 2] {
467    let (s, ox, oy) = fit;
468    if !s.is_finite() || s.abs() < 1e-6 {
469        return cam_center;
470    }
471    let base = [
472        (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
473        (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
474    ];
475    rotate_about(base, cam_center, -roll)
476}
477
478fn rotate_about(p: [f32; 2], center: [f32; 2], roll: f32) -> [f32; 2] {
479    if !roll.is_finite() || roll.abs() < 1e-7 {
480        return p;
481    }
482    let (c, r) = (roll.cos(), roll.sin());
483    let dx = p[0] - center[0];
484    let dy = p[1] - center[1];
485    [center[0] + c * dx - r * dy, center[1] + r * dx + c * dy]
486}
487
488/// Viewport-local physical-px press to world point through the painted
489/// [`FrameGeom`]. Takes the region-local position, so HUD chrome or a
490/// non-fullscreen viewport does not shift picks.
491///
492/// Picks track the *painted* (possibly shaken) view by design: correct for
493/// clicking on a drawn sprite. For continuous aim (mouse crosshair, NT
494/// firing), do NOT reuse the shaken geom: unproject the staged raw px via
495/// [`unproject_px`] or [`aim_world`] through a geom built without the shake
496/// offset, otherwise the aim point inherits shake jitter exactly when
497/// trauma is highest.
498pub fn pick_world(local_px: [f32; 2], geom: FrameGeom, world_size: [f32; 2]) -> [f32; 2] {
499    let d = if geom.density.is_finite() && geom.density > 1e-6 {
500        geom.density
501    } else {
502        1.0
503    };
504    dp_to_world_with_roll(
505        [local_px[0] / d, local_px[1] / d],
506        world_size,
507        geom.pivot,
508        geom.fit,
509        geom.roll,
510    )
511}
512
513/// Continuous-aim inverse: like [`pick_world`] but through an *unshaken*
514/// camera (`offset` and trauma roll zeroed). Picks must track the painted
515/// view; aim must not inherit shake jitter. Pass the same `world_size` and
516/// density the viewport painted with, plus the look point and roll of the
517/// camera *without* shake applied.
518pub fn aim_world(
519    local_px: [f32; 2],
520    look_unshaken: [f32; 2],
521    roll_unshaken: f32,
522    fit: (f32, f32, f32),
523    density: f32,
524    world_size: [f32; 2],
525) -> [f32; 2] {
526    let d = if density.is_finite() && density > 1e-6 {
527        density
528    } else {
529        1.0
530    };
531    dp_to_world_with_roll(
532        [local_px[0] / d, local_px[1] / d],
533        world_size,
534        look_unshaken,
535        fit,
536        roll_unshaken,
537    )
538}
539
540/// [`aim_world`] straight from a [`Camera2d`]: uses the follow target
541/// (`center`, no shake `offset`) with zero roll so firing aim never sprays
542/// with the noise function. Paint the viewport with the shaken camera;
543/// read aim with this.
544pub fn aim_world_unshaken(
545    local_px: [f32; 2],
546    cam: &Camera2d,
547    canvas_dp: [f32; 2],
548    world_size: [f32; 2],
549    density: f32,
550) -> [f32; 2] {
551    let fit = effective_fit(canvas_dp, world_size, cam);
552    aim_world(
553        local_px,
554        [cam.center.x, cam.center.y],
555        0.0,
556        fit,
557        density,
558        world_size,
559    )
560}
561
562/// One painted frame's geometry, shared between viewports (writers)
563/// and world-anchored siblings such as [`ActorFrame`] (readers).
564/// Maps either direction so board sprites, picks, and actor
565/// surfaces track together, including under camera shake.
566///
567/// Timing: layout publishes ahead of `prepare` and paint re-publishes;
568/// composition-time readers see the previous frame's geometry, so
569/// placement trails the board by one frame under camera motion.
570/// Event-time readers (picks) see the latest publish.
571#[derive(Clone, Copy, Debug)]
572pub struct FrameGeom {
573    /// Effective dp fit of the world extent: `(scale, off_x, off_y)` from
574    /// [`effective_fit`] (contain-fit scaled by zoom/units_per_pixel).
575    pub fit: (f32, f32, f32),
576    /// Look-point shift in world units: `cam.effective_center() - world / 2`.
577    /// Zero when the camera is default.
578    pub look: [f32; 2],
579    /// Physical px per dp at paint time.
580    pub density: f32,
581    /// Painted viewport size in physical px (drives the GPU camera).
582    pub viewport_px: [f32; 2],
583    /// Camera roll in radians around the look point.
584    pub roll: f32,
585    /// Camera look point in world coords at publish time.
586    /// Roll pivot for [`surface_dp`] and picks.
587    pub pivot: [f32; 2],
588}
589
590impl Default for FrameGeom {
591    fn default() -> Self {
592        Self {
593            fit: (1.0, 0.0, 0.0),
594            look: [0.0, 0.0],
595            density: 1.0,
596            viewport_px: [1.0, 1.0],
597            roll: 0.0,
598            pivot: [0.0, 0.0],
599        }
600    }
601}
602
603/// Shared paint-time geometry. Canvas views use this on the UI thread;
604/// GPU callbacks hold it behind a mutex (`Send + Sync`).
605#[derive(Clone, Default)]
606pub struct GeomHandle(Arc<Mutex<FrameGeom>>);
607
608impl GeomHandle {
609    pub fn new() -> Self {
610        Self(Arc::new(Mutex::new(FrameGeom::default())))
611    }
612
613    pub fn get(&self) -> FrameGeom {
614        self.0.lock().map(|g| *g).unwrap_or_default()
615    }
616
617    pub fn set(&self, g: FrameGeom) {
618        if let Ok(mut slot) = self.0.lock() {
619            *slot = g;
620        }
621    }
622
623    pub fn arc(&self) -> Arc<Mutex<FrameGeom>> {
624        self.0.clone()
625    }
626}
627
628impl From<FrameGeom> for GeomHandle {
629    fn from(g: FrameGeom) -> Self {
630        Self(Arc::new(Mutex::new(g)))
631    }
632}
633
634impl From<std::sync::Arc<std::sync::Mutex<FrameGeom>>> for GeomHandle {
635    /// Wrap GPU call sites that hold the raw mutex handle.
636    fn from(arc: std::sync::Arc<std::sync::Mutex<FrameGeom>>) -> Self {
637        Self(arc)
638    }
639}
640
641impl From<std::rc::Rc<std::cell::Cell<FrameGeom>>> for GeomHandle {
642    /// Wrap canvas call sites holding the old cell handle by snapshotting
643    /// the current value into the shared handle.
644    fn from(rc: std::rc::Rc<std::cell::Cell<FrameGeom>>) -> Self {
645        Self(Arc::new(Mutex::new(rc.get())))
646    }
647}
648
649/// Press slop in dp: pointer-up farther than this from pointer-down
650/// cancels the `Click` (drag-off-cancel). Density-independent: compared
651/// after dividing physical-px deltas by the painted density, so a tap
652/// cancels identically on density 1 and density 3.
653pub const CLICK_SLOP_DP: f32 = 12.0;
654/// Legacy physical-px slop value. Kept so old call sites keep compiling;
655/// new code uses [`CLICK_SLOP_DP`] via [`click_within_slop_dp`].
656pub const CLICK_SLOP_PX: f32 = CLICK_SLOP_DP;
657
658/// Legacy px-space slop check (density 1). New code uses
659/// [`click_within_slop_dp`].
660#[allow(dead_code)]
661fn click_within_slop(a: [f32; 2], b: [f32; 2]) -> bool {
662    click_within_slop_dp(a, b, 1.0)
663}
664
665/// Dp-space slop check: physical-px points divided by `density` (falling
666/// back to 1.0) before comparing against [`CLICK_SLOP_DP`].
667pub fn click_within_slop_dp(a: [f32; 2], b: [f32; 2], density: f32) -> bool {
668    let d = if density.is_finite() && density > 1e-6 {
669        density
670    } else {
671        1.0
672    };
673    let dx = (a[0] - b[0]) / d;
674    let dy = (a[1] - b[1]) / d;
675    dx * dx + dy * dy <= CLICK_SLOP_DP * CLICK_SLOP_DP
676}
677
678/// World-anchored surface rect in dp: `([off_x, off_y], [w, h])`.
679/// `ActorFrame` is its view form. The center rotates with roll about
680/// the pivot; size stays axis-aligned.
681pub fn surface_dp(center: [f32; 2], size: [f32; 2], geom: FrameGeom) -> ([f32; 2], [f32; 2]) {
682    let (s, ox, oy) = geom.fit;
683    let r = rotate_about(center, geom.pivot, geom.roll);
684    (
685        [
686            ox + (r[0] - size[0] * 0.5 - geom.look[0]) * s,
687            oy + (r[1] - size[1] * 0.5 - geom.look[1]) * s,
688        ],
689        [size[0] * s, size[1] * s],
690    )
691}
692
693fn rgba8(c: [f32; 4]) -> Color {
694    Color::from_rgba(
695        (c[0].clamp(0.0, 1.0) * 255.0) as u8,
696        (c[1].clamp(0.0, 1.0) * 255.0) as u8,
697        (c[2].clamp(0.0, 1.0) * 255.0) as u8,
698        (c[3].clamp(0.0, 1.0) * 255.0) as u8,
699    )
700}
701
702/// 2D viewport view. Gesture handling and camera state live in the
703/// [`FrameInput`] snapshot. Fills its parent; draws `background`, then
704/// sprites, then world texts, then the fullscreen tint. Each paint
705/// publishes [`FrameGeom`] to `geom_out` for dp-space siblings.
706#[allow(non_snake_case)] // Repose view convention (cf. resims `Viewport3d`).
707pub fn Viewport2d(
708    input: FrameInput,
709    geom_out: GeomHandle,
710    on_event: impl Fn(PickEvent) + 'static,
711) -> View {
712    let input = Rc::new(input);
713    let world_size = input.world_size;
714    let pick_geom = geom_out.clone();
715    let move_geom = geom_out.clone();
716    let release_geom = geom_out.clone();
717    let draw_input = input.clone();
718    let draw_geom = geom_out.clone();
719    let on_event = Rc::new(on_event);
720    let on_down = on_event.clone();
721    let on_touch_down = on_event.clone();
722    let on_move = on_event.clone();
723    let on_touch_move = on_event.clone();
724    let on_up = on_event.clone();
725    let on_up_click = on_event.clone();
726    let on_leave = on_event;
727    let press: Rc<RefCell<HashMap<u64, [f32; 2]>>> = Rc::new(RefCell::new(HashMap::new()));
728    let press_down = press.clone();
729    let press_up = press.clone();
730    let press_leave = press.clone();
731
732    let modifier = Modifier::new()
733        .fill_max_size()
734        .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
735            let p = ev.position;
736            let g = pick_geom.get();
737            let world = pick_world([p.x, p.y], g, world_size);
738            press_down.borrow_mut().insert(ev.id.0, [p.x, p.y]);
739            let w = ev.position_in_window();
740            let button = button_of(&ev);
741            on_down(PickEvent::Press {
742                world: Vec2::new(world[0], world[1]),
743                screen: [w.x, w.y],
744                button,
745            });
746            if is_touch(&ev) {
747                on_touch_down(PickEvent::TouchDown {
748                    id: ev.id.0,
749                    screen: screen_of(&ev),
750                });
751            }
752        })
753        .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
754            let p = ev.position;
755            let g = move_geom.get();
756            let world = pick_world([p.x, p.y], g, world_size);
757            let w = ev.position_in_window();
758            on_move(PickEvent::Hover {
759                world: Vec2::new(world[0], world[1]),
760                screen: [w.x, w.y],
761            });
762            if is_touch(&ev) {
763                on_touch_move(PickEvent::TouchMove {
764                    id: ev.id.0,
765                    screen: screen_of(&ev),
766                });
767            }
768        })
769        .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
770            let p = ev.position;
771            let start = press_up.borrow_mut().remove(&ev.id.0);
772            if let Some(start) = start
773                && click_within_slop_dp(start, [p.x, p.y], release_geom.get().density)
774            {
775                let g = release_geom.get();
776                let world = pick_world([p.x, p.y], g, world_size);
777                let w = ev.position_in_window();
778                let button = button_of(&ev);
779                on_up_click(PickEvent::Click {
780                    world: Vec2::new(world[0], world[1]),
781                    screen: [w.x, w.y],
782                    button,
783                });
784            }
785            if is_touch(&ev) {
786                on_up(PickEvent::TouchUp { id: ev.id.0 });
787            }
788        })
789        .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
790            press_leave.borrow_mut().remove(&ev.id.0);
791            if is_touch(&ev) {
792                on_leave(PickEvent::TouchUp { id: ev.id.0 });
793            }
794        });
795    Canvas(modifier, move |scope: &mut DrawScope| {
796        let d = effective_density_scale();
797        let cam = draw_input.cam;
798        let cam_center = cam.effective_center();
799        let fit = effective_fit(
800            [scope.size.width / d, scope.size.height / d],
801            draw_input.world_size,
802            &cam,
803        );
804        draw_geom.set(FrameGeom {
805            fit,
806            look: [
807                cam_center[0] - draw_input.world_size[0] * 0.5,
808                cam_center[1] - draw_input.world_size[1] * 0.5,
809            ],
810            density: d,
811            viewport_px: [scope.size.width, scope.size.height],
812            roll: cam.roll,
813            pivot: cam_center,
814        });
815        let project = |wx: f32, wy: f32| -> [f32; 2] {
816            let [dx, dy] = world_to_dp_with_roll(
817                [wx, wy],
818                draw_input.world_size,
819                cam_center,
820                (fit.0, fit.1, fit.2),
821                cam.roll,
822            );
823            [dx * d, dy * d]
824        };
825        if let Some(bg) = draw_input.background {
826            scope.draw_rect(
827                Rect {
828                    x: 0.0,
829                    y: 0.0,
830                    w: scope.size.width,
831                    h: scope.size.height,
832                },
833                rgba8(bg),
834                Px(0.0),
835            );
836        }
837        for spr in draw_input.sprites.iter() {
838            // Rotation goes through draw_rect_rotated: unrotated quad,
839            // rotated in px about the projected center. The rect origin
840            // is pivot based (not projected twice) and the angle is
841            // roll + rotation. Matches batch::instance_rows.
842            let px_per_world = fit.0 * d;
843            let [px, py] = project(spr.center.x, spr.center.y);
844            let ax = if spr.flip_x {
845                1.0 - spr.anchor.x
846            } else {
847                spr.anchor.x
848            };
849            let ay = if spr.flip_y {
850                1.0 - spr.anchor.y
851            } else {
852                spr.anchor.y
853            };
854            scope.draw_rect_rotated(
855                Rect {
856                    x: px - ax * spr.size.x * px_per_world,
857                    y: py - ay * spr.size.y * px_per_world,
858                    w: (spr.size.x * px_per_world).max(0.0),
859                    h: (spr.size.y * px_per_world).max(0.0),
860                },
861                rgba8(spr.color),
862                Px(0.0),
863                cam.roll + spr.rotation,
864                repose_core::Vec2 { x: px, y: py },
865            );
866        }
867        for t in draw_input.texts.iter() {
868            let [tx, ty] = project(t.pos.x, t.pos.y);
869            scope.draw_text(
870                t.text.clone(),
871                repose_core::Vec2 { x: tx, y: ty },
872                rgba8(t.color),
873                Px(t.size * fit.0 * d),
874            );
875        }
876        if let Some(tint) = draw_input.overlay_color {
877            scope.draw_rect(
878                Rect {
879                    x: 0.0,
880                    y: 0.0,
881                    w: scope.size.width,
882                    h: scope.size.height,
883                },
884                rgba8(tint),
885                Px(0.0),
886            );
887        }
888    })
889}
890
891/// GPU twin of [`Viewport2d`]: same snapshot in, same picks out, with
892/// sprites as textured atlas quads through [`SpriteBatch`]. Fills its
893/// parent. `background` paints first and `overlay_color` last, matching
894/// canvas except world texts, which stay canvas-side as sibling views.
895///
896/// Timing: layout publishes viewport size ahead of `prepare`; paint
897/// re-publishes from callback info. `prepare` rebuilds the batch and
898/// camera; atlas uploads apply once per frame. `chroma` above `0.0`
899/// renders offscreen and composites back (see `post`).
900#[allow(non_snake_case)]
901pub fn Viewport2dGpu(
902    input: FrameInput,
903    geom_out: GeomHandle,
904    uploads: Arc<[AtlasUpload]>,
905    desc: BatchDesc,
906    on_event: impl Fn(PickEvent) + 'static,
907) -> View {
908    Viewport2dGpuWithId(input, geom_out, uploads, desc, "viewport2d.main", on_event)
909}
910
911/// GPU viewport with explicit batch id so a second viewport or minimap
912/// can coexist (each id owns its pipeline and instances).
913#[allow(non_snake_case)]
914pub fn Viewport2dGpuWithId(
915    input: FrameInput,
916    geom_out: GeomHandle,
917    uploads: Arc<[AtlasUpload]>,
918    desc: BatchDesc,
919    batch_id: impl Into<String>,
920    on_event: impl Fn(PickEvent) + 'static,
921) -> View {
922    let batch_id: String = batch_id.into();
923    let bg_id = format!("{batch_id}.background");
924    let overlay_id = format!("{batch_id}.overlay");
925    let input = Arc::new(input);
926    let world_size = input.world_size;
927    let pick_geom = geom_out.clone();
928    let move_geom = geom_out.clone();
929    let release_geom = geom_out.clone();
930    let size_geom = geom_out.clone();
931    let size_input = input.clone();
932    let geom_for_payload = geom_out.clone();
933    let on_event = Arc::new(on_event);
934    let on_down = on_event.clone();
935    let on_touch_down = on_event.clone();
936    let on_move = on_event.clone();
937    let on_touch_move = on_event.clone();
938    let on_up = on_event.clone();
939    let on_up_click = on_event.clone();
940    let on_leave = on_event;
941    let press: Arc<Mutex<HashMap<u64, [f32; 2]>>> = Arc::new(Mutex::new(HashMap::new()));
942    let press_down = press.clone();
943    let press_up = press.clone();
944    let press_leave = press.clone();
945    let payload = GpuViewport {
946        input: input.clone(),
947        geom: geom_for_payload.arc(),
948        batch_id,
949        uploads,
950        desc,
951        bg: FullscreenPass::new(
952            bg_id,
953            fullscreen::SOLID_WGSL,
954            FullscreenDesc {
955                texture_slots: 0,
956                filter: TextureFilter::Nearest,
957            },
958        ),
959        overlay: FullscreenPass::new(
960            overlay_id,
961            fullscreen::SOLID_WGSL,
962            FullscreenDesc {
963                texture_slots: 0,
964                filter: TextureFilter::Nearest,
965            },
966        ),
967    };
968    let modifier = Modifier::new()
969        .fill_max_size()
970        .on_size_changed(move |dp: repose_core::Vec2| {
971            let d = effective_density_scale();
972            let d = if d.is_finite() && d > 1e-6 { d } else { 1.0 };
973            let fit = effective_fit([dp.x, dp.y], size_input.world_size, &size_input.cam);
974            let center = size_input.cam.effective_center();
975            size_geom.set(FrameGeom {
976                viewport_px: [dp.x * d, dp.y * d],
977                density: d,
978                fit,
979                look: [
980                    center[0] - size_input.world_size[0] * 0.5,
981                    center[1] - size_input.world_size[1] * 0.5,
982                ],
983                roll: size_input.cam.roll,
984                pivot: center,
985            });
986        })
987        .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
988            let p = ev.position;
989            let g = pick_geom.get();
990            let world = pick_world([p.x, p.y], g, world_size);
991            if let Ok(mut slots) = press_down.lock() {
992                slots.insert(ev.id.0, [p.x, p.y]);
993            }
994            let w = ev.position_in_window();
995            let button = button_of(&ev);
996            on_down(PickEvent::Press {
997                world: Vec2::new(world[0], world[1]),
998                screen: [w.x, w.y],
999                button,
1000            });
1001            if is_touch(&ev) {
1002                on_touch_down(PickEvent::TouchDown {
1003                    id: ev.id.0,
1004                    screen: screen_of(&ev),
1005                });
1006            }
1007        })
1008        .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
1009            let p = ev.position;
1010            let g = move_geom.get();
1011            let world = pick_world([p.x, p.y], g, world_size);
1012            let w = ev.position_in_window();
1013            on_move(PickEvent::Hover {
1014                world: Vec2::new(world[0], world[1]),
1015                screen: [w.x, w.y],
1016            });
1017            if is_touch(&ev) {
1018                on_touch_move(PickEvent::TouchMove {
1019                    id: ev.id.0,
1020                    screen: screen_of(&ev),
1021                });
1022            }
1023        })
1024        .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
1025            let p = ev.position;
1026            let start = press_up.lock().ok().and_then(|mut s| s.remove(&ev.id.0));
1027            if let Some(start) = start
1028                && click_within_slop_dp(start, [p.x, p.y], release_geom.get().density)
1029            {
1030                let g = release_geom.get();
1031                let world = pick_world([p.x, p.y], g, world_size);
1032                let w = ev.position_in_window();
1033                let button = button_of(&ev);
1034                on_up_click(PickEvent::Click {
1035                    world: Vec2::new(world[0], world[1]),
1036                    screen: [w.x, w.y],
1037                    button,
1038                });
1039            }
1040            if is_touch(&ev) {
1041                on_up(PickEvent::TouchUp { id: ev.id.0 });
1042            }
1043        })
1044        .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
1045            if let Ok(mut s) = press_leave.lock() {
1046                s.remove(&ev.id.0);
1047            }
1048            if is_touch(&ev) {
1049                on_leave(PickEvent::TouchUp { id: ev.id.0 });
1050            }
1051        });
1052    Embedded(modifier, Callback::new(payload))
1053}
1054
1055struct GpuViewport {
1056    input: Arc<FrameInput>,
1057    geom: Arc<Mutex<FrameGeom>>,
1058    batch_id: String,
1059    uploads: Arc<[AtlasUpload]>,
1060    desc: BatchDesc,
1061    bg: FullscreenPass,
1062    overlay: FullscreenPass,
1063}
1064
1065impl WgpuCallback for GpuViewport {
1066    fn prepare(
1067        &self,
1068        device: &wgpu::Device,
1069        queue: &wgpu::Queue,
1070        encoder: &mut wgpu::CommandEncoder,
1071        screen: &ScreenDescriptor,
1072        resources: &mut CallbackResources,
1073    ) -> Vec<wgpu::CommandBuffer> {
1074        let (vp_phys, density) = self
1075            .geom
1076            .lock()
1077            .map(|g| {
1078                let d = if g.density.is_finite() && g.density > 1e-6 {
1079                    g.density
1080                } else {
1081                    1.0
1082                };
1083                (g.viewport_px, d)
1084            })
1085            .unwrap_or(([1.0, 1.0], 1.0));
1086        let dp = if vp_phys[0] > 1.0 && vp_phys[1] > 1.0 {
1087            [vp_phys[0] / density, vp_phys[1] / density]
1088        } else {
1089            self.input.viewport_dp
1090        };
1091        let mut batch = SpriteBatch::with_id(self.batch_id.clone(), self.desc);
1092        batch.set_camera(self.input.cam.fit_matrix(dp, self.input.world_size));
1093        for s in &self.input.sprites {
1094            batch.push_sprite(s);
1095        }
1096        batch.extend_uploads(self.uploads.iter().cloned());
1097        batch.prepare(device, queue, encoder, screen, resources);
1098        let composite = post::use_composite(self.input.chroma);
1099        // Background fill under the batch. Skipped when the snapshot has
1100        if let Some(bg) = self.input.background
1101            && !composite
1102        {
1103            let words = [bg[0], bg[1], bg[2], bg[3]];
1104            self.bg.prepare_with(
1105                device,
1106                queue,
1107                screen,
1108                resources,
1109                bytemuck::cast_slice(&words),
1110                &[],
1111            );
1112        }
1113        if let Some(ov) = self.input.overlay_color {
1114            let words = [ov[0], ov[1], ov[2], ov[3]];
1115            self.overlay.prepare_with(
1116                device,
1117                queue,
1118                screen,
1119                resources,
1120                bytemuck::cast_slice(&words),
1121                &[],
1122            );
1123        }
1124        if post::use_composite(self.input.chroma) {
1125            let w = vp_phys[0].max(1.0) as u32;
1126            let h = vp_phys[1].max(1.0) as u32;
1127            post::prepare_composite(
1128                device,
1129                queue,
1130                encoder,
1131                screen,
1132                resources,
1133                self.batch_id.as_str(),
1134                w,
1135                h,
1136                self.input.chroma,
1137                self.input.background,
1138            );
1139        }
1140        Vec::new()
1141    }
1142
1143    fn paint(
1144        &self,
1145        info: repose_core::PaintCallbackInfo,
1146        rpass: &mut wgpu::RenderPass<'static>,
1147        resources: &CallbackResources,
1148    ) {
1149        let d = if info.pixels_per_point.is_finite() && info.pixels_per_point > 1e-6 {
1150            info.pixels_per_point
1151        } else {
1152            1.0
1153        };
1154        let vp = [info.viewport.w, info.viewport.h];
1155        let fit = effective_fit(
1156            [vp[0] / d, vp[1] / d],
1157            self.input.world_size,
1158            &self.input.cam,
1159        );
1160        let cam = self.input.cam.effective_center();
1161        let roll = self.input.cam.roll;
1162        if let Ok(mut g) = self.geom.lock() {
1163            *g = FrameGeom {
1164                fit,
1165                look: [
1166                    cam[0] - self.input.world_size[0] * 0.5,
1167                    cam[1] - self.input.world_size[1] * 0.5,
1168                ],
1169                density: d,
1170                viewport_px: vp,
1171                roll,
1172                pivot: cam,
1173            };
1174        }
1175        if post::use_composite(self.input.chroma) {
1176            post::paint_composite(self.batch_id.as_str(), rpass, resources);
1177        } else {
1178            if self.input.background.is_some() {
1179                self.bg.paint(info, rpass, resources);
1180            }
1181            draw_batch_with_id(self.batch_id.as_str(), rpass, resources);
1182        }
1183        if self.input.overlay_color.is_some() {
1184            self.overlay.paint(info, rpass, resources);
1185        }
1186    }
1187}
1188
1189/// Stack GPU sprites under canvas texts/tint from one [`FrameInput`].
1190/// Mounts `Viewport2dGpu` plus a transparent canvas pass for `texts` /
1191/// `overlay_color`, so GPU games keep damage numbers and tints.
1192#[allow(non_snake_case)]
1193pub fn Viewport2dGpuWithHud(
1194    input: FrameInput,
1195    geom: GeomHandle,
1196    uploads: Arc<[AtlasUpload]>,
1197    desc: BatchDesc,
1198    on_event: impl Fn(PickEvent) + 'static,
1199) -> View {
1200    use repose_ui::ViewExt as _;
1201    let texts = input.texts.clone();
1202    let overlay = input.overlay_color;
1203    let cam = input.cam;
1204    let world_size = input.world_size;
1205    let hud_input = std::rc::Rc::new((cam, world_size, texts, overlay));
1206    let hud_geom = geom.clone();
1207    let hud = Canvas(
1208        Modifier::new().fill_max_size().hit_passthrough(),
1209        move |scope: &mut DrawScope| {
1210            let (cam, world_size, texts, overlay) =
1211                (hud_input.0, hud_input.1, &hud_input.2, hud_input.3);
1212            let d = effective_density_scale();
1213            let cam_center = cam.effective_center();
1214            let fit = effective_fit(
1215                [scope.size.width / d, scope.size.height / d],
1216                world_size,
1217                &cam,
1218            );
1219            hud_geom.set(FrameGeom {
1220                fit,
1221                look: [
1222                    cam_center[0] - world_size[0] * 0.5,
1223                    cam_center[1] - world_size[1] * 0.5,
1224                ],
1225                density: d,
1226                viewport_px: [scope.size.width, scope.size.height],
1227                roll: cam.roll,
1228                pivot: cam_center,
1229            });
1230            let project = |wx: f32, wy: f32| -> [f32; 2] {
1231                let [dx, dy] = world_to_dp_with_roll(
1232                    [wx, wy],
1233                    world_size,
1234                    cam_center,
1235                    (fit.0, fit.1, fit.2),
1236                    cam.roll,
1237                );
1238                [dx * d, dy * d]
1239            };
1240            for t in texts.iter() {
1241                let [tx, ty] = project(t.pos.x, t.pos.y);
1242                scope.draw_text(
1243                    t.text.clone(),
1244                    repose_core::Vec2 { x: tx, y: ty },
1245                    rgba8(t.color),
1246                    Px(t.size * fit.0 * d),
1247                );
1248            }
1249            if let Some(tint) = overlay {
1250                scope.draw_rect(
1251                    Rect {
1252                        x: 0.0,
1253                        y: 0.0,
1254                        w: scope.size.width,
1255                        h: scope.size.height,
1256                    },
1257                    rgba8(tint),
1258                    Px(0.0),
1259                );
1260            }
1261        },
1262    );
1263    repose_ui::ZStack(Modifier::new().fill_max_size())
1264        .child(Viewport2dGpu(input, geom, uploads, desc, on_event))
1265        .child(hud)
1266}
1267/// World-anchored surface: `child` is boxed to a `size` rect centered
1268/// on a world `center`, positioned through [`FrameGeom`]. `mirror_x`
1269/// flips about the surface center (rozvp horde case: centered content).
1270/// Offsets trail the board by one frame under camera motion; steady state
1271/// matches. For origin-bearing art (NT sprites with off-center
1272/// `xorigin`/`yorigin`), prefer [`ActorFrameWithOrigin`] or the sprite
1273/// batch's anchor flips, which mirror about the anchor instead.
1274#[allow(non_snake_case)]
1275pub fn ActorFrame(
1276    center: [f32; 2],
1277    size: [f32; 2],
1278    geom: GeomHandle,
1279    mirror_x: bool,
1280    child: View,
1281) -> View {
1282    ActorFrameWithOrigin(center, size, geom, mirror_x, [0.5, 0.5], child)
1283}
1284
1285/// Origin-aware [`ActorFrame`]: `mirror_x` flips about the normalized
1286/// `origin` (`[0, 1]`, e.g. an NT `xorigin / w`) instead of the surface
1287/// center. Set `origin` from the catalog's
1288/// [`AnimDef::anchor`](repame_anim::AnimDef::anchor) when the surface
1289/// content carries an off-center sprite origin.
1290#[allow(non_snake_case)]
1291pub fn ActorFrameWithOrigin(
1292    center: [f32; 2],
1293    size: [f32; 2],
1294    geom: GeomHandle,
1295    mirror_x: bool,
1296    origin: [f32; 2],
1297    child: View,
1298) -> View {
1299    let ([ox, oy], [w, h]) = surface_dp(center, size, geom.get());
1300    // `.absolute()` is load-bearing: without it all surfaces stack
1301    // at one fixed spot.
1302    let mut modifier = Modifier::new().size(Dp(w), Dp(h)).absolute().offset(
1303        Some(Dp(ox)),
1304        Some(Dp(oy)),
1305        None,
1306        None,
1307    );
1308    if mirror_x {
1309        modifier = modifier
1310            .transform_origin(origin[0].clamp(0.0, 1.0), origin[1].clamp(0.0, 1.0))
1311            .scale2(-1.0, 1.0);
1312    }
1313    UiBox(modifier).child(child)
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318    use super::*;
1319    use repose_core::locals::{Density, set_density_default};
1320
1321    #[test]
1322    fn contain_fit_roundtrip_at_density_125() {
1323        // 1.25 density: px canvas -> dp fit -> world -> back is identity.
1324        set_density_default(Density { scale: 1.25 });
1325        let d = effective_density_scale();
1326        // 1250x750 px canvas, 800x600 world: dp fit s=1, ox=100, oy=0.
1327        let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
1328        let cam = [400.0, 300.0];
1329        // Draw: world (400,300) -> dp -> px lands on the press point.
1330        let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
1331        // Pick: window px -> dp -> world maps back.
1332        let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
1333        set_density_default(Density { scale: 1.0 });
1334        assert!(
1335            (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
1336            "dp fit, got {fit:?}"
1337        );
1338        assert!(
1339            (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
1340            "draw forward, got ({dx},{dy})"
1341        );
1342        assert!(
1343            (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
1344            "pick inverse, got ({wx},{wy})"
1345        );
1346        // Camera shift moves draw and pick together.
1347        let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
1348        let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
1349        assert!(
1350            (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
1351            "shifted round trip, got {back:?}"
1352        );
1353    }
1354
1355    #[test]
1356    fn gpu_viewport_honors_cam_and_background() {
1357        // Snapshot camera frames world sprites; `background` fills pixels
1358        // the quad does not cover. Skips where no GPU exists.
1359        use repose_core::{Color, Rect, Scene, SceneNode};
1360        use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
1361
1362        let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
1363            Ok(r) => r,
1364            Err(e) => {
1365                eprintln!("SKIP gpu viewport test (no GPU): {e}");
1366                return;
1367            }
1368        };
1369        // Page 0: solid magenta 2x2.
1370        let page = [255, 0, 255, 255].repeat(4);
1371        let uploads = vec![AtlasUpload {
1372            page: 0,
1373            x: 0,
1374            y: 0,
1375            w: 2,
1376            h: 2,
1377            rgba: page,
1378        }];
1379        let cam = Camera2d {
1380            center: Vec2::new(64.0, 64.0),
1381            offset: Vec2::ZERO,
1382            units_per_pixel: 0.5,
1383            zoom: 1.0,
1384            roll: 0.0,
1385        };
1386        let input = FrameInput {
1387            cam,
1388            world_size: [128.0, 128.0],
1389            viewport_dp: [256.0, 256.0],
1390            sprites: vec![SpriteInstance {
1391                center: Vec2::new(64.0, 64.0),
1392                size: Vec2::new(32.0, 32.0),
1393                uv_min: Vec2::new(0.0, 0.0),
1394                uv_max: Vec2::new(1.0, 1.0),
1395                color: [1.0, 1.0, 1.0, 1.0],
1396                ..Default::default()
1397            }],
1398            texts: Vec::new(),
1399            background: Some([0.0, 0.0, 1.0, 1.0]),
1400            overlay_color: None,
1401            chroma: 0.0,
1402        };
1403        let geom = Arc::new(Mutex::new(FrameGeom {
1404            fit: (1.0, 0.0, 0.0),
1405            look: [0.0, 0.0],
1406            density: 1.0,
1407            viewport_px: [256.0, 256.0],
1408            roll: 0.0,
1409            pivot: [64.0, 64.0],
1410        }));
1411        let payload = GpuViewport {
1412            input: Arc::new(input.clone()),
1413            geom,
1414            batch_id: "test.viewport2d.main".to_string(),
1415            uploads: Arc::from(uploads),
1416            desc: BatchDesc {
1417                layer_size: 2,
1418                layers: 1,
1419                filter: TextureFilter::Nearest,
1420                ..Default::default()
1421            },
1422            bg: FullscreenPass::new(
1423                "test.viewport2d.background",
1424                fullscreen::SOLID_WGSL,
1425                FullscreenDesc {
1426                    texture_slots: 0,
1427                    filter: TextureFilter::Nearest,
1428                },
1429            ),
1430            overlay: FullscreenPass::new(
1431                "test.viewport2d.overlay",
1432                fullscreen::SOLID_WGSL,
1433                FullscreenDesc {
1434                    texture_slots: 0,
1435                    filter: TextureFilter::Nearest,
1436                },
1437            ),
1438        };
1439        let scene = Scene {
1440            clear_color: Color::from_rgba(0, 0, 0, 255),
1441            nodes: vec![SceneNode::Callback {
1442                rect: Rect {
1443                    x: 0.0,
1444                    y: 0.0,
1445                    w: 256.0,
1446                    h: 256.0,
1447                },
1448                payload: Callback::new(payload),
1449            }],
1450        };
1451        let px = renderer
1452            .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1453            .expect("offscreen render");
1454        let at = |x: u32, y: u32| -> [u8; 4] {
1455            let i = ((y * 256 + x) * 4) as usize;
1456            [px[i], px[i + 1], px[i + 2], px[i + 3]]
1457        };
1458        // Corners are outside the centered quad: snapshot background.
1459        assert_eq!(at(8, 8), [0, 0, 255, 255], "background fill");
1460        assert_eq!(at(247, 247), [0, 0, 255, 255], "background fill");
1461        assert_eq!(at(128, 128), [255, 0, 255, 255], "cam-centered quad");
1462        let overlay_input = FrameInput {
1463            overlay_color: Some([1.0, 0.0, 0.0, 1.0]),
1464            ..input
1465        };
1466        let overlay_payload = GpuViewport {
1467            input: Arc::new(overlay_input),
1468            geom: Arc::new(Mutex::new(FrameGeom {
1469                fit: (1.0, 0.0, 0.0),
1470                look: [0.0, 0.0],
1471                density: 1.0,
1472                viewport_px: [256.0, 256.0],
1473                roll: 0.0,
1474                pivot: [64.0, 64.0],
1475            })),
1476            batch_id: "test.viewport2d.overlay".to_string(),
1477            uploads: Arc::from(
1478                vec![AtlasUpload {
1479                    page: 0,
1480                    x: 0,
1481                    y: 0,
1482                    w: 2,
1483                    h: 2,
1484                    rgba: [255, 0, 255, 255].repeat(4),
1485                }]
1486            ),
1487            desc: BatchDesc {
1488                layer_size: 2,
1489                layers: 1,
1490                filter: TextureFilter::Nearest,
1491                ..Default::default()
1492            },
1493            bg: FullscreenPass::new(
1494                "test.viewport2d.background",
1495                fullscreen::SOLID_WGSL,
1496                FullscreenDesc {
1497                    texture_slots: 0,
1498                    filter: TextureFilter::Nearest,
1499                },
1500            ),
1501            overlay: FullscreenPass::new(
1502                "test.viewport2d.overlay",
1503                fullscreen::SOLID_WGSL,
1504                FullscreenDesc {
1505                    texture_slots: 0,
1506                    filter: TextureFilter::Nearest,
1507                },
1508            ),
1509        };
1510        let scene = Scene {
1511            clear_color: Color::from_rgba(0, 0, 0, 255),
1512            nodes: vec![SceneNode::Callback {
1513                rect: Rect {
1514                    x: 0.0,
1515                    y: 0.0,
1516                    w: 256.0,
1517                    h: 256.0,
1518                },
1519                payload: Callback::new(overlay_payload),
1520            }],
1521        };
1522        let px = renderer
1523            .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1524            .expect("offscreen render");
1525        let at = |x: u32, y: u32| -> [u8; 4] {
1526            let i = ((y * 256 + x) * 4) as usize;
1527            [px[i], px[i + 1], px[i + 2], px[i + 3]]
1528        };
1529        assert_eq!(at(8, 8), [255, 0, 0, 255], "overlay covers background");
1530        assert_eq!(at(128, 128), [255, 0, 0, 255], "overlay covers sprite");
1531    }
1532
1533    #[test]
1534    fn surface_dp_matches_board_point_under_shake() {
1535        // Actor surfaces share FrameGeom with board sprites: the surface
1536        // center lands on the board point, including with a shifted look.
1537        let geom = FrameGeom {
1538            fit: (1.0, 100.0, 0.0),
1539            look: [0.0, 0.0],
1540            density: 1.25,
1541            viewport_px: [1250.0, 750.0],
1542            roll: 0.0,
1543            pivot: [400.0, 300.0],
1544        };
1545        let ([ox, oy], [w, h]) = surface_dp([400.0, 300.0], [64.0, 80.0], geom);
1546        assert!(
1547            (ox - 468.0).abs() < 1e-4 && (oy - 260.0).abs() < 1e-4,
1548            "offset, got ({ox},{oy})"
1549        );
1550        assert!(
1551            (w - 64.0).abs() < 1e-4 && (h - 80.0).abs() < 1e-4,
1552            "size, got ({w},{h})"
1553        );
1554        // Surface center == board point for the same world pos.
1555        let board = world_to_dp([400.0, 300.0], [800.0, 600.0], [400.0, 300.0], geom.fit);
1556        assert!(
1557            (ox + w * 0.5 - board[0]).abs() < 1e-4 && (oy + h * 0.5 - board[1]).abs() < 1e-4,
1558            "surface glued to board point"
1559        );
1560        let shaken = FrameGeom {
1561            look: [10.0, -10.0],
1562            ..geom
1563        };
1564        let ([sx, sy], _) = surface_dp([400.0, 300.0], [64.0, 80.0], shaken);
1565        let sboard = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], shaken.fit);
1566        assert!(
1567            (sx + w * 0.5 - sboard[0]).abs() < 1e-4 && (sy + h * 0.5 - sboard[1]).abs() < 1e-4,
1568            "surface glued under shake"
1569        );
1570    }
1571
1572    #[test]
1573    fn contain_fit_has_no_silent_clamp() {
1574        for (canvas, world) in [
1575            ([8000.0, 6000.0], [80.0, 60.0]),
1576            ([80.0, 60.0], [8000.0, 6000.0]),
1577            ([1000.0, 750.0], [800.0, 600.0]),
1578        ] {
1579            let fit = contain_fit(canvas, world);
1580            let cam = [world[0] * 0.5, world[1] * 0.5];
1581            let dp = world_to_dp([cam[0], cam[1]], world, cam, fit);
1582            let back = dp_to_world(dp, world, cam, fit);
1583            assert!(
1584                (back[0] - cam[0]).abs() < 1e-3 && (back[1] - cam[1]).abs() < 1e-3,
1585                "round trip for {canvas:?}/{world:?}, fit {fit:?} gave {back:?}"
1586            );
1587        }
1588        assert_eq!(contain_fit([0.0, 600.0], [800.0, 600.0]), (1.0, 0.0, 0.0));
1589        assert_eq!(
1590            dp_to_world(
1591                [10.0, 10.0],
1592                [800.0, 600.0],
1593                [400.0, 300.0],
1594                (0.0, 0.0, 0.0)
1595            ),
1596            [400.0, 300.0]
1597        );
1598    }
1599
1600    #[test]
1601    fn canvas_rotated_rect_matches_instance_rows() {
1602        // Canvas draws the mirror-adjusted quad plus a px-space rotation;
1603        // GPU draws `instance_rows` corners. Both land on the same
1604        // px corners (compared as sets) across combos.
1605        let cam = Camera2d {
1606            center: Vec2::new(400.0, 300.0),
1607            offset: Vec2::ZERO,
1608            units_per_pixel: 1.0,
1609            zoom: 1.0,
1610            roll: 0.3,
1611        };
1612        let world_size = [800.0, 600.0];
1613        let canvas_dp = [800.0, 600.0];
1614        let fit = effective_fit(canvas_dp, world_size, &cam);
1615        let center = cam.effective_center();
1616        let project = |wx: f32, wy: f32| -> [f32; 2] {
1617            world_to_dp_with_roll([wx, wy], world_size, center, fit, cam.roll)
1618        };
1619        let k = fit.0;
1620        for (rotation, flip_x, flip_y, anchor) in [
1621            (0.7f32, false, false, [0.5, 0.5]),
1622            (0.7, true, false, [0.5, 0.5]),
1623            (0.7, false, true, [0.5, 0.5]),
1624            (0.7, true, true, [0.5, 0.5]),
1625            (-1.2, true, false, [0.0, 0.0]),
1626            (2.1, false, true, [1.0, 0.25]),
1627            (0.0, true, false, [0.5, 0.5]),
1628        ] {
1629            let size = [64.0f32, 40.0];
1630            let ctr = [400.0f32, 300.0];
1631            // GPU reference: instance_rows corners through the projection.
1632            let (row0, row1) = batch::instance_rows(ctr, size, rotation, anchor, flip_x, flip_y);
1633            let mut expect = [[0.0f32; 2]; 4];
1634            for (i, corner) in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]
1635                .iter()
1636                .enumerate()
1637            {
1638                let wx = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
1639                let wy = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
1640                expect[i] = project(wx, wy);
1641            }
1642            // Canvas: mirror-adjusted rect about the pivot, rotated by
1643            // roll + rotation. Mirroring is captured by the rect as a
1644            // set, so the angle does not negate.
1645            let ax = if flip_x { 1.0 - anchor[0] } else { anchor[0] };
1646            let ay = if flip_y { 1.0 - anchor[1] } else { anchor[1] };
1647            let [px, py] = project(ctr[0], ctr[1]);
1648            let angle = cam.roll + rotation;
1649            let (c, s) = (angle.cos(), angle.sin());
1650            let (ox, oy) = (px - ax * size[0] * k, py - ay * size[1] * k);
1651            let w = size[0] * k;
1652            let h = size[1] * k;
1653            for (i, corner) in [[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]].iter().enumerate() {
1654                let rx = px + (ox + corner[0] - px) * c - (oy + corner[1] - py) * s;
1655                let ry = py + (ox + corner[0] - px) * s + (oy + corner[1] - py) * c;
1656                let mut best = f32::INFINITY;
1657                for e in expect {
1658                    best = best.min((rx - e[0]).hypot(ry - e[1]));
1659                }
1660                assert!(
1661                    best < 1e-2,
1662                    "rot={rotation} flip=({flip_x},{flip_y}) anchor={anchor:?}: corner {i} ({rx},{ry}) matches none of {expect:?}"
1663                );
1664            }
1665        }
1666    }
1667
1668    #[test]
1669    #[allow(deprecated)]
1670    fn camera_guards_div_by_zero() {
1671        let bad = Camera2d {
1672            center: Vec2::ZERO,
1673            offset: Vec2::ZERO,
1674            units_per_pixel: 0.0,
1675            zoom: 0.0,
1676            roll: 0.0,
1677        };
1678        let m = bad.view_proj([0.0, 0.0]);
1679        assert!(m.is_finite(), "view_proj stays finite, got {m:?}");
1680        assert_eq!(bad.screen_to_world([0.0, 0.0], [5.0, 5.0]), Vec2::ZERO);
1681    }
1682
1683    #[test]
1684    fn fit_matrix_canvas_gpu_picks_agree() {
1685        let cam = Camera2d {
1686            center: Vec2::new(410.0, 290.0),
1687            offset: Vec2::ZERO,
1688            units_per_pixel: 1.0,
1689            zoom: 2.0,
1690            roll: 0.0,
1691        };
1692        let world_size = [800.0, 600.0];
1693        let density = 1.25;
1694        let vp_phys = [1250.0, 750.0];
1695        let canvas_dp = [vp_phys[0] / density, vp_phys[1] / density];
1696        let cam_center = cam.effective_center();
1697        let fit = effective_fit(canvas_dp, world_size, &cam);
1698        assert!((fit.0 - 2.0).abs() < 1e-4, "effective fit, got {fit:?}");
1699        let vp = cam.fit_matrix(canvas_dp, world_size);
1700        assert!(vp.is_finite());
1701        for world in [[400.0, 300.0], [410.0, 290.0], [0.0, 0.0], [800.0, 600.0]] {
1702            let [dx, dy] = world_to_dp(world, world_size, cam_center, fit);
1703            let px = [dx * density, dy * density];
1704            let ndc = vp.project_point3(glam::Vec3::new(world[0], world[1], 0.0));
1705            let gpu_px = [
1706                (ndc.x + 1.0) * 0.5 * vp_phys[0],
1707                (1.0 - ndc.y) * 0.5 * vp_phys[1],
1708            ];
1709            assert!(
1710                (px[0] - gpu_px[0]).abs() < 1e-2 && (px[1] - gpu_px[1]).abs() < 1e-2,
1711                "canvas/GPU disagree for {world:?}: canvas {px:?} vs gpu {gpu_px:?}"
1712            );
1713            let back =
1714                cam.dp_to_world_pt(canvas_dp, world_size, [px[0] / density, px[1] / density]);
1715            assert!(
1716                (back.x - world[0]).abs() < 1e-3 && (back.y - world[1]).abs() < 1e-3,
1717                "pick round trip for {world:?} gave {back:?}"
1718            );
1719        }
1720    }
1721
1722    #[test]
1723    fn letterbox_pick_matches_draw() {
1724        // canvas 1600x900 dp, world 800x800 -> horizontal letterbox
1725        let cam = Camera2d {
1726            center: Vec2::new(400.0, 400.0),
1727            ..Default::default()
1728        };
1729        let fit = effective_fit([1600.0, 900.0], [800.0, 800.0], &cam);
1730        let dp = world_to_dp([400.0, 400.0], [800.0, 800.0], cam.effective_center(), fit);
1731        let back = dp_to_world(dp, [800.0, 800.0], cam.effective_center(), fit);
1732        assert!((back[0] - 400.0).abs() < 1e-3 && (back[1] - 400.0).abs() < 1e-3);
1733        // Method and plain function share the same matrix.
1734        let m = cam.fit_matrix([1600.0, 900.0], [800.0, 800.0]);
1735        let plain = fit_view_proj([1600.0, 900.0], [800.0, 800.0], cam.effective_center(), fit);
1736        let dm = m - plain;
1737        let max = dm
1738            .to_cols_array()
1739            .iter()
1740            .fold(0.0f32, |a, b| a.max(b.abs()));
1741        assert!(max < 1e-5);
1742    }
1743
1744    #[test]
1745    fn picks_ignore_viewport_origin() {
1746        let geom = FrameGeom {
1747            fit: (1.0, 100.0, 0.0),
1748            look: [0.0, 0.0],
1749            density: 1.25,
1750            viewport_px: [1250.0, 750.0],
1751            roll: 0.0,
1752            pivot: [400.0, 300.0],
1753        };
1754        let world_size = [800.0, 600.0];
1755        let a = pick_world([125.0, 75.0], geom, world_size);
1756        let b = pick_world([125.0, 75.0], geom, world_size);
1757        assert_eq!(a, b);
1758        let [wx, wy] = pick_world([125.0, 75.0], geom, world_size);
1759        assert!(
1760            (wx - 0.0).abs() < 1e-4 && (wy - 60.0).abs() < 1e-4,
1761            "local pick, got ({wx},{wy})"
1762        );
1763    }
1764
1765    #[test]
1766    fn offset_shifts_look_and_draw_together() {
1767        // Shake rides the offset channel: the same world point draws and
1768        // picks with the offset applied.
1769        let world_size = [800.0, 600.0];
1770        let canvas_dp = [1000.0, 600.0];
1771        let mut cam = Camera2d {
1772            center: Vec2::new(400.0, 300.0),
1773            offset: Vec2::new(10.0, -10.0),
1774            units_per_pixel: 1.0,
1775            zoom: 1.0,
1776            roll: 0.0,
1777        };
1778        assert_eq!(cam.effective_center(), [410.0, 290.0]);
1779        let fit = effective_fit(canvas_dp, world_size, &cam);
1780        let dp = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
1781        let back = dp_to_world(dp, world_size, cam.effective_center(), fit);
1782        assert!((back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4);
1783        cam.offset = Vec2::ZERO;
1784        let dp2 = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
1785        // Offset 10 world units at scale 1 moves the drawing by 10 dp.
1786        assert!((dp[0] - dp2[0] + 10.0).abs() < 1e-4);
1787        assert!((dp[1] - dp2[1] - 10.0).abs() < 1e-4);
1788    }
1789
1790    #[test]
1791    fn touch_gate_and_screen_space() {
1792        use repose_core::Vec2 as RVec2;
1793        use repose_core::input::{
1794            Modifiers, PointerButton, PointerEvent, PointerEventKind, PointerId, PointerKind,
1795        };
1796        let ev_of = |kind: PointerKind| {
1797            let mut ev = PointerEvent::new(
1798                PointerId(3),
1799                kind,
1800                PointerEventKind::Down(PointerButton::Primary),
1801                RVec2 { x: 10.0, y: 20.0 },
1802                1.0,
1803                Modifiers::default(),
1804            );
1805            ev.origin = RVec2 { x: 5.0, y: 7.0 };
1806            ev
1807        };
1808        // Touch + pen forward to touch zones; mouse stays on clicks.
1809        assert!(is_touch(&ev_of(PointerKind::Touch)));
1810        assert!(is_touch(&ev_of(PointerKind::Pen)));
1811        assert!(!is_touch(&ev_of(PointerKind::Mouse)));
1812        // Touch zones sample window-physical px (origin + position).
1813        assert_eq!(screen_of(&ev_of(PointerKind::Touch)), [15.0, 27.0]);
1814    }
1815
1816    #[test]
1817    fn presses_track_per_pointer_id() {
1818        use std::cell::RefCell;
1819        use std::collections::HashMap;
1820        use std::rc::Rc;
1821        let presses: Rc<RefCell<HashMap<u64, [f32; 2]>>> =
1822            Rc::new(RefCell::new(HashMap::new()));
1823        let down = presses.clone();
1824        let up = presses.clone();
1825        let leave = presses.clone();
1826        let press_down = move |id: u64, at: [f32; 2]| {
1827            down.borrow_mut().insert(id, at);
1828        };
1829        let press_up = move |id: u64| up.borrow_mut().remove(&id);
1830        let press_leave = move |id: u64| {
1831            leave.borrow_mut().remove(&id);
1832        };
1833        press_down(1, [10.0, 10.0]);
1834        press_down(2, [200.0, 200.0]);
1835        assert_eq!(press_up(1), Some([10.0, 10.0]));
1836        assert_eq!(
1837            presses.borrow().get(&2),
1838            Some(&[200.0, 200.0]),
1839            "second finger press survives first finger lift"
1840        );
1841        press_leave(2);
1842        assert!(presses.borrow().is_empty());
1843    }
1844
1845    #[test]
1846    fn click_slop_cancels_drags() {
1847        assert!(click_within_slop([100.0, 100.0], [105.0, 105.0]));
1848        assert!(click_within_slop_dp([100.0, 100.0], [105.0, 105.0], 1.0));
1849        assert!(!click_within_slop_dp(
1850            [100.0, 100.0],
1851            [100.0 + CLICK_SLOP_DP + 1.0, 100.0],
1852            1.0
1853        ));
1854    }
1855
1856    #[test]
1857    fn click_slop_is_density_independent() {
1858        // 12 dp at density 3 is 36 px: same tap cancels on both densities.
1859        assert!(click_within_slop_dp([0.0, 0.0], [36.0, 0.0], 3.0));
1860        assert!(!click_within_slop_dp([0.0, 0.0], [37.0, 0.0], 3.0));
1861        assert!(click_within_slop_dp([0.0, 0.0], [12.0, 0.0], 1.0));
1862        assert!(!click_within_slop_dp([0.0, 0.0], [13.0, 0.0], 1.0));
1863    }
1864
1865    #[test]
1866    fn aim_ignores_shake_while_pick_follows_it() {
1867        use super::{aim_world_unshaken, pick_world};
1868        let world_size = [800.0, 600.0];
1869        let canvas_dp = [800.0, 600.0];
1870        let mut cam = Camera2d {
1871            center: Vec2::new(400.0, 300.0),
1872            offset: Vec2::new(10.0, -5.0),
1873            units_per_pixel: 1.0,
1874            zoom: 1.0,
1875            roll: 0.0,
1876        };
1877        let fit = effective_fit(canvas_dp, world_size, &cam);
1878        let shaken = FrameGeom {
1879            fit,
1880            look: [
1881                cam.effective_center()[0] - world_size[0] * 0.5,
1882                cam.effective_center()[1] - world_size[1] * 0.5,
1883            ],
1884            density: 1.0,
1885            viewport_px: [800.0, 600.0],
1886            roll: 0.0,
1887            pivot: cam.effective_center(),
1888        };
1889        let px = [400.0, 300.0];
1890        let pick = pick_world(px, shaken, world_size);
1891        let aim = aim_world_unshaken(px, &cam, canvas_dp, world_size, 1.0);
1892        assert!(
1893            (pick[0] - aim[0] - 10.0).abs() < 1e-3 && (pick[1] - aim[1] + 5.0).abs() < 1e-3,
1894            "pick follows shake, aim does not: pick {pick:?} aim {aim:?}"
1895        );
1896        cam.offset = Vec2::ZERO;
1897        let aim_still = aim_world_unshaken(px, &cam, canvas_dp, world_size, 1.0);
1898        assert!(
1899            (aim[0] - aim_still[0]).abs() < 1e-3 && (aim[1] - aim_still[1]).abs() < 1e-3,
1900            "aim independent of shake: {aim:?} vs {aim_still:?}"
1901        );
1902    }
1903
1904    #[test]
1905    fn roll_roundtrips_and_moves_pixels() {
1906        let cam = Camera2d {
1907            center: Vec2::new(400.0, 300.0),
1908            offset: Vec2::ZERO,
1909            units_per_pixel: 1.0,
1910            zoom: 1.0,
1911            roll: std::f32::consts::FRAC_PI_2,
1912        };
1913        let world = [800.0, 600.0];
1914        let dp = [1000.0, 600.0];
1915        let fit = effective_fit(dp, world, &cam);
1916        for p in [[400.0, 300.0], [500.0, 300.0], [0.0, 0.0]] {
1917            let fwd = world_to_dp_with_roll(p, world, cam.effective_center(), fit, cam.roll);
1918            let back = dp_to_world_with_roll(fwd, world, cam.effective_center(), fit, cam.roll);
1919            assert!(
1920                (back[0] - p[0]).abs() < 1e-3 && (back[1] - p[1]).abs() < 1e-3,
1921                "roll roundtrip for {p:?} gave {back:?}"
1922            );
1923        }
1924        let plain = world_to_dp([500.0, 300.0], world, cam.effective_center(), fit);
1925        let rolled =
1926            world_to_dp_with_roll([500.0, 300.0], world, cam.effective_center(), fit, cam.roll);
1927        assert!(
1928            (plain[0] - rolled[0]).abs() + (plain[1] - rolled[1]).abs() > 1.0,
1929            "roll must move pixels, plain {plain:?} rolled {rolled:?}"
1930        );
1931        let m0 = fit_view_proj_with_roll(dp, world, cam.effective_center(), fit, 0.0);
1932        let legacy = fit_view_proj(dp, world, cam.effective_center(), fit);
1933        assert_eq!(m0, legacy);
1934    }
1935
1936    #[test]
1937    fn surface_tracks_roll_about_pivot() {
1938        let geom = FrameGeom {
1939            fit: (1.0, 0.0, 0.0),
1940            look: [0.0, 0.0],
1941            density: 1.0,
1942            viewport_px: [800.0, 600.0],
1943            roll: std::f32::consts::FRAC_PI_2,
1944            pivot: [400.0, 300.0],
1945        };
1946        let ([ox, oy], _) = surface_dp([500.0, 300.0], [10.0, 10.0], geom);
1947        assert!(
1948            (ox - 395.0).abs() < 1e-3 && (oy - 395.0).abs() < 1e-3,
1949            "rolled surface, got ({ox},{oy})"
1950        );
1951    }
1952
1953    #[test]
1954    fn geom_handle_compat_wraps_legacy_handles() {
1955        use std::cell::Cell;
1956        use std::rc::Rc;
1957        let rc = Rc::new(Cell::new(FrameGeom {
1958            fit: (2.0, 1.0, 2.0),
1959            ..Default::default()
1960        }));
1961        let h: GeomHandle = rc.into();
1962        assert_eq!(h.get().fit, (2.0, 1.0, 2.0));
1963        let arc = Arc::new(Mutex::new(FrameGeom::default()));
1964        let h2: GeomHandle = arc.into();
1965        assert_eq!(h2.get().density, 1.0);
1966    }
1967}
1968
1969/// One decoded + magnified hardware-cursor frame.
1970#[derive(Clone, Debug, PartialEq, Eq)]
1971pub struct CursorFrame {
1972    pub rgba: Vec<u8>,
1973    pub size: [u16; 2],
1974    pub hotspot: [u16; 2],
1975    pub key: (i32, [u32; 3], u64, u32),
1976}
1977
1978pub fn cursor_frame(
1979    strip: &[u8],
1980    strip_w: u32,
1981    strip_h: u32,
1982    frames: u32,
1983    frame: i32,
1984    cell: (u32, u32),
1985    origin: (f32, f32),
1986    tint: [f32; 4],
1987    mag: u32,
1988    strip_hash: u64,
1989) -> Option<CursorFrame> {
1990    let frames = frames.max(1) as i32;
1991    let frame = frame.clamp(0, frames - 1);
1992    let (cw, ch) = (cell.0.max(1), cell.1.max(1));
1993    if strip_w < cw * (frame as u32 + 1) || strip_h < ch {
1994        return None;
1995    }
1996    let mag = mag.clamp(1, 8);
1997    let tint_u8 = [
1998        (tint[0].clamp(0.0, 1.0) * 255.0) as u32,
1999        (tint[1].clamp(0.0, 1.0) * 255.0) as u32,
2000        (tint[2].clamp(0.0, 1.0) * 255.0) as u32,
2001    ];
2002    let key = (frame, tint_u8, strip_hash, mag);
2003    let fx = (frame as u32 * cw) as usize;
2004    let (dw, dh) = (cw * mag, ch * mag);
2005    if dw > 2048 || dh > 2048 {
2006        return None;
2007    }
2008    let mut px = Vec::with_capacity((dw * dh * 4) as usize);
2009    for row in 0..ch {
2010        let start = ((row * strip_w) as usize + fx) * 4;
2011        let end = start + (cw as usize) * 4;
2012        let cell = strip.get(start..end)?;
2013        let mut mag_row = Vec::with_capacity((cw * mag * 4) as usize);
2014        for pix in cell.chunks_exact(4) {
2015            let t = [
2016                (pix[0] as u32 * tint_u8[0] / 255) as u8,
2017                (pix[1] as u32 * tint_u8[1] / 255) as u8,
2018                (pix[2] as u32 * tint_u8[2] / 255) as u8,
2019                pix[3],
2020            ];
2021            for _ in 0..mag {
2022                mag_row.extend_from_slice(&t);
2023            }
2024        }
2025        for _ in 0..mag {
2026            px.extend_from_slice(&mag_row);
2027        }
2028    }
2029    let (w16, h16) = (dw.min(2048) as u16, dh.min(2048) as u16);
2030    Some(CursorFrame {
2031        rgba: px,
2032        size: [w16, h16],
2033        hotspot: [
2034            (origin.0 * mag as f32).round().clamp(0.0, w16.saturating_sub(1) as f32) as u16,
2035            (origin.1 * mag as f32).round().clamp(0.0, h16.saturating_sub(1) as f32) as u16,
2036        ],
2037        key,
2038    })
2039}
2040
2041/// Unproject one window-physical px point through this frame's camera.
2042pub fn unproject_px(
2043    px: Vec2,
2044    viewport_dp: [f32; 2],
2045    world_size: [f32; 2],
2046    density: f32,
2047    cam: &Camera2d,
2048) -> Vec2 {
2049    let d = if density.is_finite() && density > 1e-6 {
2050        density
2051    } else {
2052        1.0
2053    };
2054    let dp = [px.x / d, px.y / d];
2055    cam.dp_to_world_pt(viewport_dp, world_size, dp)
2056}
2057
2058#[cfg(test)]
2059mod unproject_tests {
2060    use super::*;
2061
2062    #[test]
2063    fn px_round_trips_through_fit() {
2064        let cam = Camera2d {
2065            center: Vec2::new(213.0, 120.0),
2066            offset: Vec2::ZERO,
2067            units_per_pixel: 1.0 / 3.0,
2068            zoom: 1.0,
2069            roll: 0.0,
2070        };
2071        let world = Vec2::new(100.0, 60.0);
2072        let viewport_dp = [1024.0, 600.0];
2073        let world_size = [1024.0, 600.0];
2074        let (s, ox, oy) = effective_fit(viewport_dp, world_size, &cam);
2075        let [dx, dy] = world_to_dp(
2076            [world.x, world.y],
2077            world_size,
2078            cam.effective_center(),
2079            (s, ox, oy),
2080        );
2081        let px = Vec2::new(dx * 1.25, dy * 1.25);
2082        let back = unproject_px(px, viewport_dp, world_size, 1.25, &cam);
2083        assert!(
2084            (back - world).length() < 0.01,
2085            "round trip, got {back:?} for {world:?}"
2086        );
2087    }
2088}