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, Modifier, Rect, View};
18use repose_render_wgpu::{Callback, CallbackResources, ScreenDescriptor, WgpuCallback};
19use repose_ui::Box as UiBox;
20use repose_ui::ViewExt;
21
22pub mod batch;
23use batch::draw_batch;
24pub use batch::{AtlasUpload, BatchDesc, SpriteBatch, TextureFilter, frame_uv, instance_rows, screen_camera, sprite_aabb};
25
26pub mod fullscreen;
27pub use fullscreen::{FullscreenDesc, FullscreenPass, FullscreenTexture};
28
29pub mod post;
30
31/// 2D orthographic camera. Owned by the UI (signals), copied into the
32/// snapshot per frame.
33///
34/// The camera's look point is [`effective_center`](Camera2d::effective_center)
35/// (`center + offset`); `zoom` and `units_per_pixel` scale the view
36/// uniformly. All three viewport consumers (canvas drawing, the GPU batch
37/// matrix, and pointer picks) derive from these fields through one shared
38/// transform, so they can never disagree.
39#[derive(Clone, Copy, Debug)]
40pub struct Camera2d {
41    /// World-space point the camera follows (the follow target, room
42    /// center, or player position). Defaults to `(0, 0)`.
43    ///
44    /// Scroll limits (see [`apply_limits`]) clamp this field; put
45    /// momentary displacement such as trauma shake in [`offset`](Camera2d::offset)
46    /// instead so it can push past the limits.
47    pub center: Vec2,
48    /// Momentary displacement added to [`center`](Camera2d::center).
49    /// Defaults to `(0, 0)`.
50    ///
51    /// Useful for looking around or camera shake animations: applied after
52    /// limits, so a shake impulse still moves the view even when the follow
53    /// target is pinned at a scroll edge. Decays back to zero under
54    /// game-side trauma handling.
55    pub offset: Vec2,
56    /// World units per screen pixel at zoom 1. Defaults to `1.0`.
57    ///
58    /// Combined with the viewport size to build the orthographic
59    /// projection: a smaller value shows less of the world (larger
60    /// sprites). Together with `zoom`, the visible world width is
61    /// `viewport_dp * units_per_pixel / zoom`. Non-positive values fall
62    /// back to `1.0` so a bad snapshot can never divide by zero.
63    pub units_per_pixel: f32,
64    /// Magnification. Defaults to `1.0`.
65    ///
66    /// Higher values zoom in: `2.0` shows half the world width on each
67    /// axis (a quarter of the area); `0.5` shows twice as much. Applied on
68    /// canvas and GPU alike. Non-positive values fall back to `1.0`.
69    pub zoom: f32,
70}
71
72impl Default for Camera2d {
73    fn default() -> Self {
74        Self {
75            center: Vec2::ZERO,
76            offset: Vec2::ZERO,
77            units_per_pixel: 1.0,
78            zoom: 1.0,
79        }
80    }
81}
82
83/// Scroll limits in world units. Disabled by default.
84///
85/// The clamp applies to [`center`](Camera2d::center) only: pass the follow
86/// target through [`apply_limits`] before writing it into the camera, and
87/// keep momentary displacement in [`offset`](Camera2d::offset) so shake
88/// bypasses the clamp by design.
89#[derive(Clone, Copy, Debug)]
90pub struct CameraLimits {
91    /// Smallest visible center `x`. Defaults to `0.0`.
92    pub left: f32,
93    /// Smallest visible center `y`. Defaults to `0.0`.
94    pub top: f32,
95    /// Largest visible center `x`. Defaults to `0.0`.
96    pub right: f32,
97    /// Largest visible center `y`. Defaults to `0.0`.
98    pub bottom: f32,
99    /// Master switch. Defaults to `false` (no clamping).
100    pub enabled: bool,
101}
102
103impl Default for CameraLimits {
104    fn default() -> Self {
105        Self {
106            left: 0.0,
107            top: 0.0,
108            right: 0.0,
109            bottom: 0.0,
110            enabled: false,
111        }
112    }
113}
114
115/// Clamp a follow target into scroll limits.
116///
117/// Returns `center` unchanged when limits are disabled or when a pair is
118/// inverted (`left > right` is normalized, never a trap). Each axis clamps
119/// independently, so a corner target slides along the clamped edge.
120///
121/// ```rust
122/// use repame_sprite::{CameraLimits, apply_limits};
123///
124/// let limits = CameraLimits { left: 100.0, top: 100.0, right: 700.0, bottom: 500.0, enabled: true };
125/// assert_eq!(apply_limits([50.0, 300.0], limits), [100.0, 300.0]);
126/// assert_eq!(apply_limits([400.0, 300.0], limits), [400.0, 300.0]);
127/// ```
128pub fn apply_limits(center: [f32; 2], limits: CameraLimits) -> [f32; 2] {
129    if !limits.enabled {
130        return center;
131    }
132    [
133        center[0].clamp(limits.left.min(limits.right), limits.left.max(limits.right)),
134        center[1].clamp(limits.top.min(limits.bottom), limits.top.max(limits.bottom)),
135    ]
136}
137
138/// Ease the camera toward its follow target.
139///
140/// Moves `current` toward `target` with exponential smoothing at `speed`
141/// world units per second: fast when far away, settling gently without
142/// overshooting. Large `speed` values approach a snap; the motion is
143/// frame-rate independent for a fixed `dt`.
144///
145/// - `speed <= 0` (or non-finite) snaps directly to `target`.
146/// - `dt <= 0` holds `current` (a paused frame never moves the camera).
147///
148/// ```rust
149/// use repame_sprite::smooth_toward;
150///
151/// let p = smooth_toward([0.0, 0.0], [100.0, 0.0], 5.0, 0.016);
152/// assert!(p[0] > 0.0 && p[0] < 100.0);
153/// ```
154pub fn smooth_toward(current: [f32; 2], target: [f32; 2], speed: f32, dt: f32) -> [f32; 2] {
155    if dt <= 0.0 {
156        return current;
157    }
158    if speed <= 0.0 || !speed.is_finite() {
159        return target;
160    }
161    let t = 1.0 - (-speed * dt).exp();
162    [
163        current[0] + (target[0] - current[0]) * t,
164        current[1] + (target[1] - current[1]) * t,
165    ]
166}
167
168impl Camera2d {
169    /// Effective look point: `center + offset`.
170    ///
171    /// All framing (canvas drawing, the GPU batch matrix, pointer picks,
172    /// actor surfaces) uses this value, so shake and look-around applied
173    /// through `offset` move every consumer together.
174    pub fn effective_center(&self) -> [f32; 2] {
175        [self.center.x + self.offset.x, self.center.y + self.offset.y]
176    }
177
178    /// Y-down view-projection: world +y points screen-down. Guards degenerate inputs (zero viewport/zoom) so a
179    /// bad snapshot can never div-by-zero; callers driving viewports
180    /// should prefer [`fit_view_proj`] (contain-fit + look), which is the
181    /// single framing contract canvas, GPU, and picks share.
182    pub fn view_proj(&self, viewport_px: [f32; 2]) -> Mat4 {
183        let vp_w = viewport_px[0].max(1.0);
184        let vp_h = viewport_px[1].max(1.0);
185        let zoom = sane_positive(self.zoom);
186        let upp = sane_positive(self.units_per_pixel);
187        let w = vp_w * upp / zoom;
188        let h = vp_h * upp / zoom;
189        let proj = glam::camera::rh::proj::directx::orthographic(
190            -w / 2.0,
191            w / 2.0,
192            h / 2.0,
193            -h / 2.0,
194            -1000.0,
195            1000.0,
196        );
197        let view = Mat4::from_translation(Vec3::new(
198            -(self.center.x + self.offset.x),
199            -(self.center.y + self.offset.y),
200            0.0,
201        ));
202        proj * view
203    }
204
205    /// World-space position under a viewport-pixel cursor position.
206    /// Guards zero viewports (returns the look point instead of NaN).
207    pub fn screen_to_world(&self, viewport_px: [f32; 2], px: [f32; 2]) -> Vec2 {
208        if viewport_px[0] <= 0.0 || viewport_px[1] <= 0.0 {
209            let [cx, cy] = self.effective_center();
210            return Vec2::new(cx, cy);
211        }
212        let ndc = Vec2::new(
213            (px[0] / viewport_px[0]) * 2.0 - 1.0,
214            1.0 - (px[1] / viewport_px[1]) * 2.0,
215        );
216        let inv = self.view_proj(viewport_px).inverse();
217        let world = inv.project_point3(ndc.extend(0.0));
218        Vec2::new(world.x, world.y)
219    }
220}
221
222/// One batched sprite. `uv` is in atlas texels normalized to 0..1.
223#[derive(Clone, Copy, Debug)]
224pub struct SpriteInstance {
225    /// World-space center, rotation radians, size in world units.
226    pub center: Vec2,
227    pub rotation: f32,
228    pub size: Vec2,
229    /// Normalized anchor (origin): `[0.5, 0.5]` centers the quad on
230    /// `center` (legacy default), `[0, 0]` pins the top-left corner -
231    /// bevy `Anchor` semantics, y-down.
232    pub anchor: Vec2,
233    /// Mirror around the anchor axes (left-walking hordes, etc.).
234    /// Solid canvas fills are flip-invariant; the GPU batch mirrors
235    /// geometry (UVs untouched).
236    pub flip_x: bool,
237    pub flip_y: bool,
238    pub uv_min: Vec2,
239    pub uv_max: Vec2,
240    /// RGBA tint, same byte semantics as the legacy canvas path
241    /// (`(c * 255) as u8` per channel).
242    pub color: [f32; 4],
243    /// Atlas page index for multi-texture batches.
244    pub page: u32,
245}
246
247impl Default for SpriteInstance {
248    fn default() -> Self {
249        Self {
250            center: Vec2::ZERO,
251            rotation: 0.0,
252            size: Vec2::ONE,
253            anchor: Vec2::new(0.5, 0.5),
254            flip_x: false,
255            flip_y: false,
256            uv_min: Vec2::ZERO,
257            uv_max: Vec2::ONE,
258            color: [1.0, 1.0, 1.0, 1.0],
259            page: 0,
260        }
261    }
262}
263
264/// World-anchored text floater (damage numbers, `+25`).
265#[derive(Clone, Debug, Default)]
266pub struct WorldText {
267    pub text: String,
268    /// World-space anchor, same convention as the legacy canvas text path.
269    pub pos: Vec2,
270    /// RGBA, same byte semantics as [`SpriteInstance::color`].
271    pub color: [f32; 4],
272    /// Font size in world units.
273    pub size: f32,
274}
275
276/// Everything the viewport draws this frame. Plain data, snapshot per frame.
277///
278/// Framing contract (single source of truth): canvas, GPU, and picks all
279/// derive from `world_size` + `cam` through [`effective_fit`] /
280/// [`fit_view_proj`] / [`world_to_dp`]. `cam.effective_center()` is the
281/// look point (rest + `offset` shake included); `cam.zoom` /
282/// `cam.units_per_pixel` scale the fit uniformly on every backend.
283#[derive(Clone, Default, Debug)]
284pub struct FrameInput {
285    pub cam: Camera2d,
286    /// World-space extent the contain-fit maps into the viewport.
287    pub world_size: [f32; 2],
288    /// Cold-start viewport size in **dp** (physical px / density), used for
289    /// the GPU camera until the first painted [`FrameGeom`] arrives.
290    /// Must be dp, not physical px, or the first frame's fit is off by
291    /// the density factor on HiDPI screens.
292    pub viewport_px: [f32; 2],
293    pub sprites: Vec<SpriteInstance>,
294    /// World-anchored text, drawn after the sprite pass.
295    pub texts: Vec<WorldText>,
296    /// Full-viewport fill under everything (letterbox included).
297    pub background: Option<[f32; 4]>,
298    /// Optional fullscreen tint/color-grading hook (e.g. FOW dimming,
299    /// damage flash). Applied after the sprite pass.
300    pub overlay_color: Option<[f32; 4]>,
301    /// Chromatic aberration amount (bevy `chromatic_intensity` units;
302    /// NT pulses land at 0.04..0.7). GPU viewports render the scene
303    /// offscreen and composite it back with an RGB split; `0.0` keeps
304    /// the zero-cost direct path. Canvas viewports ignore it (sampling
305    /// FX need pixels, and the canvas path is vector commands).
306    pub chroma: f32,
307}
308
309/// UI-facing pointer events from the viewport.
310#[derive(Clone, Debug)]
311pub enum PickEvent {
312    Click { world: Vec2, screen: [f32; 2] },
313    Hover { world: Vec2 },
314    /// Touch/pen contact began: pointer id + window-physical px
315    /// (y-down, `origin + position`). Mouse never emits these; taps
316    /// still emit `Click` too (button/UI parity).
317    TouchDown { id: u64, screen: [f32; 2] },
318    /// Touch/pen contact moved (same coordinate space as `TouchDown`).
319    TouchMove { id: u64, screen: [f32; 2] },
320    /// Touch/pen contact ended (up / leave).
321    TouchUp { id: u64 },
322}
323
324/// Touch/pen pointers drive game touch zones; mouse stays on the
325/// click/hover path.
326fn is_touch(ev: &repose_core::input::PointerEvent) -> bool {
327    matches!(
328        ev.kind,
329        repose_core::input::PointerKind::Touch | repose_core::input::PointerKind::Pen
330    )
331}
332
333/// Window-physical px (y-down) for touch-zone sampling.
334fn screen_of(ev: &repose_core::input::PointerEvent) -> [f32; 2] {
335    let p = ev.position_in_window();
336    [p.x, p.y]
337}
338
339/// Clamp helper: finite positive values pass through, everything else
340/// (zero, negative, NaN, infinite) falls back to 1.0 so framing math can
341/// never div-by-zero.
342fn sane_positive(v: f32) -> f32 {
343    if v.is_finite() && v > 1e-6 { v } else { 1.0 }
344}
345
346/// Contain-fit of a `world` extent into a dp-space canvas: uniform scale
347/// plus centering offsets, all dp. Pure, so games and tests can pin it.
348/// No silent clamp: `world_to_dp` and `dp_to_world` are exact inverses
349/// for any positive scale, so picks never drift from drawn content.
350pub fn contain_fit(canvas_dp: [f32; 2], world: [f32; 2]) -> (f32, f32, f32) {
351    if canvas_dp[0] <= 0.0 || canvas_dp[1] <= 0.0 || world[0] <= 0.0 || world[1] <= 0.0 {
352        return (1.0, 0.0, 0.0);
353    }
354    let s = (canvas_dp[0] / world[0]).min(canvas_dp[1] / world[1]);
355    if !s.is_finite() || s <= 1e-6 {
356        return (1.0, 0.0, 0.0);
357    }
358    (
359        s,
360        (canvas_dp[0] - world[0] * s) * 0.5,
361        (canvas_dp[1] - world[1] * s) * 0.5,
362    )
363}
364
365/// Effective fit for one frame: base contain-fit scaled by
366/// `zoom / units_per_pixel`, re-centered. Default camera (`zoom = 1`,
367/// `units_per_pixel = 1`) is exactly [`contain_fit`]; zoom 2 doubles
368/// on-screen size on canvas and GPU alike.
369pub fn effective_fit(canvas_dp: [f32; 2], world: [f32; 2], cam: &Camera2d) -> (f32, f32, f32) {
370    let (base, _, _) = contain_fit(canvas_dp, world);
371    let zoom = sane_positive(cam.zoom);
372    let upp = sane_positive(cam.units_per_pixel);
373    let s = base * zoom / upp;
374    if !s.is_finite() || s <= 1e-6 {
375        return (1.0, 0.0, 0.0);
376    }
377    (
378        s,
379        (canvas_dp[0] - world[0] * s) * 0.5,
380        (canvas_dp[1] - world[1] * s) * 0.5,
381    )
382}
383
384/// GPU camera for the shared framing contract: the exact matrix form of
385/// [`world_to_dp`] over `canvas_dp` (dp = physical px / density).
386/// Canvas draws via `world_to_dp`, GPU draws via this matrix, picks invert
387/// via `dp_to_world`. One transform, three consumers.
388pub fn fit_view_proj(
389    canvas_dp: [f32; 2],
390    world_size: [f32; 2],
391    cam_center: [f32; 2],
392    fit: (f32, f32, f32),
393) -> Mat4 {
394    let w = canvas_dp[0].max(1.0);
395    let h = canvas_dp[1].max(1.0);
396    let (s, ox, oy) = fit;
397    let s = if s.is_finite() && s > 1e-6 { s } else { 1.0 };
398    let lx = cam_center[0] - world_size[0] * 0.5;
399    let ly = cam_center[1] - world_size[1] * 0.5;
400    let sx = 2.0 * s / w;
401    let sy = -2.0 * s / h;
402    let tx = 2.0 * (ox - lx * s) / w - 1.0;
403    let ty = 1.0 - 2.0 * (oy - ly * s) / h;
404    Mat4::from_cols(
405        glam::Vec4::new(sx, 0.0, 0.0, 0.0),
406        glam::Vec4::new(0.0, sy, 0.0, 0.0),
407        glam::Vec4::new(0.0, 0.0, 0.001, 0.0),
408        glam::Vec4::new(tx, ty, 0.0, 1.0),
409    )
410}
411
412/// World point -> dp canvas point through the fit. `cam_center` shifts the
413/// look point (trauma shake included): at the default center
414/// (`world / 2`) this is exactly `offset + world * scale`.
415pub fn world_to_dp(
416    world: [f32; 2],
417    world_size: [f32; 2],
418    cam_center: [f32; 2],
419    fit: (f32, f32, f32),
420) -> [f32; 2] {
421    let (s, ox, oy) = fit;
422    [
423        ox + (world[0] - (cam_center[0] - world_size[0] * 0.5)) * s,
424        oy + (world[1] - (cam_center[1] - world_size[1] * 0.5)) * s,
425    ]
426}
427
428/// Inverse of [`world_to_dp`]: dp canvas point -> world point.
429/// Guards degenerate scales (returns the look point instead of Inf).
430pub fn dp_to_world(
431    dp: [f32; 2],
432    world_size: [f32; 2],
433    cam_center: [f32; 2],
434    fit: (f32, f32, f32),
435) -> [f32; 2] {
436    let (s, ox, oy) = fit;
437    if !s.is_finite() || s.abs() < 1e-6 {
438        return cam_center;
439    }
440    [
441        (dp[0] - ox) / s + (cam_center[0] - world_size[0] * 0.5),
442        (dp[1] - oy) / s + (cam_center[1] - world_size[1] * 0.5),
443    ]
444}
445
446/// Viewport-local physical-px press -> world point through the painted
447/// [`FrameGeom`]. Takes the region-local position (`PointerEvent.position`,
448/// already relative to the viewport origin), so HUD chrome, docking, or a
449/// non-fullscreen viewport never shift picks.
450pub fn pick_world(local_px: [f32; 2], geom: FrameGeom, world_size: [f32; 2]) -> [f32; 2] {
451    let d = if geom.density.is_finite() && geom.density > 1e-6 {
452        geom.density
453    } else {
454        1.0
455    };
456    dp_to_world(
457        [local_px[0] / d, local_px[1] / d],
458        world_size,
459        [
460            geom.look[0] + world_size[0] * 0.5,
461            geom.look[1] + world_size[1] * 0.5,
462        ],
463        geom.fit,
464    )
465}
466
467/// One painted frame's geometry, shared between [`Viewport2d`] /
468/// [`Viewport2dGpu`] (writers) and world-anchored siblings like
469/// [`ActorFrame`] (readers). Everything needed to map either direction,
470/// so board sprites, picks, and actor surfaces stay glued - including
471/// under camera shake.
472#[derive(Clone, Copy, Debug)]
473pub struct FrameGeom {
474    /// Effective dp fit of the world extent: `(scale, off_x, off_y)` from
475    /// [`effective_fit`] (contain-fit scaled by zoom/units_per_pixel).
476    pub fit: (f32, f32, f32),
477    /// Look-point shift in world units: `cam.effective_center() - world / 2`
478    /// (rest + offset shake included; zero when the camera is default).
479    pub look: [f32; 2],
480    /// Physical px per dp at paint time.
481    pub density: f32,
482    /// Painted viewport size in physical px (drives the GPU camera).
483    pub viewport_px: [f32; 2],
484}
485
486impl Default for FrameGeom {
487    fn default() -> Self {
488        Self {
489            fit: (1.0, 0.0, 0.0),
490            look: [0.0, 0.0],
491            density: 1.0,
492            viewport_px: [1.0, 1.0],
493        }
494    }
495}
496
497/// World-anchored surface rect in dp: `([off_x, off_y], [w, h])` for a
498/// `size` box centered on `center`. Pure; `ActorFrame` is its view form.
499pub fn surface_dp(center: [f32; 2], size: [f32; 2], geom: FrameGeom) -> ([f32; 2], [f32; 2]) {
500    let (s, ox, oy) = geom.fit;
501    (
502        [
503            ox + (center[0] - size[0] * 0.5 - geom.look[0]) * s,
504            oy + (center[1] - size[1] * 0.5 - geom.look[1]) * s,
505        ],
506        [size[0] * s, size[1] * s],
507    )
508}
509
510fn rgba8(c: [f32; 4]) -> Color {
511    Color::from_rgba(
512        (c[0].clamp(0.0, 1.0) * 255.0) as u8,
513        (c[1].clamp(0.0, 1.0) * 255.0) as u8,
514        (c[2].clamp(0.0, 1.0) * 255.0) as u8,
515        (c[3].clamp(0.0, 1.0) * 255.0) as u8,
516    )
517}
518
519/// 2D viewport view. Owns nothing render-side; gesture handling and camera
520/// state live in the [`FrameInput`] snapshot (same split as resims
521/// `Viewport3d`).
522///
523/// Layout contract: fills its parent. Draws `background`, then sprites,
524/// then world texts, then the fullscreen tint - all through the shared
525/// [`effective_fit`] framing of [`FrameInput::world_size`]. Pointer presses
526/// (`Click`) and cursor moves (`Hover`) are reported via `on_event` in
527/// world coords (viewport-local, so a non-zero viewport origin never
528/// shifts picks). Each paint publishes a
529/// [`FrameGeom`] snapshot to `geom_out` for dp-space siblings
530/// ([`ActorFrame`]); the viewport, picks, and actor surfaces therefore
531/// share one transform by construction.
532///
533/// Canvas note: the canvas path draws debug solids (axis-aligned bounding
534/// boxes of the snapshot quads). Rotated sprites are exact on the GPU
535/// viewport; on canvas they draw as their AABB with a debug assert.
536#[allow(non_snake_case)] // Repose view convention (cf. resims `Viewport3d`).
537pub fn Viewport2d(
538    input: FrameInput,
539    geom_out: Rc<Cell<FrameGeom>>,
540    on_event: impl Fn(PickEvent) + 'static,
541) -> View {
542    let input = Rc::new(input);
543    let world_size = input.world_size;
544    let pick_geom = geom_out.clone();
545    let move_geom = geom_out.clone();
546    let draw_input = input.clone();
547    let draw_geom = geom_out.clone();
548    let on_event = Rc::new(on_event);
549    let on_down = on_event.clone();
550    let on_touch_down = on_event.clone();
551    let on_move = on_event.clone();
552    let on_touch_move = on_event.clone();
553    let on_up = on_event.clone();
554    let on_leave = on_event;
555
556    let modifier = Modifier::new()
557        .fill_max_size()
558        .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
559            let p = ev.position;
560            let g = pick_geom.get();
561            let world = pick_world([p.x, p.y], g, world_size);
562            on_down(PickEvent::Click {
563                world: Vec2::new(world[0], world[1]),
564                screen: [p.x, p.y],
565            });
566            if is_touch(&ev) {
567                on_touch_down(PickEvent::TouchDown {
568                    id: ev.id.0,
569                    screen: screen_of(&ev),
570                });
571            }
572        })
573        .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
574            let p = ev.position;
575            let g = move_geom.get();
576            let world = pick_world([p.x, p.y], g, world_size);
577            on_move(PickEvent::Hover {
578                world: Vec2::new(world[0], world[1]),
579            });
580            if is_touch(&ev) {
581                on_touch_move(PickEvent::TouchMove {
582                    id: ev.id.0,
583                    screen: screen_of(&ev),
584                });
585            }
586        })
587        .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
588            if is_touch(&ev) {
589                on_up(PickEvent::TouchUp { id: ev.id.0 });
590            }
591        })
592        .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
593            if is_touch(&ev) {
594                on_leave(PickEvent::TouchUp { id: ev.id.0 });
595            }
596        });
597    Canvas(modifier, move |scope: &mut DrawScope| {
598        let d = effective_density_scale();
599        let cam = draw_input.cam;
600        let cam_center = cam.effective_center();
601        let fit = effective_fit(
602            [scope.size.width / d, scope.size.height / d],
603            draw_input.world_size,
604            &cam,
605        );
606        draw_geom.set(FrameGeom {
607            fit,
608            look: [
609                cam_center[0] - draw_input.world_size[0] * 0.5,
610                cam_center[1] - draw_input.world_size[1] * 0.5,
611            ],
612            density: d,
613            viewport_px: [scope.size.width, scope.size.height],
614        });
615        let project = |wx: f32, wy: f32| -> [f32; 2] {
616            let [dx, dy] = world_to_dp(
617                [wx, wy],
618                draw_input.world_size,
619                cam_center,
620                (fit.0, fit.1, fit.2),
621            );
622            [dx * d, dy * d]
623        };
624        if let Some(bg) = draw_input.background {
625            scope.draw_rect(
626                Rect {
627                    x: 0.0,
628                    y: 0.0,
629                    w: scope.size.width,
630                    h: scope.size.height,
631                },
632                rgba8(bg),
633                0.0,
634            );
635        }
636        for spr in draw_input.sprites.iter() {
637            if spr.rotation.abs() > 1e-4 {
638                debug_assert!(
639                    false,
640                    "canvas viewport draws rotated sprites as AABB; use GPU viewport for exact rotation"
641                );
642            }
643            let (row0, row1) = batch::instance_rows(
644                [spr.center.x, spr.center.y],
645                [spr.size.x, spr.size.y],
646                spr.rotation,
647                [spr.anchor.x, spr.anchor.y],
648                spr.flip_x,
649                spr.flip_y,
650            );
651            let mut min_x = f32::INFINITY;
652            let mut min_y = f32::INFINITY;
653            let mut max_x = f32::NEG_INFINITY;
654            let mut max_y = f32::NEG_INFINITY;
655            for corner in [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]] {
656                let wx = row0[0] * corner[0] + row0[1] * corner[1] + row0[3];
657                let wy = row1[0] * corner[0] + row1[1] * corner[1] + row1[3];
658                let [px, py] = project(wx, wy);
659                min_x = min_x.min(px);
660                min_y = min_y.min(py);
661                max_x = max_x.max(px);
662                max_y = max_y.max(py);
663            }
664            scope.draw_rect(
665                Rect {
666                    x: min_x,
667                    y: min_y,
668                    w: (max_x - min_x).max(0.0),
669                    h: (max_y - min_y).max(0.0),
670                },
671                rgba8(spr.color),
672                0.0,
673            );
674        }
675        for t in draw_input.texts.iter() {
676            let [tx, ty] = project(t.pos.x, t.pos.y);
677            scope.draw_text(
678                t.text.clone(),
679                repose_core::Vec2 { x: tx, y: ty },
680                rgba8(t.color),
681                t.size * fit.0 * d,
682            );
683        }
684        if let Some(tint) = draw_input.overlay_color {
685            scope.draw_rect(
686                Rect {
687                    x: 0.0,
688                    y: 0.0,
689                    w: scope.size.width,
690                    h: scope.size.height,
691                },
692                rgba8(tint),
693                0.0,
694            );
695        }
696    })
697}
698
699/// GPU twin of [`Viewport2d`]: same snapshot in, same picks out, but
700/// sprites draw as textured atlas quads through [`SpriteBatch`].
701///
702/// Framing is identical to the canvas path: the batch camera is
703/// [`fit_view_proj`] over the density-corrected viewport (physical px /
704/// density, same dp the canvas path draws in). `background` paints first
705/// as a uniform-only fullscreen fill so GPU and canvas agree on every
706/// `FrameInput` field except world texts and tint, which stay canvas-view
707/// features for now: GPU consumers compose those as sibling views.
708///
709/// Layout contract: fills its parent. The payload is rebuilt from the
710/// snapshot every frame (like the canvas draw closure): `prepare`
711/// rebuilds the batch, and camera from the last painted viewport (cold
712/// start falls back to [`FrameInput::viewport_px`]), and `paint`
713/// records the fresh viewport into `geom_out` and issues the shared
714/// draw calls. Atlas uploads ride along per frame; games drain their
715/// atlas queue once per frame, so each upload applies exactly once.
716/// Geometry crosses the render thread behind a mutex (`WgpuCallback`
717/// payloads must be `Send + Sync`), picks read it back on the UI side.
718/// `FrameInput::chroma` above `0.0` renders the scene offscreen and
719/// composites it back with an RGB split (see `post`); `0.0` draws the
720/// batch straight into the main pass.
721#[allow(non_snake_case)]
722pub fn Viewport2dGpu(
723    input: FrameInput,
724    geom_out: Arc<Mutex<FrameGeom>>,
725    uploads: Vec<AtlasUpload>,
726    desc: BatchDesc,
727    on_event: impl Fn(PickEvent) + 'static,
728) -> View {
729    let input = Arc::new(input);
730    let world_size = input.world_size;
731    let pick_geom = geom_out.clone();
732    let move_geom = geom_out.clone();
733    let on_event = Arc::new(on_event);
734    let on_down = on_event.clone();
735    let on_touch_down = on_event.clone();
736    let on_move = on_event.clone();
737    let on_touch_move = on_event.clone();
738    let on_up = on_event.clone();
739    let on_leave = on_event;
740    let payload = GpuViewport {
741        input: input.clone(),
742        geom: geom_out,
743        uploads,
744        desc,
745        bg: FullscreenPass::new(
746            "viewport2d.background",
747            fullscreen::SOLID_WGSL,
748            FullscreenDesc {
749                texture_slots: 0,
750                filter: TextureFilter::Nearest,
751            },
752        ),
753    };
754    let modifier = Modifier::new()
755        .fill_max_size()
756        .on_pointer_down(move |ev: repose_core::input::PointerEvent| {
757            let p = ev.position;
758            let Ok(g) = pick_geom.lock() else {
759                return;
760            };
761            let world = pick_world([p.x, p.y], *g, world_size);
762            on_down(PickEvent::Click {
763                world: Vec2::new(world[0], world[1]),
764                screen: [p.x, p.y],
765            });
766            if is_touch(&ev) {
767                on_touch_down(PickEvent::TouchDown {
768                    id: ev.id.0,
769                    screen: screen_of(&ev),
770                });
771            }
772        })
773        .on_pointer_move(move |ev: repose_core::input::PointerEvent| {
774            let p = ev.position;
775            let Ok(g) = move_geom.lock() else {
776                return;
777            };
778            let world = pick_world([p.x, p.y], *g, world_size);
779            on_move(PickEvent::Hover {
780                world: Vec2::new(world[0], world[1]),
781            });
782            if is_touch(&ev) {
783                on_touch_move(PickEvent::TouchMove {
784                    id: ev.id.0,
785                    screen: screen_of(&ev),
786                });
787            }
788        })
789        .on_pointer_up(move |ev: repose_core::input::PointerEvent| {
790            if is_touch(&ev) {
791                on_up(PickEvent::TouchUp { id: ev.id.0 });
792            }
793        })
794        .on_pointer_leave(move |ev: repose_core::input::PointerEvent| {
795            if is_touch(&ev) {
796                on_leave(PickEvent::TouchUp { id: ev.id.0 });
797            }
798        });
799    Embedded(modifier, Callback::new(payload))
800}
801
802struct GpuViewport {
803    input: Arc<FrameInput>,
804    geom: Arc<Mutex<FrameGeom>>,
805    uploads: Vec<AtlasUpload>,
806    desc: BatchDesc,
807    bg: FullscreenPass,
808}
809
810impl WgpuCallback for GpuViewport {
811    fn prepare(
812        &self,
813        device: &wgpu::Device,
814        queue: &wgpu::Queue,
815        encoder: &mut wgpu::CommandEncoder,
816        screen: &ScreenDescriptor,
817        resources: &mut CallbackResources,
818    ) -> Vec<wgpu::CommandBuffer> {
819        let (vp_phys, density) = self
820            .geom
821            .lock()
822            .map(|g| {
823                let d = if g.density.is_finite() && g.density > 1e-6 {
824                    g.density
825                } else {
826                    1.0
827                };
828                (g.viewport_px, d)
829            })
830            .unwrap_or(([1.0, 1.0], 1.0));
831        let dp = if vp_phys[0] > 1.0 && vp_phys[1] > 1.0 {
832            [vp_phys[0] / density, vp_phys[1] / density]
833        } else {
834            self.input.viewport_px
835        };
836        let fit = effective_fit(dp, self.input.world_size, &self.input.cam);
837        let cam_center = self.input.cam.effective_center();
838        let mut batch = SpriteBatch::new(self.desc);
839        batch.set_camera(fit_view_proj(dp, self.input.world_size, cam_center, fit));
840        for s in &self.input.sprites {
841            batch.push_sprite(s);
842        }
843        batch.extend_uploads(self.uploads.clone());
844        batch.prepare(device, queue, encoder, screen, resources);
845        // Background first (uniform-only fill, same color the canvas
846        // path fills under the batch). Skipped when the snapshot has
847        // none so transparent scenes keep compositing.
848        if let Some(bg) = self.input.background {
849            let words = [bg[0], bg[1], bg[2], bg[3]];
850            self.bg.prepare_with(
851                device,
852                queue,
853                screen,
854                resources,
855                bytemuck::cast_slice(&words),
856                &[],
857            );
858        }
859        if post::use_composite(self.input.chroma) {
860            let w = vp_phys[0].max(1.0) as u32;
861            let h = vp_phys[1].max(1.0) as u32;
862            post::prepare_composite(
863                device,
864                queue,
865                encoder,
866                screen,
867                resources,
868                w,
869                h,
870                self.input.chroma,
871                self.input.background,
872            );
873        }
874        Vec::new()
875    }
876
877    fn paint(
878        &self,
879        info: repose_core::PaintCallbackInfo,
880        rpass: &mut wgpu::RenderPass<'static>,
881        resources: &CallbackResources,
882    ) {
883        let d = if info.pixels_per_point.is_finite() && info.pixels_per_point > 1e-6 {
884            info.pixels_per_point
885        } else {
886            1.0
887        };
888        let vp = [info.viewport.w, info.viewport.h];
889        let fit = effective_fit(
890            [vp[0] / d, vp[1] / d],
891            self.input.world_size,
892            &self.input.cam,
893        );
894        let cam = self.input.cam.effective_center();
895        if let Ok(mut g) = self.geom.lock() {
896            *g = FrameGeom {
897                fit,
898                look: [
899                    cam[0] - self.input.world_size[0] * 0.5,
900                    cam[1] - self.input.world_size[1] * 0.5,
901                ],
902                density: d,
903                viewport_px: vp,
904            };
905        }
906        if post::use_composite(self.input.chroma) {
907            post::paint_composite(rpass, resources);
908        } else {
909            // Snapshot background first (opaque uniform fill), then the
910            // sprite batch on top: same order as the canvas path.
911            if self.input.background.is_some() {
912                self.bg.paint(info, rpass, resources);
913            }
914            draw_batch(rpass, resources);
915        }
916    }
917}
918
919/// A world-anchored surface: `child` (painting in its own local coords)/// is boxed to a `size` rect centered on a world `center`, positioned
920/// through the viewport's [`FrameGeom`]. Siblings of [`Viewport2d`] -
921/// e.g. live actor surfaces - track sim positions without game-side unit
922/// math, glued to sprites and picks even under camera shake. `mirror_x`
923/// flips around the surface center (left-walking hordes reusing
924/// right-facing art).
925#[allow(non_snake_case)]
926pub fn ActorFrame(
927    center: [f32; 2],
928    size: [f32; 2],
929    geom: Rc<Cell<FrameGeom>>,
930    mirror_x: bool,
931    child: View,
932) -> View {
933    let ([ox, oy], [w, h]) = surface_dp(center, size, geom.get());
934    // `.absolute()` is load-bearing: without it taffy ignores the offsets
935    // and every surface stacks at the same fixed spot.
936    let mut modifier = Modifier::new()
937        .size(w, h)
938        .absolute()
939        .offset(Some(ox), Some(oy), None, None);
940    if mirror_x {
941        modifier = modifier.scale2(-1.0, 1.0);
942    }
943    UiBox(modifier).child(child)
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949    use repose_core::locals::{Density, set_density_default};
950
951    #[test]
952    fn contain_fit_roundtrip_at_density_125() {
953        // Production eDP-1 runs at 1.25: px canvas -> dp fit -> world ->
954        // back must be identity or clicks miss their visuals.
955        set_density_default(Density { scale: 1.25 });
956        let d = effective_density_scale();
957        // 1250x750 px canvas, 800x600 world: dp fit s=1, ox=100, oy=0.
958        let fit = contain_fit([1250.0 / d, 750.0 / d], [800.0, 600.0]);
959        let cam = [400.0, 300.0];
960        // Draw: world (400,300) -> dp -> px must hit the press point.
961        let [dx, dy] = world_to_dp([400.0, 300.0], [800.0, 600.0], cam, fit);
962        // Pick: window px -> dp -> world must map back exactly.
963        let [wx, wy] = dp_to_world([625.0 / d, 375.0 / d], [800.0, 600.0], cam, fit);
964        set_density_default(Density { scale: 1.0 });
965        assert!(
966            (fit.0 - 1.0).abs() < 1e-4 && (fit.1 - 100.0).abs() < 1e-4 && fit.2.abs() < 1e-4,
967            "dp fit, got {fit:?}"
968        );
969        assert!(
970            (dx * d - 625.0).abs() < 1e-3 && (dy * d - 375.0).abs() < 1e-3,
971            "draw forward, got ({dx},{dy})"
972        );
973        assert!(
974            (wx - 400.0).abs() < 1e-3 && (wy - 300.0).abs() < 1e-3,
975            "pick inverse, got ({wx},{wy})"
976        );
977        // Camera shift (trauma shake) moves draw and pick together.
978        let shifted = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], fit);
979        let back = dp_to_world(shifted, [800.0, 600.0], [410.0, 290.0], fit);
980        assert!(
981            (back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4,
982            "shifted round trip, got {back:?}"
983        );
984    }
985
986    #[test]
987    fn gpu_viewport_honors_cam_and_background() {
988        // End-to-end GPU proof for the viewport contract: the snapshot
989        // camera frames world sprites (cam center lands on the screen
990        // center) and `background` fills every uncovered pixel. Skips
991        // gracefully where no GPU exists.
992        use repose_core::{Color, Rect, Scene, SceneNode};
993        use repose_render_wgpu::{Callback, offscreen::OffscreenRenderer};
994
995        let mut renderer = match OffscreenRenderer::new_blocking(256, 256, 1) {
996            Ok(r) => r,
997            Err(e) => {
998                eprintln!("SKIP gpu viewport test (no GPU): {e}");
999                return;
1000            }
1001        };
1002        // Page 0: solid magenta 2x2.
1003        let page = [255, 0, 255, 255].repeat(4);
1004        let uploads = vec![AtlasUpload {
1005            page: 0,
1006            x: 0,
1007            y: 0,
1008            w: 2,
1009            h: 2,
1010            rgba: page,
1011        }];
1012        let cam = Camera2d {
1013            center: Vec2::new(64.0, 64.0),
1014            offset: Vec2::ZERO,
1015            units_per_pixel: 0.5,
1016            zoom: 1.0,
1017        };
1018        let input = FrameInput {
1019            cam,
1020            world_size: [128.0, 128.0],
1021            viewport_px: [256.0, 256.0],
1022            sprites: vec![SpriteInstance {
1023                center: Vec2::new(64.0, 64.0),
1024                size: Vec2::new(32.0, 32.0),
1025                uv_min: Vec2::new(0.0, 0.0),
1026                uv_max: Vec2::new(1.0, 1.0),
1027                color: [1.0, 1.0, 1.0, 1.0],
1028                ..Default::default()
1029            }],
1030            texts: Vec::new(),
1031            background: Some([0.0, 0.0, 1.0, 1.0]),
1032            overlay_color: None,
1033            chroma: 0.0,
1034        };
1035        let geom = Arc::new(Mutex::new(FrameGeom {
1036            fit: (1.0, 0.0, 0.0),
1037            look: [0.0, 0.0],
1038            density: 1.0,
1039            viewport_px: [256.0, 256.0],
1040        }));
1041        let payload = GpuViewport {
1042            input: Arc::new(input),
1043            geom,
1044            uploads,
1045            desc: BatchDesc {
1046                layer_size: 2,
1047                layers: 1,
1048                filter: TextureFilter::Nearest,
1049            },
1050            bg: FullscreenPass::new(
1051                "test.viewport2d.background",
1052                fullscreen::SOLID_WGSL,
1053                FullscreenDesc {
1054                    texture_slots: 0,
1055                    filter: TextureFilter::Nearest,
1056                },
1057            ),
1058        };
1059        let scene = Scene {
1060            clear_color: Color::from_rgba(0, 0, 0, 255),
1061            nodes: vec![SceneNode::Callback {
1062                rect: Rect {
1063                    x: 0.0,
1064                    y: 0.0,
1065                    w: 256.0,
1066                    h: 256.0,
1067                },
1068                payload: Callback::new(payload),
1069            }],
1070        };
1071        let px = renderer
1072            .render_rgba(&scene, Some([0.0, 0.0, 0.0, 1.0]))
1073            .expect("offscreen render");
1074        let at = |x: u32, y: u32| -> [u8; 4] {
1075            let i = ((y * 256 + x) * 4) as usize;
1076            [px[i], px[i + 1], px[i + 2], px[i + 3]]
1077        };
1078        // Corners are outside the centered quad: snapshot background.
1079        assert_eq!(at(8, 8), [0, 0, 255, 255], "background fill");
1080        assert_eq!(at(247, 247), [0, 0, 255, 255], "background fill");
1081        // Screen center carries the cam-centered sprite (magenta page).
1082        assert_eq!(at(128, 128), [255, 0, 255, 255], "cam-centered quad");
1083    }
1084
1085    #[test]
1086    fn surface_dp_matches_board_point_under_shake() {
1087        // Actor surfaces and board sprites share FrameGeom: the surface
1088        // center must land exactly on the board-space point, including
1089        // with a shifted look point (trauma shake).
1090        let geom = FrameGeom {
1091            fit: (1.0, 100.0, 0.0),
1092            look: [0.0, 0.0],
1093            density: 1.25,
1094            viewport_px: [1250.0, 750.0],
1095        };
1096        let ([ox, oy], [w, h]) = surface_dp([400.0, 300.0], [64.0, 80.0], geom);
1097        assert!(
1098            (ox - 468.0).abs() < 1e-4 && (oy - 260.0).abs() < 1e-4,
1099            "offset, got ({ox},{oy})"
1100        );
1101        assert!(
1102            (w - 64.0).abs() < 1e-4 && (h - 80.0).abs() < 1e-4,
1103            "size, got ({w},{h})"
1104        );
1105        // Surface center == board point for the same world pos.
1106        let board = world_to_dp([400.0, 300.0], [800.0, 600.0], [400.0, 300.0], geom.fit);
1107        assert!(
1108            (ox + w * 0.5 - board[0]).abs() < 1e-4 && (oy + h * 0.5 - board[1]).abs() < 1e-4,
1109            "surface glued to board point"
1110        );
1111        let shaken = FrameGeom {
1112            look: [10.0, -10.0],
1113            ..geom
1114        };
1115        let ([sx, sy], _) = surface_dp([400.0, 300.0], [64.0, 80.0], shaken);
1116        let sboard = world_to_dp([400.0, 300.0], [800.0, 600.0], [410.0, 290.0], shaken.fit);
1117        assert!(
1118            (sx + w * 0.5 - sboard[0]).abs() < 1e-4 && (sy + h * 0.5 - sboard[1]).abs() < 1e-4,
1119            "surface glued under shake"
1120        );
1121    }
1122
1123    #[test]
1124    fn contain_fit_has_no_silent_clamp() {
1125        for (canvas, world) in [
1126            ([8000.0, 6000.0], [80.0, 60.0]),
1127            ([80.0, 60.0], [8000.0, 6000.0]),
1128            ([1000.0, 750.0], [800.0, 600.0]),
1129        ] {
1130            let fit = contain_fit(canvas, world);
1131            let cam = [world[0] * 0.5, world[1] * 0.5];
1132            let dp = world_to_dp([cam[0], cam[1]], world, cam, fit);
1133            let back = dp_to_world(dp, world, cam, fit);
1134            assert!(
1135                (back[0] - cam[0]).abs() < 1e-3 && (back[1] - cam[1]).abs() < 1e-3,
1136                "round trip for {canvas:?}/{world:?}, fit {fit:?} gave {back:?}"
1137            );
1138        }
1139        assert_eq!(contain_fit([0.0, 600.0], [800.0, 600.0]), (1.0, 0.0, 0.0));
1140        assert_eq!(
1141            dp_to_world(
1142                [10.0, 10.0],
1143                [800.0, 600.0],
1144                [400.0, 300.0],
1145                (0.0, 0.0, 0.0)
1146            ),
1147            [400.0, 300.0]
1148        );
1149    }
1150
1151    #[test]
1152    fn camera_guards_never_div_by_zero() {
1153        let bad = Camera2d {
1154            center: Vec2::ZERO,
1155            offset: Vec2::ZERO,
1156            units_per_pixel: 0.0,
1157            zoom: 0.0,
1158        };
1159        let m = bad.view_proj([0.0, 0.0]);
1160        assert!(m.is_finite(), "view_proj must stay finite, got {m:?}");
1161        assert_eq!(bad.screen_to_world([0.0, 0.0], [5.0, 5.0]), Vec2::ZERO);
1162    }
1163
1164    #[test]
1165    fn fit_matrix_canvas_gpu_picks_agree() {
1166        let cam = Camera2d {
1167            center: Vec2::new(410.0, 290.0),
1168            offset: Vec2::ZERO,
1169            units_per_pixel: 1.0,
1170            zoom: 2.0,
1171        };
1172        let world_size = [800.0, 600.0];
1173        let density = 1.25;
1174        let vp_phys = [1250.0, 750.0];
1175        let canvas_dp = [vp_phys[0] / density, vp_phys[1] / density];
1176        let cam_center = cam.effective_center();
1177        let fit = effective_fit(canvas_dp, world_size, &cam);
1178        assert!((fit.0 - 2.0).abs() < 1e-4, "effective fit, got {fit:?}");
1179        let vp = fit_view_proj(canvas_dp, world_size, cam_center, fit);
1180        assert!(vp.is_finite());
1181        for world in [[400.0, 300.0], [410.0, 290.0], [0.0, 0.0], [800.0, 600.0]] {
1182            let [dx, dy] = world_to_dp(world, world_size, cam_center, fit);
1183            let px = [dx * density, dy * density];
1184            let ndc = vp.project_point3(glam::Vec3::new(world[0], world[1], 0.0));
1185            let gpu_px = [
1186                (ndc.x + 1.0) * 0.5 * vp_phys[0],
1187                (1.0 - ndc.y) * 0.5 * vp_phys[1],
1188            ];
1189            assert!(
1190                (px[0] - gpu_px[0]).abs() < 1e-2 && (px[1] - gpu_px[1]).abs() < 1e-2,
1191                "canvas/GPU disagree for {world:?}: canvas {px:?} vs gpu {gpu_px:?}"
1192            );
1193            let back = dp_to_world(
1194                [px[0] / density, px[1] / density],
1195                world_size,
1196                cam_center,
1197                fit,
1198            );
1199            assert!(
1200                (back[0] - world[0]).abs() < 1e-3 && (back[1] - world[1]).abs() < 1e-3,
1201                "pick round trip for {world:?} gave {back:?}"
1202            );
1203        }
1204    }
1205
1206    #[test]
1207    fn picks_ignore_viewport_origin() {
1208        let geom = FrameGeom {
1209            fit: (1.0, 100.0, 0.0),
1210            look: [0.0, 0.0],
1211            density: 1.25,
1212            viewport_px: [1250.0, 750.0],
1213        };
1214        let world_size = [800.0, 600.0];
1215        let a = pick_world([125.0, 75.0], geom, world_size);
1216        let b = pick_world([125.0, 75.0], geom, world_size);
1217        assert_eq!(a, b);
1218        let [wx, wy] = pick_world([125.0, 75.0], geom, world_size);
1219        assert!(
1220            (wx - 0.0).abs() < 1e-4 && (wy - 60.0).abs() < 1e-4,
1221            "local pick, got ({wx},{wy})"
1222        );
1223    }
1224
1225    #[test]
1226    fn offset_shifts_look_and_draw_together() {
1227        // Shake rides the offset channel: the same world point draws and
1228        // picks with the offset applied.
1229        let world_size = [800.0, 600.0];
1230        let canvas_dp = [1000.0, 600.0];
1231        let mut cam = Camera2d {
1232            center: Vec2::new(400.0, 300.0),
1233            offset: Vec2::new(10.0, -10.0),
1234            units_per_pixel: 1.0,
1235            zoom: 1.0,
1236        };
1237        assert_eq!(cam.effective_center(), [410.0, 290.0]);
1238        let fit = effective_fit(canvas_dp, world_size, &cam);
1239        let dp = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
1240        let back = dp_to_world(dp, world_size, cam.effective_center(), fit);
1241        assert!((back[0] - 400.0).abs() < 1e-4 && (back[1] - 300.0).abs() < 1e-4);
1242        cam.offset = Vec2::ZERO;
1243        let dp2 = world_to_dp([400.0, 300.0], world_size, cam.effective_center(), fit);
1244        // Offset 10 world units at scale 1 moves the drawing by 10 dp.
1245        assert!((dp[0] - dp2[0] + 10.0).abs() < 1e-4);
1246        assert!((dp[1] - dp2[1] - 10.0).abs() < 1e-4);
1247    }
1248
1249    #[test]
1250    fn limits_clamp_center_not_offset() {
1251        let limits = CameraLimits {
1252            left: 100.0,
1253            top: 100.0,
1254            right: 700.0,
1255            bottom: 500.0,
1256            enabled: true,
1257        };
1258        assert_eq!(apply_limits([50.0, 300.0], limits), [100.0, 300.0]);
1259        assert_eq!(apply_limits([400.0, 900.0], limits), [400.0, 500.0]);
1260        assert_eq!(apply_limits([400.0, 300.0], limits), [400.0, 300.0]);
1261        assert_eq!(
1262            apply_limits([50.0, 50.0], CameraLimits { enabled: false, ..limits }),
1263            [50.0, 50.0]
1264        );
1265    }
1266
1267    #[test]
1268    fn smoothing_converges_without_overshoot() {
1269        let target = [100.0, 0.0];
1270        let p1 = smooth_toward([0.0, 0.0], target, 5.0, 0.016);
1271        assert!(p1[0] > 0.0 && p1[0] < 100.0, "eases forward, got {p1:?}");
1272        let mut p = [0.0, 0.0];
1273        for _ in 0..600 {
1274            p = smooth_toward(p, target, 5.0, 0.016);
1275        }
1276        assert!((p[0] - 100.0).abs() < 1e-2, "settles, got {p:?}");
1277        assert_eq!(smooth_toward([1.0, 2.0], target, 0.0, 0.016), target);
1278        assert_eq!(smooth_toward([1.0, 2.0], target, 5.0, 0.0), [1.0, 2.0]);
1279    }
1280
1281    #[test]
1282    fn touch_gate_and_screen_space() {
1283        use repose_core::input::{
1284            Modifiers, PointerButton, PointerEvent, PointerEventKind, PointerId, PointerKind,
1285        };
1286        use repose_core::Vec2 as RVec2;
1287        let ev_of = |kind: PointerKind| {
1288            let mut ev = PointerEvent::new(
1289                PointerId(3),
1290                kind,
1291                PointerEventKind::Down(PointerButton::Primary),
1292                RVec2 { x: 10.0, y: 20.0 },
1293                1.0,
1294                Modifiers::default(),
1295            );
1296            ev.origin = RVec2 { x: 5.0, y: 7.0 };
1297            ev
1298        };
1299        // Touch + pen forward to touch zones; mouse stays on clicks.
1300        assert!(is_touch(&ev_of(PointerKind::Touch)));
1301        assert!(is_touch(&ev_of(PointerKind::Pen)));
1302        assert!(!is_touch(&ev_of(PointerKind::Mouse)));
1303        // Touch zones sample window-physical px (origin + position).
1304        assert_eq!(screen_of(&ev_of(PointerKind::Touch)), [15.0, 27.0]);
1305    }
1306}