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