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