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