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