Skip to main content

concinnity_render/
sprite.rs

1//! Sprite quad assembly. Piggybacks on the text render pass: a plain Sprite is
2//! emitted as a TextDrawCall containing a single quad with the sentinel UV
3//! (u < 0) the text shader interprets as a solid-coloured fill (alpha carried
4//! in v); a Sprite with a `texture` is emitted with real 0..1 UVs and a
5//! positive vertex `mode` so the shader samples the sprite's texture, which
6//! lives in the same atlas pool as the font atlases. Either way, screen-space
7//! rectangles need no pipeline of their own.
8
9use alloc::vec::Vec;
10use concinnity_core::math::sin_cos;
11
12use crate::components::{Sprite, SpriteFit};
13use crate::overlay_maps::{ClipRects, OverlayLayers, TextureSlots};
14use crate::render_types::{TextDrawCall, TextVertex};
15use concinnity_core::gfx::overlay::{OverlayTransform, UI_REFERENCE_SIZE};
16
17/// A view-owned sprite that spans the whole reference canvas is a full-screen
18/// backdrop (e.g. a menu dim): it is stretched to fill the live window rather
19/// than uniform-scaled, and an opaque one hides the scene behind it.
20pub fn covers_canvas(s: &Sprite) -> bool {
21    let [ref_w, ref_h] = UI_REFERENCE_SIZE;
22    s.screen.is_some()
23        && s.x <= 0.0
24        && s.y <= 0.0
25        && s.x + s.width >= ref_w
26        && s.y + s.height >= ref_h
27}
28
29// Build a TextDrawCall per visible Sprite. `default_atlas_slot` is the atlas
30// a solid-fill call binds (the shader does not sample for sentinel-UV verts,
31// but the backend still expects a valid slot). Pass the slot of any loaded
32// font; returns an empty list when there are no fonts (the text pipeline
33// isn't initialised in that case). `texture_slots` maps a Sprite's Texture
34// asset to its slot in the atlas pool; a textured sprite whose texture never
35// made it there falls back to a solid fill. `viewport` is the live logical
36// window size: view-owned sprites are overlay UI authored in the reference
37// canvas and are mapped onto the window so menus scale with it; HUD / scene
38// sprites (view == None) keep literal window pixels.
39#[cfg(test)]
40pub(crate) fn build_sprite_calls(
41    sprites: &[Sprite],
42    default_atlas_slot: Option<usize>,
43    texture_slots: &TextureSlots,
44    viewport: [f32; 2],
45    clips: &ClipRects,
46    layers: &OverlayLayers,
47) -> Vec<TextDrawCall> {
48    let mut out = crate::call_buffer::TextCallBuffer::default();
49    build_sprite_calls_into(
50        &mut out,
51        sprites,
52        default_atlas_slot,
53        texture_slots,
54        viewport,
55        clips,
56        layers,
57    );
58    out.take()
59}
60
61/// `build_sprite_calls`, appending onto an existing draw list so a caller
62/// assembling a frame from several element groups reuses one buffer (and, in
63/// steady state, the pooled geometry of the spent frame it recycled). A
64/// `follow_cursor` sprite is skipped: it is the cursor pass's silhouette
65/// source (see `cursor.rs`), not a scene quad.
66pub fn build_sprite_calls_into(
67    out: &mut crate::call_buffer::TextCallBuffer,
68    sprites: &[Sprite],
69    default_atlas_slot: Option<usize>,
70    texture_slots: &TextureSlots,
71    viewport: [f32; 2],
72    clips: &ClipRects,
73    layers: &OverlayLayers,
74) {
75    let fill_slot = match default_atlas_slot {
76        Some(s) => s,
77        None => return,
78    };
79    let overlay = OverlayTransform::from_viewport(viewport);
80    let cover = OverlayTransform::cover_from_viewport(viewport);
81    let bottom = OverlayTransform::bottom_anchored_from_viewport(viewport);
82    let [vw, vh] = viewport;
83    for s in sprites {
84        if !s.visible || s.follow_cursor {
85            continue;
86        }
87        let [r, g, b, a] = s.tint;
88        // A transparent fill still draws when a visible border is set: the
89        // border ring is the sprite (an outline, e.g. a selection highlight).
90        let border_only = s.border_width > 0.0 && s.border_color[3] > 0.0;
91        if a <= 0.0 && !border_only {
92            continue;
93        }
94        let (x0, y0, x1, y1) = if s.screen.is_some() {
95            if s.fit == SpriteFit::Cover {
96                // Full-bleed stage imagery: uniform fill, centered crop. The
97                // canvas edges map at or beyond the window edges, so edge-
98                // anchored content (a bottom-anchored portrait) stays flush.
99                let (ax, ay) = cover.forward(s.x, s.y);
100                let (bx, by) = cover.forward(s.x + s.width, s.y + s.height);
101                (ax, ay, bx, by)
102            } else if s.fit == SpriteFit::Bottom {
103                // Bottom-anchored furniture (a dialog box): fit scale, but
104                // pinned to the window bottom rather than the letterbox margin.
105                let (ax, ay) = bottom.forward(s.x, s.y);
106                let (bx, by) = bottom.forward(s.x + s.width, s.y + s.height);
107                (ax, ay, bx, by)
108            } else if covers_canvas(s) && vw > 0.0 && vh > 0.0 {
109                // A view-owned sprite spanning the whole reference canvas is a
110                // full-screen backdrop (e.g. a menu dim): always fill the live
111                // window instead of uniform-scaling, which would letterbox it.
112                (0.0, 0.0, vw, vh)
113            } else {
114                let (ax, ay) = overlay.forward(s.x, s.y);
115                let (bx, by) = overlay.forward(s.x + s.width, s.y + s.height);
116                (ax, ay, bx, by)
117            }
118        } else {
119            (s.x, s.y, s.x + s.width, s.y + s.height)
120        };
121        let texture_slot = s.texture.and_then(|t| texture_slots.get(&t).copied());
122        // UVs derive from the vertex position inside the rect so arbitrary
123        // boundary geometry (the rounded-corner path) samples correctly; for
124        // the plain quad this reproduces the 0..1 corner UVs exactly.
125        let (w, h) = ((x1 - x0).max(f32::EPSILON), (y1 - y0).max(f32::EPSILON));
126        let v = |x: f32, y: f32, alpha: f32| match texture_slot {
127            // Textured quad: real UVs, tint in color, alpha in mode.
128            Some(_) => TextVertex {
129                pos: [x, y],
130                uv: [(x - x0) / w, (y - y0) / h],
131                color: [r, g, b],
132                mode: alpha,
133            },
134            // Solid fill: sentinel u < 0, alpha carried in v.
135            None => TextVertex {
136                pos: [x, y],
137                uv: [-1.0, alpha],
138                color: [r, g, b],
139                mode: 0.0,
140            },
141        };
142        // The corner radius is authored in the sprite's own pixel space; map
143        // it through the same scale the rect took (the overlay transforms are
144        // uniform; the full-canvas stretch takes the smaller axis).
145        let scale = if s.width > 0.0 && s.height > 0.0 {
146            ((x1 - x0) / s.width).min((y1 - y0) / s.height)
147        } else {
148            1.0
149        };
150        let radius = (s.corner_radius * scale).min(w / 2.0).min(h / 2.0);
151        let border = (s.border_width * scale).min(w / 2.0).min(h / 2.0);
152        let (mut vertices, mut indices) = out.geometry();
153        if border > 0.5 && s.border_color[3] > 0.0 {
154            let [br, bg, bb, ba] = s.border_color;
155            let border_v = |x: f32, y: f32, alpha: f32| TextVertex {
156                pos: [x, y],
157                uv: [-1.0, alpha],
158                color: [br, bg, bb],
159                mode: 0.0,
160            };
161            if a <= 0.0 {
162                // A transparent fill cannot cover an outer quad (blending
163                // leaves the border colour showing through), so an outline
164                // is four edge strips with nothing inside.
165                ring_geometry(
166                    &mut vertices,
167                    &mut indices,
168                    [x0, y0, x1, y1],
169                    border,
170                    ba,
171                    border_v,
172                );
173            } else {
174                // Border stroke: an outer rounded rect in the border colour,
175                // with the tinted fill inset by the stroke width drawn on top
176                // so a ring of the border colour is left showing around it.
177                rect_geometry(
178                    &mut vertices,
179                    &mut indices,
180                    [x0, y0, x1, y1],
181                    radius,
182                    ba,
183                    border_v,
184                );
185                rect_geometry(
186                    &mut vertices,
187                    &mut indices,
188                    [x0 + border, y0 + border, x1 - border, y1 - border],
189                    (radius - border).max(0.0),
190                    a,
191                    v,
192                );
193            }
194        } else {
195            rect_geometry(&mut vertices, &mut indices, [x0, y0, x1, y1], radius, a, v);
196        }
197        out.calls.push(TextDrawCall {
198            vertices,
199            indices,
200            atlas_slot: texture_slot.unwrap_or(fill_slot),
201            clip_rect: clips
202                .get(&s.asset_id)
203                .map(|b| crate::text::band_to_window(&overlay, *b)),
204            layer: layers.get(&s.asset_id).copied().unwrap_or(0),
205        });
206    }
207}
208
209// Append a rectangle's geometry: a feathered rounded rect when the radius is
210// set, otherwise a plain two-triangle quad. Shared by the fill and the border
211// ring, which is why indices are rebased on the buffer's current length.
212// `rect` is `[x0, y0, x1, y1]`.
213fn rect_geometry(
214    vertices: &mut Vec<TextVertex>,
215    indices: &mut Vec<u16>,
216    rect: [f32; 4],
217    radius: f32,
218    alpha: f32,
219    mut v: impl FnMut(f32, f32, f32) -> TextVertex,
220) {
221    let [x0, y0, x1, y1] = rect;
222    if radius > 0.5 {
223        rounded_rect_geometry(vertices, indices, rect, radius, alpha, v);
224    } else {
225        let base = vertices.len() as u16;
226        vertices.extend_from_slice(&[
227            v(x0, y0, alpha),
228            v(x1, y0, alpha),
229            v(x1, y1, alpha),
230            v(x0, y1, alpha),
231        ]);
232        indices.extend([0, 1, 2, 0, 2, 3].map(|i| base + i));
233    }
234}
235
236// Append a hollow rectangle as four edge strips `width` wide inside `rect`
237// (`[x0, y0, x1, y1]`): the top and bottom span the full width, the sides
238// fill the gap between them.
239fn ring_geometry(
240    vertices: &mut Vec<TextVertex>,
241    indices: &mut Vec<u16>,
242    rect: [f32; 4],
243    width: f32,
244    alpha: f32,
245    mut v: impl FnMut(f32, f32, f32) -> TextVertex,
246) {
247    let [x0, y0, x1, y1] = rect;
248    let strips = [
249        [x0, y0, x1, y0 + width],
250        [x0, y1 - width, x1, y1],
251        [x0, y0 + width, x0 + width, y1 - width],
252        [x1 - width, y0 + width, x1, y1 - width],
253    ];
254    for strip in strips {
255        rect_geometry(vertices, indices, strip, 0.0, alpha, &mut v);
256    }
257}
258
259// Arc steps per rounded corner. Six segments keep a 10-15 px UI radius
260// visually smooth once the feathered edge blends the silhouette.
261const CORNER_SEGMENTS: usize = 6;
262// Width (window pixels) of the soft edge ring. The solid interior stops this
263// far inside the authored boundary and fades to transparent at it, so the
264// silhouette never grows past the authored rect.
265const EDGE_FEATHER: f32 = 1.25;
266
267// Tessellate a rounded rectangle in window space: a solid convex polygon
268// inset one feather width inside the authored boundary, fanned from its first
269// point, plus a fading ring out to the boundary for anti-aliasing. Vertex
270// alpha carries the fade (both sprite modes read per-vertex alpha).
271fn rounded_rect_geometry(
272    vertices: &mut Vec<TextVertex>,
273    indices: &mut Vec<u16>,
274    rect: [f32; 4],
275    radius: f32,
276    alpha: f32,
277    mut v: impl FnMut(f32, f32, f32) -> TextVertex,
278) {
279    use core::f32::consts::FRAC_PI_2;
280    let [x0, y0, x1, y1] = rect;
281    // Every corner sweeps the same quarter turn, so the unit offsets are
282    // evaluated once and each corner reuses them rotated into its quadrant.
283    let mut arc = [(0.0_f32, 0.0_f32); CORNER_SEGMENTS + 1];
284    for (i, slot) in arc.iter_mut().enumerate() {
285        let (s, c) = sin_cos((i as f32 / CORNER_SEGMENTS as f32) * FRAC_PI_2);
286        *slot = (c, s);
287    }
288    // Corner arc centers in polygon order, each with the quarter-turn count its
289    // arc starts at; y grows downward so the arcs sweep clockwise.
290    let corners = [
291        (x0 + radius, y0 + radius, 2),
292        (x1 - radius, y0 + radius, 3),
293        (x1 - radius, y1 - radius, 0),
294        (x0 + radius, y1 - radius, 1),
295    ];
296    let mut boundary = [(0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32); 4 * (CORNER_SEGMENTS + 1)];
297    for (c, &(cx, cy, quadrant)) in corners.iter().enumerate() {
298        for (i, &(uc, us)) in arc.iter().enumerate() {
299            let (rc, rs) = match quadrant {
300                0 => (uc, us),
301                1 => (-us, uc),
302                2 => (-uc, -us),
303                _ => (us, -uc),
304            };
305            boundary[c * (CORNER_SEGMENTS + 1) + i] = (cx, cy, rc, rs);
306        }
307    }
308    let m = boundary.len();
309    let base = vertices.len() as u16;
310    let inner_r = (radius - EDGE_FEATHER).max(0.0);
311    vertices.reserve(2 * m);
312    for &(cx, cy, cos, sin) in &boundary {
313        vertices.push(v(cx + inner_r * cos, cy + inner_r * sin, alpha));
314    }
315    for &(cx, cy, cos, sin) in &boundary {
316        vertices.push(v(cx + radius * cos, cy + radius * sin, 0.0));
317    }
318    indices.reserve(3 * (m - 2) + 6 * m);
319    for i in 1..m - 1 {
320        indices.extend([base, base + i as u16, base + (i + 1) as u16]);
321    }
322    for i in 0..m {
323        let j = (i + 1) % m;
324        let (i, j, m) = (i as u16, j as u16, m as u16);
325        indices.extend([i, j, m + j, i, m + j, m + i].map(|k| base + k));
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::ecs::TextureHandle;
333    use crate::ecs::asset_id::AssetId;
334
335    use alloc::vec;
336    fn no_clips() -> ClipRects {
337        ClipRects::new()
338    }
339    fn no_layers() -> OverlayLayers {
340        OverlayLayers::new()
341    }
342
343    fn no_slots() -> TextureSlots {
344        TextureSlots::new()
345    }
346
347    fn sprite(x: f32, y: f32, w: f32, h: f32, tint: [f32; 4]) -> Sprite {
348        Sprite {
349            asset_id: AssetId::default(),
350            x,
351            y,
352            width: w,
353            height: h,
354            texture: None,
355            tint,
356            follow_cursor: false,
357            visible: true,
358            screen: None,
359            fit: SpriteFit::Fit,
360            corner_radius: 0.0,
361            border_width: 0.0,
362            border_color: [0.0, 0.0, 0.0, 1.0],
363        }
364    }
365
366    #[test]
367    fn no_fonts_means_no_calls() {
368        let s = sprite(0.0, 0.0, 100.0, 100.0, [1.0, 0.0, 0.0, 1.0]);
369        assert!(
370            build_sprite_calls(
371                core::slice::from_ref(&s),
372                None,
373                &no_slots(),
374                [0.0, 0.0],
375                &no_clips(),
376                &no_layers()
377            )
378            .is_empty()
379        );
380    }
381
382    #[test]
383    fn visible_sprite_emits_quad_with_sentinel_uv() {
384        let s = sprite(10.0, 20.0, 100.0, 50.0, [0.5, 0.5, 0.5, 0.75]);
385        let calls = build_sprite_calls(
386            core::slice::from_ref(&s),
387            Some(0),
388            &no_slots(),
389            [0.0, 0.0],
390            &no_clips(),
391            &no_layers(),
392        );
393        assert_eq!(calls.len(), 1);
394        assert_eq!(calls[0].vertices.len(), 4);
395        assert_eq!(calls[0].indices, vec![0, 1, 2, 0, 2, 3]);
396        for v in &calls[0].vertices {
397            assert!(v.uv[0] < 0.0, "sentinel u should be negative");
398            assert!((v.uv[1] - 0.75).abs() < 1e-5, "alpha carried in v");
399            assert_eq!(v.color, [0.5, 0.5, 0.5]);
400        }
401        assert_eq!(calls[0].vertices[0].pos, [10.0, 20.0]);
402        assert_eq!(calls[0].vertices[2].pos, [110.0, 70.0]);
403    }
404
405    // A fully transparent fill normally skips the sprite, but a visible
406    // border keeps it: the border ring alone draws (an outline sprite).
407    #[test]
408    fn transparent_fill_draws_when_a_border_is_set() {
409        let invisible = sprite(0.0, 0.0, 100.0, 50.0, [0.0, 0.0, 0.0, 0.0]);
410        assert!(
411            build_sprite_calls(
412                core::slice::from_ref(&invisible),
413                Some(0),
414                &no_slots(),
415                [0.0, 0.0],
416                &no_clips(),
417                &no_layers()
418            )
419            .is_empty(),
420            "borderless transparent fill still skips"
421        );
422
423        let mut outline = sprite(0.0, 0.0, 100.0, 50.0, [0.0, 0.0, 0.0, 0.0]);
424        outline.border_width = 2.0;
425        outline.border_color = [0.2, 0.4, 0.9, 1.0];
426        let calls = build_sprite_calls(
427            core::slice::from_ref(&outline),
428            Some(0),
429            &no_slots(),
430            [0.0, 0.0],
431            &no_clips(),
432            &no_layers(),
433        );
434        assert_eq!(calls.len(), 1, "the border ring draws");
435        // Four edge strips, every vertex in the border colour at full alpha:
436        // nothing is drawn inside the ring, so the object shows through.
437        let verts = &calls[0].vertices;
438        assert_eq!(verts.len(), 16);
439        assert!(
440            verts
441                .iter()
442                .all(|v| v.color == [0.2, 0.4, 0.9] && v.uv == [-1.0, 1.0])
443        );
444        let inside = |x: f32, y: f32| {
445            calls[0].indices.chunks(3).any(|t| {
446                let p: Vec<[f32; 2]> = t.iter().map(|&i| verts[i as usize].pos).collect();
447                point_in_triangle([x, y], p[0], p[1], p[2])
448            })
449        };
450        assert!(inside(50.0, 1.0), "the top strip covers the edge");
451        assert!(inside(1.0, 25.0), "the left strip covers the edge");
452        assert!(!inside(50.0, 25.0), "the interior is empty");
453
454        // An opaque fill keeps the inset-fill stroke (rounded borders rely
455        // on it).
456        let mut panel = sprite(0.0, 0.0, 100.0, 50.0, [0.1, 0.1, 0.1, 1.0]);
457        panel.border_width = 2.0;
458        panel.border_color = [0.2, 0.4, 0.9, 1.0];
459        let calls = build_sprite_calls(
460            core::slice::from_ref(&panel),
461            Some(0),
462            &no_slots(),
463            [0.0, 0.0],
464            &no_clips(),
465            &no_layers(),
466        );
467        assert_eq!(calls[0].vertices.len(), 8);
468    }
469
470    fn point_in_triangle(p: [f32; 2], a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> bool {
471        let sign = |p: [f32; 2], q: [f32; 2], r: [f32; 2]| {
472            (p[0] - r[0]) * (q[1] - r[1]) - (q[0] - r[0]) * (p[1] - r[1])
473        };
474        let (d1, d2, d3) = (sign(p, a, b), sign(p, b, c), sign(p, c, a));
475        let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
476        let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
477        !(neg && pos)
478    }
479
480    #[test]
481    fn textured_sprite_emits_real_uvs_and_its_slot() {
482        let mut s = sprite(10.0, 20.0, 100.0, 50.0, [1.0, 0.9, 0.8, 0.75]);
483        s.texture = Some(TextureHandle(42));
484        let mut slots = no_slots();
485        slots.insert(TextureHandle(42), 3);
486        let calls = build_sprite_calls(
487            core::slice::from_ref(&s),
488            Some(0),
489            &slots,
490            [0.0, 0.0],
491            &no_clips(),
492            &no_layers(),
493        );
494        assert_eq!(calls.len(), 1);
495        // The call binds the sprite texture's atlas slot, not the font's.
496        assert_eq!(calls[0].atlas_slot, 3);
497        let vs = &calls[0].vertices;
498        assert_eq!(vs[0].uv, [0.0, 0.0]);
499        assert_eq!(vs[1].uv, [1.0, 0.0]);
500        assert_eq!(vs[2].uv, [1.0, 1.0]);
501        assert_eq!(vs[3].uv, [0.0, 1.0]);
502        for v in vs {
503            // Tint in color, alpha in the mode flag (> 0 = textured).
504            assert_eq!(v.color, [1.0, 0.9, 0.8]);
505            assert!((v.mode - 0.75).abs() < 1e-5);
506        }
507    }
508
509    #[test]
510    fn rounded_sprite_tessellates_with_a_feathered_edge() {
511        let mut s = sprite(100.0, 100.0, 400.0, 200.0, [0.1, 0.2, 0.3, 0.9]);
512        s.corner_radius = 20.0;
513        let calls = build_sprite_calls(
514            core::slice::from_ref(&s),
515            Some(0),
516            &no_slots(),
517            [0.0, 0.0],
518            &no_clips(),
519            &no_layers(),
520        );
521        assert_eq!(calls.len(), 1);
522        let vs = &calls[0].vertices;
523        // An inner solid ring and an outer transparent ring, 4 corner arcs of
524        // CORNER_SEGMENTS + 1 points each.
525        let ring = 4 * (CORNER_SEGMENTS + 1);
526        assert_eq!(vs.len(), 2 * ring);
527        for v in &vs[..ring] {
528            assert!(
529                (v.uv[1] - 0.9).abs() < 1e-5,
530                "inner ring carries the tint alpha"
531            );
532        }
533        for v in &vs[ring..] {
534            assert!(v.uv[1].abs() < 1e-5, "outer ring fades to transparent");
535        }
536        // Every vertex stays inside the authored rect, and the outer ring
537        // reaches the rect edges at the flat sides.
538        for v in vs {
539            assert!(v.pos[0] >= 100.0 - 1e-3 && v.pos[0] <= 500.0 + 1e-3);
540            assert!(v.pos[1] >= 100.0 - 1e-3 && v.pos[1] <= 300.0 + 1e-3);
541        }
542        let min_x = vs.iter().map(|v| v.pos[0]).fold(f32::MAX, f32::min);
543        assert!((min_x - 100.0).abs() < 1e-3);
544        // The corner point itself is never touched: the arc cuts it off.
545        assert!(!vs.iter().any(|v| v.pos == [100.0, 100.0]));
546    }
547
548    #[test]
549    fn bordered_sprite_emits_a_border_ring_and_an_inset_fill() {
550        let mut s = sprite(100.0, 100.0, 200.0, 120.0, [0.1, 0.2, 0.3, 1.0]);
551        s.border_width = 2.0;
552        s.border_color = [0.8, 0.4, 0.2, 1.0];
553        let calls = build_sprite_calls(
554            core::slice::from_ref(&s),
555            Some(0),
556            &no_slots(),
557            [0.0, 0.0],
558            &no_clips(),
559            &no_layers(),
560        );
561        assert_eq!(calls.len(), 1);
562        let vs = &calls[0].vertices;
563        // Both the border-coloured outer layer and the tinted fill are present.
564        assert!(
565            vs.iter().any(|v| v.color == [0.8, 0.4, 0.2]),
566            "border colour present"
567        );
568        assert!(
569            vs.iter().any(|v| v.color == [0.1, 0.2, 0.3]),
570            "fill colour present"
571        );
572        // The border reaches the authored outer edge; the tinted fill is inset
573        // by the stroke width on every side.
574        let outer_min_x = vs.iter().map(|v| v.pos[0]).fold(f32::MAX, f32::min);
575        let outer_max_x = vs.iter().map(|v| v.pos[0]).fold(f32::MIN, f32::max);
576        assert!(
577            (outer_min_x - 100.0).abs() < 1e-3,
578            "border at the left edge"
579        );
580        assert!(
581            (outer_max_x - 300.0).abs() < 1e-3,
582            "border at the right edge"
583        );
584        let fill_min_x = vs
585            .iter()
586            .filter(|v| v.color == [0.1, 0.2, 0.3])
587            .map(|v| v.pos[0])
588            .fold(f32::MAX, f32::min);
589        assert!(
590            (fill_min_x - 102.0).abs() < 1e-3,
591            "fill inset by the stroke width"
592        );
593    }
594
595    #[test]
596    fn zero_border_stays_a_single_layer() {
597        let mut s = sprite(0.0, 0.0, 100.0, 100.0, [0.2, 0.3, 0.4, 1.0]);
598        // A colour but no width draws no border (just the fill quad).
599        s.border_width = 0.0;
600        s.border_color = [1.0, 0.0, 0.0, 1.0];
601        let calls = build_sprite_calls(
602            core::slice::from_ref(&s),
603            Some(0),
604            &no_slots(),
605            [0.0, 0.0],
606            &no_clips(),
607            &no_layers(),
608        );
609        assert_eq!(
610            calls[0].vertices.len(),
611            4,
612            "one plain quad, no border layer"
613        );
614        assert!(calls[0].vertices.iter().all(|v| v.color == [0.2, 0.3, 0.4]));
615    }
616
617    #[test]
618    fn rounded_view_sprite_scales_its_radius_with_the_window() {
619        // A 2x window doubles the radius: the outer ring's leftmost point
620        // sits at the transformed rect's left edge, and the top-left corner
621        // arc starts (2 * radius) transformed pixels down from the rect top.
622        let mut s = sprite(100.0, 100.0, 400.0, 200.0, [0.1, 0.2, 0.3, 0.9]);
623        s.screen = Some(AssetId(7));
624        s.corner_radius = 20.0;
625        let calls = build_sprite_calls(
626            core::slice::from_ref(&s),
627            Some(0),
628            &no_slots(),
629            [2.0 * UI_REFERENCE_SIZE[0], 2.0 * UI_REFERENCE_SIZE[1]],
630            &no_clips(),
631            &no_layers(),
632        );
633        let vs = &calls[0].vertices;
634        let min_x = vs.iter().map(|v| v.pos[0]).fold(f32::MAX, f32::min);
635        let top_left_arc_y = vs
636            .iter()
637            .filter(|v| (v.pos[0] - min_x).abs() < 1e-3)
638            .map(|v| v.pos[1])
639            .fold(f32::MAX, f32::min);
640        assert!((min_x - 200.0).abs() < 1e-3);
641        assert!((top_left_arc_y - (200.0 + 40.0)).abs() < 1e-3);
642    }
643
644    #[test]
645    fn textured_sprite_without_a_loaded_texture_falls_back_to_fill() {
646        let mut s = sprite(0.0, 0.0, 10.0, 10.0, [0.2, 0.3, 0.4, 1.0]);
647        s.texture = Some(TextureHandle(42));
648        // The texture never made it into the atlas pool: solid-fill sentinel.
649        let calls = build_sprite_calls(
650            core::slice::from_ref(&s),
651            Some(5),
652            &no_slots(),
653            [0.0, 0.0],
654            &no_clips(),
655            &no_layers(),
656        );
657        assert_eq!(calls[0].atlas_slot, 5);
658        assert!(calls[0].vertices[0].uv[0] < 0.0);
659        assert_eq!(calls[0].vertices[0].mode, 0.0);
660    }
661
662    #[test]
663    fn invisible_sprite_is_skipped() {
664        let mut s = sprite(0.0, 0.0, 100.0, 100.0, [1.0, 1.0, 1.0, 1.0]);
665        s.visible = false;
666        assert!(
667            build_sprite_calls(
668                core::slice::from_ref(&s),
669                Some(0),
670                &no_slots(),
671                [0.0, 0.0],
672                &no_clips(),
673                &no_layers()
674            )
675            .is_empty()
676        );
677    }
678
679    #[test]
680    fn zero_alpha_sprite_is_skipped() {
681        let s = sprite(0.0, 0.0, 100.0, 100.0, [1.0, 1.0, 1.0, 0.0]);
682        assert!(
683            build_sprite_calls(
684                core::slice::from_ref(&s),
685                Some(0),
686                &no_slots(),
687                [0.0, 0.0],
688                &no_clips(),
689                &no_layers()
690            )
691            .is_empty()
692        );
693    }
694
695    #[test]
696    fn view_owned_sprite_scales_to_window() {
697        // A view-owned (overlay) sprite is authored in the reference canvas and
698        // uniformly scaled onto the window. At twice the reference size the
699        // rect doubles and stays centered.
700        let mut s = sprite(100.0, 100.0, 200.0, 100.0, [1.0, 1.0, 1.0, 1.0]);
701        s.screen = Some(AssetId(7));
702        let calls = build_sprite_calls(
703            core::slice::from_ref(&s),
704            Some(0),
705            &no_slots(),
706            [2560.0, 1440.0],
707            &no_clips(),
708            &no_layers(),
709        );
710        assert_eq!(calls.len(), 1);
711        assert_eq!(calls[0].vertices[0].pos, [200.0, 200.0]);
712        assert_eq!(calls[0].vertices[2].pos, [600.0, 400.0]);
713    }
714
715    #[test]
716    fn view_owned_full_canvas_backdrop_fills_window() {
717        // A view-owned sprite spanning the whole reference canvas is a
718        // full-screen backdrop: it fills the live window rather than letterboxing.
719        let mut s = sprite(0.0, 0.0, 1280.0, 720.0, [0.0, 0.0, 0.0, 0.5]);
720        s.screen = Some(AssetId(7));
721        let calls = build_sprite_calls(
722            core::slice::from_ref(&s),
723            Some(0),
724            &no_slots(),
725            [2560.0, 1440.0],
726            &no_clips(),
727            &no_layers(),
728        );
729        assert_eq!(calls.len(), 1);
730        assert_eq!(calls[0].vertices[0].pos, [0.0, 0.0]);
731        assert_eq!(calls[0].vertices[2].pos, [2560.0, 1440.0]);
732    }
733
734    #[test]
735    fn cover_sprite_fills_the_window_and_crops_the_overflow() {
736        // On a 4:3 window the 16:9 canvas covers by the height ratio
737        // (768/720): vertical edges land exactly on the window edges,
738        // horizontal overflow is cropped equally on both sides.
739        let mut s = sprite(0.0, 0.0, 1280.0, 720.0, [1.0, 1.0, 1.0, 1.0]);
740        s.screen = Some(AssetId(7));
741        s.fit = SpriteFit::Cover;
742        let calls = build_sprite_calls(
743            core::slice::from_ref(&s),
744            Some(0),
745            &no_slots(),
746            [1024.0, 768.0],
747            &no_clips(),
748            &no_layers(),
749        );
750        let scale = 768.0 / 720.0;
751        let overflow = (1280.0 * scale - 1024.0) / 2.0;
752        let vs = &calls[0].vertices;
753        assert!(
754            (vs[0].pos[0] - -overflow).abs() < 1e-3,
755            "x0={}",
756            vs[0].pos[0]
757        );
758        assert!((vs[0].pos[1]).abs() < 1e-3, "y0={}", vs[0].pos[1]);
759        assert!(
760            (vs[2].pos[0] - (1024.0 + overflow)).abs() < 1e-3,
761            "x1={}",
762            vs[2].pos[0]
763        );
764        assert!((vs[2].pos[1] - 768.0).abs() < 1e-3, "y1={}", vs[2].pos[1]);
765    }
766
767    #[test]
768    fn cover_sprite_anchored_to_the_canvas_bottom_stays_flush() {
769        // A bottom-anchored partial-canvas sprite (a character portrait): its
770        // bottom edge maps exactly to the window bottom on a window taller
771        // than the reference aspect.
772        let mut s = sprite(400.0, 100.0, 480.0, 620.0, [1.0, 1.0, 1.0, 1.0]);
773        s.screen = Some(AssetId(7));
774        s.fit = SpriteFit::Cover;
775        let calls = build_sprite_calls(
776            core::slice::from_ref(&s),
777            Some(0),
778            &no_slots(),
779            [1024.0, 768.0],
780            &no_clips(),
781            &no_layers(),
782        );
783        let bottom = calls[0].vertices[2].pos[1];
784        assert!((bottom - 768.0).abs() < 1e-3, "bottom={bottom}");
785    }
786
787    #[test]
788    fn view_less_sprite_keeps_literal_pixels() {
789        // A HUD / scene sprite (view == None) is never overlay-scaled.
790        let s = sprite(10.0, 20.0, 100.0, 50.0, [0.5, 0.5, 0.5, 1.0]);
791        let calls = build_sprite_calls(
792            core::slice::from_ref(&s),
793            Some(0),
794            &no_slots(),
795            [2560.0, 1440.0],
796            &no_clips(),
797            &no_layers(),
798        );
799        assert_eq!(calls[0].vertices[0].pos, [10.0, 20.0]);
800        assert_eq!(calls[0].vertices[2].pos, [110.0, 70.0]);
801    }
802
803    #[test]
804    fn clipped_element_carries_window_space_clip_rect() {
805        // A view-owned sprite whose id is in the clips map gets a clip_rect
806        // mapped from the reference-space band through the overlay; one not in
807        // the map stays unclipped.
808        let mut s = sprite(100.0, 100.0, 50.0, 50.0, [1.0, 1.0, 1.0, 1.0]);
809        s.asset_id = AssetId(7);
810        s.screen = Some(AssetId(1));
811        let mut clips = no_clips();
812        // Reference band [200,200] size [200,60] at a 2x viewport (1280x720 ->
813        // 2560x1440, scale 2 about the centre): forward(200,200)=(400,400),
814        // forward(400,260)=(800,520) -> clip [400,400,400,120].
815        clips.insert(AssetId(7), [200.0, 200.0, 200.0, 60.0]);
816        let calls = build_sprite_calls(
817            core::slice::from_ref(&s),
818            Some(0),
819            &no_slots(),
820            [2560.0, 1440.0],
821            &clips,
822            &no_layers(),
823        );
824        let clip = calls[0].clip_rect.expect("clipped sprite has a clip rect");
825        assert!((clip[0] - 400.0).abs() < 1e-3, "x={}", clip[0]);
826        assert!((clip[1] - 400.0).abs() < 1e-3, "y={}", clip[1]);
827        assert!((clip[2] - 400.0).abs() < 1e-3, "w={}", clip[2]);
828        assert!((clip[3] - 120.0).abs() < 1e-3, "h={}", clip[3]);
829
830        // A sprite not in the clips map is unclipped.
831        let mut other = sprite(0.0, 0.0, 10.0, 10.0, [1.0, 1.0, 1.0, 1.0]);
832        other.asset_id = AssetId(9);
833        other.screen = Some(AssetId(1));
834        let calls = build_sprite_calls(
835            core::slice::from_ref(&other),
836            Some(0),
837            &no_slots(),
838            [2560.0, 1440.0],
839            &clips,
840            &no_layers(),
841        );
842        assert!(calls[0].clip_rect.is_none());
843    }
844
845    // A sprite's call carries the draw layer its id maps to (used by the editor's
846    // panel occlusion sort); an id absent from the map draws at layer 0.
847    #[test]
848    fn sprite_call_takes_its_layer_from_the_map() {
849        let mut mapped = sprite(0.0, 0.0, 10.0, 10.0, [1.0, 1.0, 1.0, 1.0]);
850        mapped.asset_id = AssetId(42);
851        let mut layers = OverlayLayers::new();
852        layers.insert(AssetId(42), 7);
853        let calls = build_sprite_calls(
854            core::slice::from_ref(&mapped),
855            Some(0),
856            &no_slots(),
857            [100.0, 100.0],
858            &no_clips(),
859            &layers,
860        );
861        assert_eq!(calls[0].layer, 7);
862
863        let mut unmapped = sprite(0.0, 0.0, 10.0, 10.0, [1.0, 1.0, 1.0, 1.0]);
864        unmapped.asset_id = AssetId(99);
865        let calls = build_sprite_calls(
866            core::slice::from_ref(&unmapped),
867            Some(0),
868            &no_slots(),
869            [100.0, 100.0],
870            &no_clips(),
871            &layers,
872        );
873        assert_eq!(calls[0].layer, 0, "an unmapped id is layer 0");
874    }
875
876    // A `follow_cursor` sprite is the cursor pass's silhouette source, never a
877    // scene quad, so the whole component slice can be passed in.
878    #[test]
879    fn follow_cursor_sprites_are_skipped() {
880        let mut s = sprite(0.0, 0.0, 10.0, 10.0, [1.0, 1.0, 1.0, 1.0]);
881        s.follow_cursor = true;
882        assert!(
883            build_sprite_calls(
884                core::slice::from_ref(&s),
885                Some(0),
886                &no_slots(),
887                [0.0, 0.0],
888                &no_clips(),
889                &no_layers()
890            )
891            .is_empty()
892        );
893    }
894}