Skip to main content

repame_sprite/
lib.rs

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