Skip to main content

concinnity_core/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 crate::math::sin_cos;
10use alloc::vec::Vec;
11
12use crate::components::{Sprite, SpriteFit};
13use crate::gfx::overlay::{OverlayTransform, UI_REFERENCE_SIZE};
14use crate::gfx::render_types::{TextDrawCall, TextVertex};
15use crate::render::overlay_maps::{ClipRects, OverlayLayers, TextureSlots};
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::render::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::render::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 a hollow stroke with nothing inside.
165                stroke_geometry(
166                    &mut vertices,
167                    &mut indices,
168                    [x0, y0, x1, y1],
169                    radius,
170                    border,
171                    ba,
172                    border_v,
173                );
174            } else if a < 1.0 {
175                // A translucent fill cannot hide an outer rect drawn under it,
176                // which would read as a panel in the border colour rather than
177                // as the world showing through. The fill takes the whole rect
178                // and the stroke is a hollow ring laid over its edge.
179                rect_geometry(&mut vertices, &mut indices, [x0, y0, x1, y1], radius, a, v);
180                stroke_geometry(
181                    &mut vertices,
182                    &mut indices,
183                    [x0, y0, x1, y1],
184                    radius,
185                    border,
186                    ba,
187                    border_v,
188                );
189            } else {
190                // Border stroke under an opaque fill: an outer rounded rect in
191                // the border colour, with the fill inset by the stroke width
192                // drawn on top so a ring of the border colour is left showing.
193                rect_geometry(
194                    &mut vertices,
195                    &mut indices,
196                    [x0, y0, x1, y1],
197                    radius,
198                    ba,
199                    border_v,
200                );
201                rect_geometry(
202                    &mut vertices,
203                    &mut indices,
204                    [x0 + border, y0 + border, x1 - border, y1 - border],
205                    (radius - border).max(0.0),
206                    a,
207                    v,
208                );
209            }
210        } else {
211            rect_geometry(&mut vertices, &mut indices, [x0, y0, x1, y1], radius, a, v);
212        }
213        out.calls.push(TextDrawCall {
214            vertices,
215            indices,
216            atlas_slot: texture_slot.unwrap_or(fill_slot),
217            clip_rect: clips
218                .get(&s.asset_id)
219                .map(|b| crate::render::text::band_to_window(&overlay, *b)),
220            layer: layers.get(&s.asset_id).copied().unwrap_or(0),
221        });
222    }
223}
224
225// Append a rectangle's geometry: a feathered rounded rect when the radius is
226// set, otherwise a plain two-triangle quad. Shared by the fill and the border
227// ring, which is why indices are rebased on the buffer's current length.
228// `rect` is `[x0, y0, x1, y1]`.
229fn rect_geometry(
230    vertices: &mut Vec<TextVertex>,
231    indices: &mut Vec<u16>,
232    rect: [f32; 4],
233    radius: f32,
234    alpha: f32,
235    mut v: impl FnMut(f32, f32, f32) -> TextVertex,
236) {
237    let [x0, y0, x1, y1] = rect;
238    if radius > 0.5 {
239        rounded_rect_geometry(vertices, indices, rect, radius, alpha, v);
240    } else {
241        let base = vertices.len() as u16;
242        vertices.extend_from_slice(&[
243            v(x0, y0, alpha),
244            v(x1, y0, alpha),
245            v(x1, y1, alpha),
246            v(x0, y1, alpha),
247        ]);
248        indices.extend([0, 1, 2, 0, 2, 3].map(|i| base + i));
249    }
250}
251
252// Append a border stroke `width` wide inside `rect`, hollow so whatever is
253// under it shows through: a ring following the fill's rounded silhouette, or
254// four straight strips for a square-cornered sprite.
255fn stroke_geometry(
256    vertices: &mut Vec<TextVertex>,
257    indices: &mut Vec<u16>,
258    rect: [f32; 4],
259    radius: f32,
260    width: f32,
261    alpha: f32,
262    v: impl FnMut(f32, f32, f32) -> TextVertex,
263) {
264    match radius > 0.5 {
265        true => rounded_ring_geometry(vertices, indices, rect, radius, width, alpha, v),
266        false => ring_geometry(vertices, indices, rect, width, alpha, v),
267    }
268}
269
270// Append a hollow rectangle as four edge strips `width` wide inside `rect`
271// (`[x0, y0, x1, y1]`): the top and bottom span the full width, the sides
272// fill the gap between them.
273fn ring_geometry(
274    vertices: &mut Vec<TextVertex>,
275    indices: &mut Vec<u16>,
276    rect: [f32; 4],
277    width: f32,
278    alpha: f32,
279    mut v: impl FnMut(f32, f32, f32) -> TextVertex,
280) {
281    let [x0, y0, x1, y1] = rect;
282    let strips = [
283        [x0, y0, x1, y0 + width],
284        [x0, y1 - width, x1, y1],
285        [x0, y0 + width, x0 + width, y1 - width],
286        [x1 - width, y0 + width, x1, y1 - width],
287    ];
288    for strip in strips {
289        rect_geometry(vertices, indices, strip, 0.0, alpha, &mut v);
290    }
291}
292
293// Arc steps per rounded corner. Six segments keep a 10-15 px UI radius
294// visually smooth once the feathered edge blends the silhouette.
295const CORNER_SEGMENTS: usize = 6;
296// Width (window pixels) of the soft edge ring. The solid interior stops this
297// far inside the authored boundary and fades to transparent at it, so the
298// silhouette never grows past the authored rect.
299const EDGE_FEATHER: f32 = 1.25;
300
301// The corner-arc samples a rounded rectangle's silhouette is drawn from: each
302// entry is a corner centre and a unit offset, so one boundary describes every
303// outline concentric with the authored rect (the feathered edge, the inset the
304// stroke leaves). Insetting the rect and dropping the radius by the same
305// amount leaves the centres where they are, which is what lets a stroke sample
306// this at two radii.
307fn rounded_boundary(
308    rect: [f32; 4],
309    radius: f32,
310) -> [(f32, f32, f32, f32); 4 * (CORNER_SEGMENTS + 1)] {
311    use core::f32::consts::FRAC_PI_2;
312    let [x0, y0, x1, y1] = rect;
313    // Every corner sweeps the same quarter turn, so the unit offsets are
314    // evaluated once and each corner reuses them rotated into its quadrant.
315    let mut arc = [(0.0_f32, 0.0_f32); CORNER_SEGMENTS + 1];
316    for (i, slot) in arc.iter_mut().enumerate() {
317        let (s, c) = sin_cos((i as f32 / CORNER_SEGMENTS as f32) * FRAC_PI_2);
318        *slot = (c, s);
319    }
320    // Corner arc centers in polygon order, each with the quarter-turn count its
321    // arc starts at; y grows downward so the arcs sweep clockwise.
322    let corners = [
323        (x0 + radius, y0 + radius, 2),
324        (x1 - radius, y0 + radius, 3),
325        (x1 - radius, y1 - radius, 0),
326        (x0 + radius, y1 - radius, 1),
327    ];
328    let mut boundary = [(0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32); 4 * (CORNER_SEGMENTS + 1)];
329    for (c, &(cx, cy, quadrant)) in corners.iter().enumerate() {
330        for (i, &(uc, us)) in arc.iter().enumerate() {
331            let (rc, rs) = match quadrant {
332                0 => (uc, us),
333                1 => (-us, uc),
334                2 => (-uc, -us),
335                _ => (us, -uc),
336            };
337            boundary[c * (CORNER_SEGMENTS + 1) + i] = (cx, cy, rc, rs);
338        }
339    }
340    boundary
341}
342
343// Append a rounded rectangle's border stroke: the fill's own silhouette,
344// hollowed out `width` inside it. The outer edge feathers exactly as the
345// fill's does, so the two silhouettes blend as one; the inner edge is the
346// inset rect's own boundary, so a stroke wider than the corner radius keeps
347// its full width along the straight runs.
348fn rounded_ring_geometry(
349    vertices: &mut Vec<TextVertex>,
350    indices: &mut Vec<u16>,
351    rect: [f32; 4],
352    radius: f32,
353    width: f32,
354    alpha: f32,
355    mut v: impl FnMut(f32, f32, f32) -> TextVertex,
356) {
357    let [x0, y0, x1, y1] = rect;
358    let inner_r = (radius - width).max(0.0);
359    let solid_r = (radius - EDGE_FEATHER).max(inner_r);
360    let outer = rounded_boundary(rect, radius);
361    let inner = rounded_boundary([x0 + width, y0 + width, x1 - width, y1 - width], inner_r);
362    let m = outer.len();
363    let base = vertices.len() as u16;
364    vertices.reserve(3 * m);
365    for &(cx, cy, cos, sin) in &outer {
366        vertices.push(v(cx + radius * cos, cy + radius * sin, 0.0));
367    }
368    for &(cx, cy, cos, sin) in &outer {
369        vertices.push(v(cx + solid_r * cos, cy + solid_r * sin, alpha));
370    }
371    for &(cx, cy, cos, sin) in &inner {
372        vertices.push(v(cx + inner_r * cos, cy + inner_r * sin, alpha));
373    }
374    indices.reserve(12 * m);
375    for loop_start in [0, m] {
376        for i in 0..m {
377            let j = (i + 1) % m;
378            let (i, j, m) = ((loop_start + i) as u16, (loop_start + j) as u16, m as u16);
379            indices.extend([i, j, m + j, i, m + j, m + i].map(|k| base + k));
380        }
381    }
382}
383
384// Tessellate a rounded rectangle in window space: a solid convex polygon
385// inset one feather width inside the authored boundary, fanned from its first
386// point, plus a fading ring out to the boundary for anti-aliasing. Vertex
387// alpha carries the fade (both sprite modes read per-vertex alpha).
388fn rounded_rect_geometry(
389    vertices: &mut Vec<TextVertex>,
390    indices: &mut Vec<u16>,
391    rect: [f32; 4],
392    radius: f32,
393    alpha: f32,
394    mut v: impl FnMut(f32, f32, f32) -> TextVertex,
395) {
396    let boundary = rounded_boundary(rect, radius);
397    let m = boundary.len();
398    let base = vertices.len() as u16;
399    let inner_r = (radius - EDGE_FEATHER).max(0.0);
400    vertices.reserve(2 * m);
401    for &(cx, cy, cos, sin) in &boundary {
402        vertices.push(v(cx + inner_r * cos, cy + inner_r * sin, alpha));
403    }
404    for &(cx, cy, cos, sin) in &boundary {
405        vertices.push(v(cx + radius * cos, cy + radius * sin, 0.0));
406    }
407    indices.reserve(3 * (m - 2) + 6 * m);
408    for i in 1..m - 1 {
409        indices.extend([base, base + i as u16, base + (i + 1) as u16]);
410    }
411    for i in 0..m {
412        let j = (i + 1) % m;
413        let (i, j, m) = (i as u16, j as u16, m as u16);
414        indices.extend([i, j, m + j, i, m + j, m + i].map(|k| base + k));
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::ecs::TextureHandle;
422    use crate::ecs::asset_id::AssetId;
423
424    use alloc::vec;
425    fn no_clips() -> ClipRects {
426        ClipRects::new()
427    }
428    fn no_layers() -> OverlayLayers {
429        OverlayLayers::new()
430    }
431
432    fn no_slots() -> TextureSlots {
433        TextureSlots::new()
434    }
435
436    fn sprite(x: f32, y: f32, w: f32, h: f32, tint: [f32; 4]) -> Sprite {
437        Sprite {
438            asset_id: AssetId::default(),
439            x,
440            y,
441            width: w,
442            height: h,
443            texture: None,
444            tint,
445            follow_cursor: false,
446            visible: true,
447            screen: None,
448            fit: SpriteFit::Fit,
449            corner_radius: 0.0,
450            border_width: 0.0,
451            border_color: [0.0, 0.0, 0.0, 1.0],
452        }
453    }
454
455    #[test]
456    fn no_fonts_means_no_calls() {
457        let s = sprite(0.0, 0.0, 100.0, 100.0, [1.0, 0.0, 0.0, 1.0]);
458        assert!(
459            build_sprite_calls(
460                core::slice::from_ref(&s),
461                None,
462                &no_slots(),
463                [0.0, 0.0],
464                &no_clips(),
465                &no_layers()
466            )
467            .is_empty()
468        );
469    }
470
471    #[test]
472    fn visible_sprite_emits_quad_with_sentinel_uv() {
473        let s = sprite(10.0, 20.0, 100.0, 50.0, [0.5, 0.5, 0.5, 0.75]);
474        let calls = build_sprite_calls(
475            core::slice::from_ref(&s),
476            Some(0),
477            &no_slots(),
478            [0.0, 0.0],
479            &no_clips(),
480            &no_layers(),
481        );
482        assert_eq!(calls.len(), 1);
483        assert_eq!(calls[0].vertices.len(), 4);
484        assert_eq!(calls[0].indices, vec![0, 1, 2, 0, 2, 3]);
485        for v in &calls[0].vertices {
486            assert!(v.uv[0] < 0.0, "sentinel u should be negative");
487            assert!((v.uv[1] - 0.75).abs() < 1e-5, "alpha carried in v");
488            assert_eq!(v.color, [0.5, 0.5, 0.5]);
489        }
490        assert_eq!(calls[0].vertices[0].pos, [10.0, 20.0]);
491        assert_eq!(calls[0].vertices[2].pos, [110.0, 70.0]);
492    }
493
494    // A fully transparent fill normally skips the sprite, but a visible
495    // border keeps it: the border ring alone draws (an outline sprite).
496    #[test]
497    fn transparent_fill_draws_when_a_border_is_set() {
498        let invisible = sprite(0.0, 0.0, 100.0, 50.0, [0.0, 0.0, 0.0, 0.0]);
499        assert!(
500            build_sprite_calls(
501                core::slice::from_ref(&invisible),
502                Some(0),
503                &no_slots(),
504                [0.0, 0.0],
505                &no_clips(),
506                &no_layers()
507            )
508            .is_empty(),
509            "borderless transparent fill still skips"
510        );
511
512        let mut outline = sprite(0.0, 0.0, 100.0, 50.0, [0.0, 0.0, 0.0, 0.0]);
513        outline.border_width = 2.0;
514        outline.border_color = [0.2, 0.4, 0.9, 1.0];
515        let calls = build_sprite_calls(
516            core::slice::from_ref(&outline),
517            Some(0),
518            &no_slots(),
519            [0.0, 0.0],
520            &no_clips(),
521            &no_layers(),
522        );
523        assert_eq!(calls.len(), 1, "the border ring draws");
524        // Four edge strips, every vertex in the border colour at full alpha:
525        // nothing is drawn inside the ring, so the object shows through.
526        let verts = &calls[0].vertices;
527        assert_eq!(verts.len(), 16);
528        assert!(
529            verts
530                .iter()
531                .all(|v| v.color == [0.2, 0.4, 0.9] && v.uv == [-1.0, 1.0])
532        );
533        let inside = |x: f32, y: f32| {
534            calls[0].indices.chunks(3).any(|t| {
535                let p: Vec<[f32; 2]> = t.iter().map(|&i| verts[i as usize].pos).collect();
536                point_in_triangle([x, y], p[0], p[1], p[2])
537            })
538        };
539        assert!(inside(50.0, 1.0), "the top strip covers the edge");
540        assert!(inside(1.0, 25.0), "the left strip covers the edge");
541        assert!(!inside(50.0, 25.0), "the interior is empty");
542
543        // An opaque fill keeps the inset-fill stroke (rounded borders rely
544        // on it).
545        let mut panel = sprite(0.0, 0.0, 100.0, 50.0, [0.1, 0.1, 0.1, 1.0]);
546        panel.border_width = 2.0;
547        panel.border_color = [0.2, 0.4, 0.9, 1.0];
548        let calls = build_sprite_calls(
549            core::slice::from_ref(&panel),
550            Some(0),
551            &no_slots(),
552            [0.0, 0.0],
553            &no_clips(),
554            &no_layers(),
555        );
556        assert_eq!(calls[0].vertices.len(), 8);
557    }
558
559    // A translucent panel is a wash over whatever is behind it, so nothing may
560    // be drawn under its fill: the stroke is a hollow ring, not a slab of the
561    // border colour with the fill laid over it.
562    #[test]
563    fn a_translucent_fill_is_not_backed_by_its_border() {
564        let mut panel = sprite(0.0, 0.0, 100.0, 50.0, [0.1, 0.1, 0.12, 0.5]);
565        panel.border_width = 2.0;
566        panel.border_color = [0.3, 0.32, 0.4, 1.0];
567        panel.corner_radius = 8.0;
568        let calls = build_sprite_calls(
569            core::slice::from_ref(&panel),
570            Some(0),
571            &no_slots(),
572            [0.0, 0.0],
573            &no_clips(),
574            &no_layers(),
575        );
576        assert_eq!(calls.len(), 1);
577        let verts = &calls[0].vertices;
578        // Whatever the stroke covers, it never reaches the middle of the panel.
579        let covered = |x: f32, y: f32, color: [f32; 3]| {
580            calls[0].indices.chunks(3).any(|t| {
581                let p: Vec<[f32; 2]> = t.iter().map(|&i| verts[i as usize].pos).collect();
582                t.iter().all(|&i| verts[i as usize].color == color)
583                    && point_in_triangle([x, y], p[0], p[1], p[2])
584            })
585        };
586        let border = [0.3, 0.32, 0.4];
587        let fill = [0.1, 0.1, 0.12];
588        assert!(covered(50.0, 1.0, border), "the stroke covers the edge");
589        assert!(
590            !covered(50.0, 25.0, border),
591            "and leaves the middle to the wash"
592        );
593        assert!(covered(50.0, 25.0, fill), "which draws there at its alpha");
594        assert!(
595            verts
596                .iter()
597                .filter(|v| v.color == fill)
598                .any(|v| (v.uv[1] - 0.5).abs() < 1e-5),
599            "the wash keeps its own alpha"
600        );
601
602        // The stroke's outer edge feathers to nothing like the fill's does.
603        let border_alphas: Vec<f32> = verts
604            .iter()
605            .filter(|v| v.color == border)
606            .map(|v| v.uv[1])
607            .collect();
608        assert!(border_alphas.contains(&0.0));
609        assert!(border_alphas.iter().any(|&a| (a - 1.0).abs() < 1e-5));
610
611        // A stroke wider than the corner radius keeps its full width.
612        panel.corner_radius = 2.0;
613        panel.border_width = 6.0;
614        let calls = build_sprite_calls(
615            core::slice::from_ref(&panel),
616            Some(0),
617            &no_slots(),
618            [0.0, 0.0],
619            &no_clips(),
620            &no_layers(),
621        );
622        let verts = &calls[0].vertices;
623        let covered = |x: f32, y: f32, color: [f32; 3]| {
624            calls[0].indices.chunks(3).any(|t| {
625                let p: Vec<[f32; 2]> = t.iter().map(|&i| verts[i as usize].pos).collect();
626                t.iter().all(|&i| verts[i as usize].color == color)
627                    && point_in_triangle([x, y], p[0], p[1], p[2])
628            })
629        };
630        assert!(covered(50.0, 5.9, border));
631        assert!(!covered(50.0, 6.1, border));
632
633        // A square-cornered translucent panel takes the straight-strip stroke.
634        panel.border_width = 2.0;
635        panel.corner_radius = 0.0;
636        let calls = build_sprite_calls(
637            core::slice::from_ref(&panel),
638            Some(0),
639            &no_slots(),
640            [0.0, 0.0],
641            &no_clips(),
642            &no_layers(),
643        );
644        let verts = &calls[0].vertices;
645        assert_eq!(verts.len(), 4 + 16, "one quad of fill, four edge strips");
646    }
647
648    fn point_in_triangle(p: [f32; 2], a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> bool {
649        let sign = |p: [f32; 2], q: [f32; 2], r: [f32; 2]| {
650            (p[0] - r[0]) * (q[1] - r[1]) - (q[0] - r[0]) * (p[1] - r[1])
651        };
652        let (d1, d2, d3) = (sign(p, a, b), sign(p, b, c), sign(p, c, a));
653        let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
654        let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
655        !(neg && pos)
656    }
657
658    #[test]
659    fn textured_sprite_emits_real_uvs_and_its_slot() {
660        let mut s = sprite(10.0, 20.0, 100.0, 50.0, [1.0, 0.9, 0.8, 0.75]);
661        s.texture = Some(TextureHandle(42));
662        let mut slots = no_slots();
663        slots.insert(TextureHandle(42), 3);
664        let calls = build_sprite_calls(
665            core::slice::from_ref(&s),
666            Some(0),
667            &slots,
668            [0.0, 0.0],
669            &no_clips(),
670            &no_layers(),
671        );
672        assert_eq!(calls.len(), 1);
673        // The call binds the sprite texture's atlas slot, not the font's.
674        assert_eq!(calls[0].atlas_slot, 3);
675        let vs = &calls[0].vertices;
676        assert_eq!(vs[0].uv, [0.0, 0.0]);
677        assert_eq!(vs[1].uv, [1.0, 0.0]);
678        assert_eq!(vs[2].uv, [1.0, 1.0]);
679        assert_eq!(vs[3].uv, [0.0, 1.0]);
680        for v in vs {
681            // Tint in color, alpha in the mode flag (> 0 = textured).
682            assert_eq!(v.color, [1.0, 0.9, 0.8]);
683            assert!((v.mode - 0.75).abs() < 1e-5);
684        }
685    }
686
687    #[test]
688    fn rounded_sprite_tessellates_with_a_feathered_edge() {
689        let mut s = sprite(100.0, 100.0, 400.0, 200.0, [0.1, 0.2, 0.3, 0.9]);
690        s.corner_radius = 20.0;
691        let calls = build_sprite_calls(
692            core::slice::from_ref(&s),
693            Some(0),
694            &no_slots(),
695            [0.0, 0.0],
696            &no_clips(),
697            &no_layers(),
698        );
699        assert_eq!(calls.len(), 1);
700        let vs = &calls[0].vertices;
701        // An inner solid ring and an outer transparent ring, 4 corner arcs of
702        // CORNER_SEGMENTS + 1 points each.
703        let ring = 4 * (CORNER_SEGMENTS + 1);
704        assert_eq!(vs.len(), 2 * ring);
705        for v in &vs[..ring] {
706            assert!(
707                (v.uv[1] - 0.9).abs() < 1e-5,
708                "inner ring carries the tint alpha"
709            );
710        }
711        for v in &vs[ring..] {
712            assert!(v.uv[1].abs() < 1e-5, "outer ring fades to transparent");
713        }
714        // Every vertex stays inside the authored rect, and the outer ring
715        // reaches the rect edges at the flat sides.
716        for v in vs {
717            assert!(v.pos[0] >= 100.0 - 1e-3 && v.pos[0] <= 500.0 + 1e-3);
718            assert!(v.pos[1] >= 100.0 - 1e-3 && v.pos[1] <= 300.0 + 1e-3);
719        }
720        let min_x = vs.iter().map(|v| v.pos[0]).fold(f32::MAX, f32::min);
721        assert!((min_x - 100.0).abs() < 1e-3);
722        // The corner point itself is never touched: the arc cuts it off.
723        assert!(!vs.iter().any(|v| v.pos == [100.0, 100.0]));
724    }
725
726    #[test]
727    fn bordered_sprite_emits_a_border_ring_and_an_inset_fill() {
728        let mut s = sprite(100.0, 100.0, 200.0, 120.0, [0.1, 0.2, 0.3, 1.0]);
729        s.border_width = 2.0;
730        s.border_color = [0.8, 0.4, 0.2, 1.0];
731        let calls = build_sprite_calls(
732            core::slice::from_ref(&s),
733            Some(0),
734            &no_slots(),
735            [0.0, 0.0],
736            &no_clips(),
737            &no_layers(),
738        );
739        assert_eq!(calls.len(), 1);
740        let vs = &calls[0].vertices;
741        // Both the border-coloured outer layer and the tinted fill are present.
742        assert!(
743            vs.iter().any(|v| v.color == [0.8, 0.4, 0.2]),
744            "border colour present"
745        );
746        assert!(
747            vs.iter().any(|v| v.color == [0.1, 0.2, 0.3]),
748            "fill colour present"
749        );
750        // The border reaches the authored outer edge; the tinted fill is inset
751        // by the stroke width on every side.
752        let outer_min_x = vs.iter().map(|v| v.pos[0]).fold(f32::MAX, f32::min);
753        let outer_max_x = vs.iter().map(|v| v.pos[0]).fold(f32::MIN, f32::max);
754        assert!(
755            (outer_min_x - 100.0).abs() < 1e-3,
756            "border at the left edge"
757        );
758        assert!(
759            (outer_max_x - 300.0).abs() < 1e-3,
760            "border at the right edge"
761        );
762        let fill_min_x = vs
763            .iter()
764            .filter(|v| v.color == [0.1, 0.2, 0.3])
765            .map(|v| v.pos[0])
766            .fold(f32::MAX, f32::min);
767        assert!(
768            (fill_min_x - 102.0).abs() < 1e-3,
769            "fill inset by the stroke width"
770        );
771    }
772
773    #[test]
774    fn zero_border_stays_a_single_layer() {
775        let mut s = sprite(0.0, 0.0, 100.0, 100.0, [0.2, 0.3, 0.4, 1.0]);
776        // A colour but no width draws no border (just the fill quad).
777        s.border_width = 0.0;
778        s.border_color = [1.0, 0.0, 0.0, 1.0];
779        let calls = build_sprite_calls(
780            core::slice::from_ref(&s),
781            Some(0),
782            &no_slots(),
783            [0.0, 0.0],
784            &no_clips(),
785            &no_layers(),
786        );
787        assert_eq!(
788            calls[0].vertices.len(),
789            4,
790            "one plain quad, no border layer"
791        );
792        assert!(calls[0].vertices.iter().all(|v| v.color == [0.2, 0.3, 0.4]));
793    }
794
795    #[test]
796    fn rounded_view_sprite_scales_its_radius_with_the_window() {
797        // A 2x window doubles the radius: the outer ring's leftmost point
798        // sits at the transformed rect's left edge, and the top-left corner
799        // arc starts (2 * radius) transformed pixels down from the rect top.
800        let mut s = sprite(100.0, 100.0, 400.0, 200.0, [0.1, 0.2, 0.3, 0.9]);
801        s.screen = Some(AssetId(7));
802        s.corner_radius = 20.0;
803        let calls = build_sprite_calls(
804            core::slice::from_ref(&s),
805            Some(0),
806            &no_slots(),
807            [2.0 * UI_REFERENCE_SIZE[0], 2.0 * UI_REFERENCE_SIZE[1]],
808            &no_clips(),
809            &no_layers(),
810        );
811        let vs = &calls[0].vertices;
812        let min_x = vs.iter().map(|v| v.pos[0]).fold(f32::MAX, f32::min);
813        let top_left_arc_y = vs
814            .iter()
815            .filter(|v| (v.pos[0] - min_x).abs() < 1e-3)
816            .map(|v| v.pos[1])
817            .fold(f32::MAX, f32::min);
818        assert!((min_x - 200.0).abs() < 1e-3);
819        assert!((top_left_arc_y - (200.0 + 40.0)).abs() < 1e-3);
820    }
821
822    #[test]
823    fn textured_sprite_without_a_loaded_texture_falls_back_to_fill() {
824        let mut s = sprite(0.0, 0.0, 10.0, 10.0, [0.2, 0.3, 0.4, 1.0]);
825        s.texture = Some(TextureHandle(42));
826        // The texture never made it into the atlas pool: solid-fill sentinel.
827        let calls = build_sprite_calls(
828            core::slice::from_ref(&s),
829            Some(5),
830            &no_slots(),
831            [0.0, 0.0],
832            &no_clips(),
833            &no_layers(),
834        );
835        assert_eq!(calls[0].atlas_slot, 5);
836        assert!(calls[0].vertices[0].uv[0] < 0.0);
837        assert_eq!(calls[0].vertices[0].mode, 0.0);
838    }
839
840    #[test]
841    fn invisible_sprite_is_skipped() {
842        let mut s = sprite(0.0, 0.0, 100.0, 100.0, [1.0, 1.0, 1.0, 1.0]);
843        s.visible = false;
844        assert!(
845            build_sprite_calls(
846                core::slice::from_ref(&s),
847                Some(0),
848                &no_slots(),
849                [0.0, 0.0],
850                &no_clips(),
851                &no_layers()
852            )
853            .is_empty()
854        );
855    }
856
857    #[test]
858    fn zero_alpha_sprite_is_skipped() {
859        let s = sprite(0.0, 0.0, 100.0, 100.0, [1.0, 1.0, 1.0, 0.0]);
860        assert!(
861            build_sprite_calls(
862                core::slice::from_ref(&s),
863                Some(0),
864                &no_slots(),
865                [0.0, 0.0],
866                &no_clips(),
867                &no_layers()
868            )
869            .is_empty()
870        );
871    }
872
873    #[test]
874    fn view_owned_sprite_scales_to_window() {
875        // A view-owned (overlay) sprite is authored in the reference canvas and
876        // uniformly scaled onto the window. At twice the reference size the
877        // rect doubles and stays centered.
878        let mut s = sprite(100.0, 100.0, 200.0, 100.0, [1.0, 1.0, 1.0, 1.0]);
879        s.screen = Some(AssetId(7));
880        let calls = build_sprite_calls(
881            core::slice::from_ref(&s),
882            Some(0),
883            &no_slots(),
884            [2560.0, 1440.0],
885            &no_clips(),
886            &no_layers(),
887        );
888        assert_eq!(calls.len(), 1);
889        assert_eq!(calls[0].vertices[0].pos, [200.0, 200.0]);
890        assert_eq!(calls[0].vertices[2].pos, [600.0, 400.0]);
891    }
892
893    #[test]
894    fn view_owned_full_canvas_backdrop_fills_window() {
895        // A view-owned sprite spanning the whole reference canvas is a
896        // full-screen backdrop: it fills the live window rather than letterboxing.
897        let mut s = sprite(0.0, 0.0, 1280.0, 720.0, [0.0, 0.0, 0.0, 0.5]);
898        s.screen = Some(AssetId(7));
899        let calls = build_sprite_calls(
900            core::slice::from_ref(&s),
901            Some(0),
902            &no_slots(),
903            [2560.0, 1440.0],
904            &no_clips(),
905            &no_layers(),
906        );
907        assert_eq!(calls.len(), 1);
908        assert_eq!(calls[0].vertices[0].pos, [0.0, 0.0]);
909        assert_eq!(calls[0].vertices[2].pos, [2560.0, 1440.0]);
910    }
911
912    #[test]
913    fn cover_sprite_fills_the_window_and_crops_the_overflow() {
914        // On a 4:3 window the 16:9 canvas covers by the height ratio
915        // (768/720): vertical edges land exactly on the window edges,
916        // horizontal overflow is cropped equally on both sides.
917        let mut s = sprite(0.0, 0.0, 1280.0, 720.0, [1.0, 1.0, 1.0, 1.0]);
918        s.screen = Some(AssetId(7));
919        s.fit = SpriteFit::Cover;
920        let calls = build_sprite_calls(
921            core::slice::from_ref(&s),
922            Some(0),
923            &no_slots(),
924            [1024.0, 768.0],
925            &no_clips(),
926            &no_layers(),
927        );
928        let scale = 768.0 / 720.0;
929        let overflow = (1280.0 * scale - 1024.0) / 2.0;
930        let vs = &calls[0].vertices;
931        assert!(
932            (vs[0].pos[0] - -overflow).abs() < 1e-3,
933            "x0={}",
934            vs[0].pos[0]
935        );
936        assert!((vs[0].pos[1]).abs() < 1e-3, "y0={}", vs[0].pos[1]);
937        assert!(
938            (vs[2].pos[0] - (1024.0 + overflow)).abs() < 1e-3,
939            "x1={}",
940            vs[2].pos[0]
941        );
942        assert!((vs[2].pos[1] - 768.0).abs() < 1e-3, "y1={}", vs[2].pos[1]);
943    }
944
945    #[test]
946    fn cover_sprite_anchored_to_the_canvas_bottom_stays_flush() {
947        // A bottom-anchored partial-canvas sprite (a character portrait): its
948        // bottom edge maps exactly to the window bottom on a window taller
949        // than the reference aspect.
950        let mut s = sprite(400.0, 100.0, 480.0, 620.0, [1.0, 1.0, 1.0, 1.0]);
951        s.screen = Some(AssetId(7));
952        s.fit = SpriteFit::Cover;
953        let calls = build_sprite_calls(
954            core::slice::from_ref(&s),
955            Some(0),
956            &no_slots(),
957            [1024.0, 768.0],
958            &no_clips(),
959            &no_layers(),
960        );
961        let bottom = calls[0].vertices[2].pos[1];
962        assert!((bottom - 768.0).abs() < 1e-3, "bottom={bottom}");
963    }
964
965    #[test]
966    fn view_less_sprite_keeps_literal_pixels() {
967        // A HUD / scene sprite (view == None) is never overlay-scaled.
968        let s = sprite(10.0, 20.0, 100.0, 50.0, [0.5, 0.5, 0.5, 1.0]);
969        let calls = build_sprite_calls(
970            core::slice::from_ref(&s),
971            Some(0),
972            &no_slots(),
973            [2560.0, 1440.0],
974            &no_clips(),
975            &no_layers(),
976        );
977        assert_eq!(calls[0].vertices[0].pos, [10.0, 20.0]);
978        assert_eq!(calls[0].vertices[2].pos, [110.0, 70.0]);
979    }
980
981    #[test]
982    fn clipped_element_carries_window_space_clip_rect() {
983        // A view-owned sprite whose id is in the clips map gets a clip_rect
984        // mapped from the reference-space band through the overlay; one not in
985        // the map stays unclipped.
986        let mut s = sprite(100.0, 100.0, 50.0, 50.0, [1.0, 1.0, 1.0, 1.0]);
987        s.asset_id = AssetId(7);
988        s.screen = Some(AssetId(1));
989        let mut clips = no_clips();
990        // Reference band [200,200] size [200,60] at a 2x viewport (1280x720 ->
991        // 2560x1440, scale 2 about the centre): forward(200,200)=(400,400),
992        // forward(400,260)=(800,520) -> clip [400,400,400,120].
993        clips.insert(AssetId(7), [200.0, 200.0, 200.0, 60.0]);
994        let calls = build_sprite_calls(
995            core::slice::from_ref(&s),
996            Some(0),
997            &no_slots(),
998            [2560.0, 1440.0],
999            &clips,
1000            &no_layers(),
1001        );
1002        let clip = calls[0].clip_rect.expect("clipped sprite has a clip rect");
1003        assert!((clip[0] - 400.0).abs() < 1e-3, "x={}", clip[0]);
1004        assert!((clip[1] - 400.0).abs() < 1e-3, "y={}", clip[1]);
1005        assert!((clip[2] - 400.0).abs() < 1e-3, "w={}", clip[2]);
1006        assert!((clip[3] - 120.0).abs() < 1e-3, "h={}", clip[3]);
1007
1008        // A sprite not in the clips map is unclipped.
1009        let mut other = sprite(0.0, 0.0, 10.0, 10.0, [1.0, 1.0, 1.0, 1.0]);
1010        other.asset_id = AssetId(9);
1011        other.screen = Some(AssetId(1));
1012        let calls = build_sprite_calls(
1013            core::slice::from_ref(&other),
1014            Some(0),
1015            &no_slots(),
1016            [2560.0, 1440.0],
1017            &clips,
1018            &no_layers(),
1019        );
1020        assert!(calls[0].clip_rect.is_none());
1021    }
1022
1023    // A sprite's call carries the draw layer its id maps to (used by the editor's
1024    // panel occlusion sort); an id absent from the map draws at layer 0.
1025    #[test]
1026    fn sprite_call_takes_its_layer_from_the_map() {
1027        let mut mapped = sprite(0.0, 0.0, 10.0, 10.0, [1.0, 1.0, 1.0, 1.0]);
1028        mapped.asset_id = AssetId(42);
1029        let mut layers = OverlayLayers::new();
1030        layers.insert(AssetId(42), 7);
1031        let calls = build_sprite_calls(
1032            core::slice::from_ref(&mapped),
1033            Some(0),
1034            &no_slots(),
1035            [100.0, 100.0],
1036            &no_clips(),
1037            &layers,
1038        );
1039        assert_eq!(calls[0].layer, 7);
1040
1041        let mut unmapped = sprite(0.0, 0.0, 10.0, 10.0, [1.0, 1.0, 1.0, 1.0]);
1042        unmapped.asset_id = AssetId(99);
1043        let calls = build_sprite_calls(
1044            core::slice::from_ref(&unmapped),
1045            Some(0),
1046            &no_slots(),
1047            [100.0, 100.0],
1048            &no_clips(),
1049            &layers,
1050        );
1051        assert_eq!(calls[0].layer, 0, "an unmapped id is layer 0");
1052    }
1053
1054    // A `follow_cursor` sprite is the cursor pass's silhouette source, never a
1055    // scene quad, so the whole component slice can be passed in.
1056    #[test]
1057    fn follow_cursor_sprites_are_skipped() {
1058        let mut s = sprite(0.0, 0.0, 10.0, 10.0, [1.0, 1.0, 1.0, 1.0]);
1059        s.follow_cursor = true;
1060        assert!(
1061            build_sprite_calls(
1062                core::slice::from_ref(&s),
1063                Some(0),
1064                &no_slots(),
1065                [0.0, 0.0],
1066                &no_clips(),
1067                &no_layers()
1068            )
1069            .is_empty()
1070        );
1071    }
1072}