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