Skip to main content

repame_sprite/
lib.rs

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