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::{cos, sin};
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, PI};
280    let [x0, y0, x1, y1] = rect;
281    // Corner arc centers in polygon order, each with its start angle; y grows
282    // downward so the arcs sweep clockwise around the boundary.
283    let corners = [
284        (x0 + radius, y0 + radius, PI),
285        (x1 - radius, y0 + radius, 1.5 * PI),
286        (x1 - radius, y1 - radius, 0.0),
287        (x0 + radius, y1 - radius, FRAC_PI_2),
288    ];
289    let mut boundary = [(0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32); 4 * (CORNER_SEGMENTS + 1)];
290    for (c, &(cx, cy, start)) in corners.iter().enumerate() {
291        for i in 0..=CORNER_SEGMENTS {
292            let t = start + (i as f32 / CORNER_SEGMENTS as f32) * FRAC_PI_2;
293            boundary[c * (CORNER_SEGMENTS + 1) + i] = (cx, cy, cos(t), sin(t));
294        }
295    }
296    let m = boundary.len();
297    let base = vertices.len() as u16;
298    let inner_r = (radius - EDGE_FEATHER).max(0.0);
299    vertices.reserve(2 * m);
300    for &(cx, cy, cos, sin) in &boundary {
301        vertices.push(v(cx + inner_r * cos, cy + inner_r * sin, alpha));
302    }
303    for &(cx, cy, cos, sin) in &boundary {
304        vertices.push(v(cx + radius * cos, cy + radius * sin, 0.0));
305    }
306    indices.reserve(3 * (m - 2) + 6 * m);
307    for i in 1..m - 1 {
308        indices.extend([base, base + i as u16, base + (i + 1) as u16]);
309    }
310    for i in 0..m {
311        let j = (i + 1) % m;
312        let (i, j, m) = (i as u16, j as u16, m as u16);
313        indices.extend([i, j, m + j, i, m + j, m + i].map(|k| base + k));
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::ecs::TextureHandle;
321    use crate::ecs::asset_id::AssetId;
322
323    use alloc::vec;
324    fn no_clips() -> ClipRects {
325        ClipRects::new()
326    }
327    fn no_layers() -> OverlayLayers {
328        OverlayLayers::new()
329    }
330
331    fn no_slots() -> TextureSlots {
332        TextureSlots::new()
333    }
334
335    fn sprite(x: f32, y: f32, w: f32, h: f32, tint: [f32; 4]) -> Sprite {
336        Sprite {
337            asset_id: AssetId::default(),
338            x,
339            y,
340            width: w,
341            height: h,
342            texture: None,
343            tint,
344            follow_cursor: false,
345            visible: true,
346            screen: None,
347            fit: SpriteFit::Fit,
348            corner_radius: 0.0,
349            border_width: 0.0,
350            border_color: [0.0, 0.0, 0.0, 1.0],
351        }
352    }
353
354    #[test]
355    fn no_fonts_means_no_calls() {
356        let s = sprite(0.0, 0.0, 100.0, 100.0, [1.0, 0.0, 0.0, 1.0]);
357        assert!(
358            build_sprite_calls(
359                core::slice::from_ref(&s),
360                None,
361                &no_slots(),
362                [0.0, 0.0],
363                &no_clips(),
364                &no_layers()
365            )
366            .is_empty()
367        );
368    }
369
370    #[test]
371    fn visible_sprite_emits_quad_with_sentinel_uv() {
372        let s = sprite(10.0, 20.0, 100.0, 50.0, [0.5, 0.5, 0.5, 0.75]);
373        let calls = build_sprite_calls(
374            core::slice::from_ref(&s),
375            Some(0),
376            &no_slots(),
377            [0.0, 0.0],
378            &no_clips(),
379            &no_layers(),
380        );
381        assert_eq!(calls.len(), 1);
382        assert_eq!(calls[0].vertices.len(), 4);
383        assert_eq!(calls[0].indices, vec![0, 1, 2, 0, 2, 3]);
384        for v in &calls[0].vertices {
385            assert!(v.uv[0] < 0.0, "sentinel u should be negative");
386            assert!((v.uv[1] - 0.75).abs() < 1e-5, "alpha carried in v");
387            assert_eq!(v.color, [0.5, 0.5, 0.5]);
388        }
389        assert_eq!(calls[0].vertices[0].pos, [10.0, 20.0]);
390        assert_eq!(calls[0].vertices[2].pos, [110.0, 70.0]);
391    }
392
393    // A fully transparent fill normally skips the sprite, but a visible
394    // border keeps it: the border ring alone draws (an outline sprite).
395    #[test]
396    fn transparent_fill_draws_when_a_border_is_set() {
397        let invisible = sprite(0.0, 0.0, 100.0, 50.0, [0.0, 0.0, 0.0, 0.0]);
398        assert!(
399            build_sprite_calls(
400                core::slice::from_ref(&invisible),
401                Some(0),
402                &no_slots(),
403                [0.0, 0.0],
404                &no_clips(),
405                &no_layers()
406            )
407            .is_empty(),
408            "borderless transparent fill still skips"
409        );
410
411        let mut outline = sprite(0.0, 0.0, 100.0, 50.0, [0.0, 0.0, 0.0, 0.0]);
412        outline.border_width = 2.0;
413        outline.border_color = [0.2, 0.4, 0.9, 1.0];
414        let calls = build_sprite_calls(
415            core::slice::from_ref(&outline),
416            Some(0),
417            &no_slots(),
418            [0.0, 0.0],
419            &no_clips(),
420            &no_layers(),
421        );
422        assert_eq!(calls.len(), 1, "the border ring draws");
423        // Four edge strips, every vertex in the border colour at full alpha:
424        // nothing is drawn inside the ring, so the object shows through.
425        let verts = &calls[0].vertices;
426        assert_eq!(verts.len(), 16);
427        assert!(
428            verts
429                .iter()
430                .all(|v| v.color == [0.2, 0.4, 0.9] && v.uv == [-1.0, 1.0])
431        );
432        let inside = |x: f32, y: f32| {
433            calls[0].indices.chunks(3).any(|t| {
434                let p: Vec<[f32; 2]> = t.iter().map(|&i| verts[i as usize].pos).collect();
435                point_in_triangle([x, y], p[0], p[1], p[2])
436            })
437        };
438        assert!(inside(50.0, 1.0), "the top strip covers the edge");
439        assert!(inside(1.0, 25.0), "the left strip covers the edge");
440        assert!(!inside(50.0, 25.0), "the interior is empty");
441
442        // An opaque fill keeps the inset-fill stroke (rounded borders rely
443        // on it).
444        let mut panel = sprite(0.0, 0.0, 100.0, 50.0, [0.1, 0.1, 0.1, 1.0]);
445        panel.border_width = 2.0;
446        panel.border_color = [0.2, 0.4, 0.9, 1.0];
447        let calls = build_sprite_calls(
448            core::slice::from_ref(&panel),
449            Some(0),
450            &no_slots(),
451            [0.0, 0.0],
452            &no_clips(),
453            &no_layers(),
454        );
455        assert_eq!(calls[0].vertices.len(), 8);
456    }
457
458    fn point_in_triangle(p: [f32; 2], a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> bool {
459        let sign = |p: [f32; 2], q: [f32; 2], r: [f32; 2]| {
460            (p[0] - r[0]) * (q[1] - r[1]) - (q[0] - r[0]) * (p[1] - r[1])
461        };
462        let (d1, d2, d3) = (sign(p, a, b), sign(p, b, c), sign(p, c, a));
463        let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
464        let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
465        !(neg && pos)
466    }
467
468    #[test]
469    fn textured_sprite_emits_real_uvs_and_its_slot() {
470        let mut s = sprite(10.0, 20.0, 100.0, 50.0, [1.0, 0.9, 0.8, 0.75]);
471        s.texture = Some(TextureHandle(42));
472        let mut slots = no_slots();
473        slots.insert(TextureHandle(42), 3);
474        let calls = build_sprite_calls(
475            core::slice::from_ref(&s),
476            Some(0),
477            &slots,
478            [0.0, 0.0],
479            &no_clips(),
480            &no_layers(),
481        );
482        assert_eq!(calls.len(), 1);
483        // The call binds the sprite texture's atlas slot, not the font's.
484        assert_eq!(calls[0].atlas_slot, 3);
485        let vs = &calls[0].vertices;
486        assert_eq!(vs[0].uv, [0.0, 0.0]);
487        assert_eq!(vs[1].uv, [1.0, 0.0]);
488        assert_eq!(vs[2].uv, [1.0, 1.0]);
489        assert_eq!(vs[3].uv, [0.0, 1.0]);
490        for v in vs {
491            // Tint in color, alpha in the mode flag (> 0 = textured).
492            assert_eq!(v.color, [1.0, 0.9, 0.8]);
493            assert!((v.mode - 0.75).abs() < 1e-5);
494        }
495    }
496
497    #[test]
498    fn rounded_sprite_tessellates_with_a_feathered_edge() {
499        let mut s = sprite(100.0, 100.0, 400.0, 200.0, [0.1, 0.2, 0.3, 0.9]);
500        s.corner_radius = 20.0;
501        let calls = build_sprite_calls(
502            core::slice::from_ref(&s),
503            Some(0),
504            &no_slots(),
505            [0.0, 0.0],
506            &no_clips(),
507            &no_layers(),
508        );
509        assert_eq!(calls.len(), 1);
510        let vs = &calls[0].vertices;
511        // An inner solid ring and an outer transparent ring, 4 corner arcs of
512        // CORNER_SEGMENTS + 1 points each.
513        let ring = 4 * (CORNER_SEGMENTS + 1);
514        assert_eq!(vs.len(), 2 * ring);
515        for v in &vs[..ring] {
516            assert!(
517                (v.uv[1] - 0.9).abs() < 1e-5,
518                "inner ring carries the tint alpha"
519            );
520        }
521        for v in &vs[ring..] {
522            assert!(v.uv[1].abs() < 1e-5, "outer ring fades to transparent");
523        }
524        // Every vertex stays inside the authored rect, and the outer ring
525        // reaches the rect edges at the flat sides.
526        for v in vs {
527            assert!(v.pos[0] >= 100.0 - 1e-3 && v.pos[0] <= 500.0 + 1e-3);
528            assert!(v.pos[1] >= 100.0 - 1e-3 && v.pos[1] <= 300.0 + 1e-3);
529        }
530        let min_x = vs.iter().map(|v| v.pos[0]).fold(f32::MAX, f32::min);
531        assert!((min_x - 100.0).abs() < 1e-3);
532        // The corner point itself is never touched: the arc cuts it off.
533        assert!(!vs.iter().any(|v| v.pos == [100.0, 100.0]));
534    }
535
536    #[test]
537    fn bordered_sprite_emits_a_border_ring_and_an_inset_fill() {
538        let mut s = sprite(100.0, 100.0, 200.0, 120.0, [0.1, 0.2, 0.3, 1.0]);
539        s.border_width = 2.0;
540        s.border_color = [0.8, 0.4, 0.2, 1.0];
541        let calls = build_sprite_calls(
542            core::slice::from_ref(&s),
543            Some(0),
544            &no_slots(),
545            [0.0, 0.0],
546            &no_clips(),
547            &no_layers(),
548        );
549        assert_eq!(calls.len(), 1);
550        let vs = &calls[0].vertices;
551        // Both the border-coloured outer layer and the tinted fill are present.
552        assert!(
553            vs.iter().any(|v| v.color == [0.8, 0.4, 0.2]),
554            "border colour present"
555        );
556        assert!(
557            vs.iter().any(|v| v.color == [0.1, 0.2, 0.3]),
558            "fill colour present"
559        );
560        // The border reaches the authored outer edge; the tinted fill is inset
561        // by the stroke width on every side.
562        let outer_min_x = vs.iter().map(|v| v.pos[0]).fold(f32::MAX, f32::min);
563        let outer_max_x = vs.iter().map(|v| v.pos[0]).fold(f32::MIN, f32::max);
564        assert!(
565            (outer_min_x - 100.0).abs() < 1e-3,
566            "border at the left edge"
567        );
568        assert!(
569            (outer_max_x - 300.0).abs() < 1e-3,
570            "border at the right edge"
571        );
572        let fill_min_x = vs
573            .iter()
574            .filter(|v| v.color == [0.1, 0.2, 0.3])
575            .map(|v| v.pos[0])
576            .fold(f32::MAX, f32::min);
577        assert!(
578            (fill_min_x - 102.0).abs() < 1e-3,
579            "fill inset by the stroke width"
580        );
581    }
582
583    #[test]
584    fn zero_border_stays_a_single_layer() {
585        let mut s = sprite(0.0, 0.0, 100.0, 100.0, [0.2, 0.3, 0.4, 1.0]);
586        // A colour but no width draws no border (just the fill quad).
587        s.border_width = 0.0;
588        s.border_color = [1.0, 0.0, 0.0, 1.0];
589        let calls = build_sprite_calls(
590            core::slice::from_ref(&s),
591            Some(0),
592            &no_slots(),
593            [0.0, 0.0],
594            &no_clips(),
595            &no_layers(),
596        );
597        assert_eq!(
598            calls[0].vertices.len(),
599            4,
600            "one plain quad, no border layer"
601        );
602        assert!(calls[0].vertices.iter().all(|v| v.color == [0.2, 0.3, 0.4]));
603    }
604
605    #[test]
606    fn rounded_view_sprite_scales_its_radius_with_the_window() {
607        // A 2x window doubles the radius: the outer ring's leftmost point
608        // sits at the transformed rect's left edge, and the top-left corner
609        // arc starts (2 * radius) transformed pixels down from the rect top.
610        let mut s = sprite(100.0, 100.0, 400.0, 200.0, [0.1, 0.2, 0.3, 0.9]);
611        s.screen = Some(AssetId(7));
612        s.corner_radius = 20.0;
613        let calls = build_sprite_calls(
614            core::slice::from_ref(&s),
615            Some(0),
616            &no_slots(),
617            [2.0 * UI_REFERENCE_SIZE[0], 2.0 * UI_REFERENCE_SIZE[1]],
618            &no_clips(),
619            &no_layers(),
620        );
621        let vs = &calls[0].vertices;
622        let min_x = vs.iter().map(|v| v.pos[0]).fold(f32::MAX, f32::min);
623        let top_left_arc_y = vs
624            .iter()
625            .filter(|v| (v.pos[0] - min_x).abs() < 1e-3)
626            .map(|v| v.pos[1])
627            .fold(f32::MAX, f32::min);
628        assert!((min_x - 200.0).abs() < 1e-3);
629        assert!((top_left_arc_y - (200.0 + 40.0)).abs() < 1e-3);
630    }
631
632    #[test]
633    fn textured_sprite_without_a_loaded_texture_falls_back_to_fill() {
634        let mut s = sprite(0.0, 0.0, 10.0, 10.0, [0.2, 0.3, 0.4, 1.0]);
635        s.texture = Some(TextureHandle(42));
636        // The texture never made it into the atlas pool: solid-fill sentinel.
637        let calls = build_sprite_calls(
638            core::slice::from_ref(&s),
639            Some(5),
640            &no_slots(),
641            [0.0, 0.0],
642            &no_clips(),
643            &no_layers(),
644        );
645        assert_eq!(calls[0].atlas_slot, 5);
646        assert!(calls[0].vertices[0].uv[0] < 0.0);
647        assert_eq!(calls[0].vertices[0].mode, 0.0);
648    }
649
650    #[test]
651    fn invisible_sprite_is_skipped() {
652        let mut s = sprite(0.0, 0.0, 100.0, 100.0, [1.0, 1.0, 1.0, 1.0]);
653        s.visible = false;
654        assert!(
655            build_sprite_calls(
656                core::slice::from_ref(&s),
657                Some(0),
658                &no_slots(),
659                [0.0, 0.0],
660                &no_clips(),
661                &no_layers()
662            )
663            .is_empty()
664        );
665    }
666
667    #[test]
668    fn zero_alpha_sprite_is_skipped() {
669        let s = sprite(0.0, 0.0, 100.0, 100.0, [1.0, 1.0, 1.0, 0.0]);
670        assert!(
671            build_sprite_calls(
672                core::slice::from_ref(&s),
673                Some(0),
674                &no_slots(),
675                [0.0, 0.0],
676                &no_clips(),
677                &no_layers()
678            )
679            .is_empty()
680        );
681    }
682
683    #[test]
684    fn view_owned_sprite_scales_to_window() {
685        // A view-owned (overlay) sprite is authored in the reference canvas and
686        // uniformly scaled onto the window. At twice the reference size the
687        // rect doubles and stays centered.
688        let mut s = sprite(100.0, 100.0, 200.0, 100.0, [1.0, 1.0, 1.0, 1.0]);
689        s.screen = Some(AssetId(7));
690        let calls = build_sprite_calls(
691            core::slice::from_ref(&s),
692            Some(0),
693            &no_slots(),
694            [2560.0, 1440.0],
695            &no_clips(),
696            &no_layers(),
697        );
698        assert_eq!(calls.len(), 1);
699        assert_eq!(calls[0].vertices[0].pos, [200.0, 200.0]);
700        assert_eq!(calls[0].vertices[2].pos, [600.0, 400.0]);
701    }
702
703    #[test]
704    fn view_owned_full_canvas_backdrop_fills_window() {
705        // A view-owned sprite spanning the whole reference canvas is a
706        // full-screen backdrop: it fills the live window rather than letterboxing.
707        let mut s = sprite(0.0, 0.0, 1280.0, 720.0, [0.0, 0.0, 0.0, 0.5]);
708        s.screen = Some(AssetId(7));
709        let calls = build_sprite_calls(
710            core::slice::from_ref(&s),
711            Some(0),
712            &no_slots(),
713            [2560.0, 1440.0],
714            &no_clips(),
715            &no_layers(),
716        );
717        assert_eq!(calls.len(), 1);
718        assert_eq!(calls[0].vertices[0].pos, [0.0, 0.0]);
719        assert_eq!(calls[0].vertices[2].pos, [2560.0, 1440.0]);
720    }
721
722    #[test]
723    fn cover_sprite_fills_the_window_and_crops_the_overflow() {
724        // On a 4:3 window the 16:9 canvas covers by the height ratio
725        // (768/720): vertical edges land exactly on the window edges,
726        // horizontal overflow is cropped equally on both sides.
727        let mut s = sprite(0.0, 0.0, 1280.0, 720.0, [1.0, 1.0, 1.0, 1.0]);
728        s.screen = Some(AssetId(7));
729        s.fit = SpriteFit::Cover;
730        let calls = build_sprite_calls(
731            core::slice::from_ref(&s),
732            Some(0),
733            &no_slots(),
734            [1024.0, 768.0],
735            &no_clips(),
736            &no_layers(),
737        );
738        let scale = 768.0 / 720.0;
739        let overflow = (1280.0 * scale - 1024.0) / 2.0;
740        let vs = &calls[0].vertices;
741        assert!(
742            (vs[0].pos[0] - -overflow).abs() < 1e-3,
743            "x0={}",
744            vs[0].pos[0]
745        );
746        assert!((vs[0].pos[1]).abs() < 1e-3, "y0={}", vs[0].pos[1]);
747        assert!(
748            (vs[2].pos[0] - (1024.0 + overflow)).abs() < 1e-3,
749            "x1={}",
750            vs[2].pos[0]
751        );
752        assert!((vs[2].pos[1] - 768.0).abs() < 1e-3, "y1={}", vs[2].pos[1]);
753    }
754
755    #[test]
756    fn cover_sprite_anchored_to_the_canvas_bottom_stays_flush() {
757        // A bottom-anchored partial-canvas sprite (a character portrait): its
758        // bottom edge maps exactly to the window bottom on a window taller
759        // than the reference aspect.
760        let mut s = sprite(400.0, 100.0, 480.0, 620.0, [1.0, 1.0, 1.0, 1.0]);
761        s.screen = Some(AssetId(7));
762        s.fit = SpriteFit::Cover;
763        let calls = build_sprite_calls(
764            core::slice::from_ref(&s),
765            Some(0),
766            &no_slots(),
767            [1024.0, 768.0],
768            &no_clips(),
769            &no_layers(),
770        );
771        let bottom = calls[0].vertices[2].pos[1];
772        assert!((bottom - 768.0).abs() < 1e-3, "bottom={bottom}");
773    }
774
775    #[test]
776    fn view_less_sprite_keeps_literal_pixels() {
777        // A HUD / scene sprite (view == None) is never overlay-scaled.
778        let s = sprite(10.0, 20.0, 100.0, 50.0, [0.5, 0.5, 0.5, 1.0]);
779        let calls = build_sprite_calls(
780            core::slice::from_ref(&s),
781            Some(0),
782            &no_slots(),
783            [2560.0, 1440.0],
784            &no_clips(),
785            &no_layers(),
786        );
787        assert_eq!(calls[0].vertices[0].pos, [10.0, 20.0]);
788        assert_eq!(calls[0].vertices[2].pos, [110.0, 70.0]);
789    }
790
791    #[test]
792    fn clipped_element_carries_window_space_clip_rect() {
793        // A view-owned sprite whose id is in the clips map gets a clip_rect
794        // mapped from the reference-space band through the overlay; one not in
795        // the map stays unclipped.
796        let mut s = sprite(100.0, 100.0, 50.0, 50.0, [1.0, 1.0, 1.0, 1.0]);
797        s.asset_id = AssetId(7);
798        s.screen = Some(AssetId(1));
799        let mut clips = no_clips();
800        // Reference band [200,200] size [200,60] at a 2x viewport (1280x720 ->
801        // 2560x1440, scale 2 about the centre): forward(200,200)=(400,400),
802        // forward(400,260)=(800,520) -> clip [400,400,400,120].
803        clips.insert(AssetId(7), [200.0, 200.0, 200.0, 60.0]);
804        let calls = build_sprite_calls(
805            core::slice::from_ref(&s),
806            Some(0),
807            &no_slots(),
808            [2560.0, 1440.0],
809            &clips,
810            &no_layers(),
811        );
812        let clip = calls[0].clip_rect.expect("clipped sprite has a clip rect");
813        assert!((clip[0] - 400.0).abs() < 1e-3, "x={}", clip[0]);
814        assert!((clip[1] - 400.0).abs() < 1e-3, "y={}", clip[1]);
815        assert!((clip[2] - 400.0).abs() < 1e-3, "w={}", clip[2]);
816        assert!((clip[3] - 120.0).abs() < 1e-3, "h={}", clip[3]);
817
818        // A sprite not in the clips map is unclipped.
819        let mut other = sprite(0.0, 0.0, 10.0, 10.0, [1.0, 1.0, 1.0, 1.0]);
820        other.asset_id = AssetId(9);
821        other.screen = Some(AssetId(1));
822        let calls = build_sprite_calls(
823            core::slice::from_ref(&other),
824            Some(0),
825            &no_slots(),
826            [2560.0, 1440.0],
827            &clips,
828            &no_layers(),
829        );
830        assert!(calls[0].clip_rect.is_none());
831    }
832
833    // A sprite's call carries the draw layer its id maps to (used by the editor's
834    // panel occlusion sort); an id absent from the map draws at layer 0.
835    #[test]
836    fn sprite_call_takes_its_layer_from_the_map() {
837        let mut mapped = sprite(0.0, 0.0, 10.0, 10.0, [1.0, 1.0, 1.0, 1.0]);
838        mapped.asset_id = AssetId(42);
839        let mut layers = OverlayLayers::new();
840        layers.insert(AssetId(42), 7);
841        let calls = build_sprite_calls(
842            core::slice::from_ref(&mapped),
843            Some(0),
844            &no_slots(),
845            [100.0, 100.0],
846            &no_clips(),
847            &layers,
848        );
849        assert_eq!(calls[0].layer, 7);
850
851        let mut unmapped = sprite(0.0, 0.0, 10.0, 10.0, [1.0, 1.0, 1.0, 1.0]);
852        unmapped.asset_id = AssetId(99);
853        let calls = build_sprite_calls(
854            core::slice::from_ref(&unmapped),
855            Some(0),
856            &no_slots(),
857            [100.0, 100.0],
858            &no_clips(),
859            &layers,
860        );
861        assert_eq!(calls[0].layer, 0, "an unmapped id is layer 0");
862    }
863
864    // A `follow_cursor` sprite is the cursor pass's silhouette source, never a
865    // scene quad, so the whole component slice can be passed in.
866    #[test]
867    fn follow_cursor_sprites_are_skipped() {
868        let mut s = sprite(0.0, 0.0, 10.0, 10.0, [1.0, 1.0, 1.0, 1.0]);
869        s.follow_cursor = true;
870        assert!(
871            build_sprite_calls(
872                core::slice::from_ref(&s),
873                Some(0),
874                &no_slots(),
875                [0.0, 0.0],
876                &no_clips(),
877                &no_layers()
878            )
879            .is_empty()
880        );
881    }
882}