ailloli_ui_render_wgpu/passes/
primitives.rs

1//! CPU vertex builders and scissor encoding for renderer primitives.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use ailloli_ui_core::math::{snap_rect_to_physical, Scale};
7use ailloli_ui_core::{Color, Rect};
8use ailloli_ui_runtime::{
9    DrawBorder, DrawBoxShadow, DrawImage, DrawRRect, DrawRingProgress, DrawText,
10};
11
12use crate::text::{GlyphKey, TextAtlas};
13use crate::vertices::{
14    BorderRRectVertex, BoxShadowVertex, RRectVertex, RingProgressVertex, TexVertex, Vertex,
15};
16
17/// Returns Unix epoch time in whole milliseconds.
18///
19/// Times before the epoch and system-clock errors conservatively return zero.
20/// The value is wall-clock time and is not monotonic.
21///
22/// # Examples
23///
24/// ```
25/// use ailloli_ui_render_wgpu::passes::now_ms;
26/// let timestamp: u128 = now_ms();
27/// assert!(timestamp > 0);
28/// ```
29pub fn now_ms() -> u128 {
30    use std::time::{SystemTime, UNIX_EPOCH};
31    SystemTime::now()
32        .duration_since(UNIX_EPOCH)
33        .unwrap_or_default()
34        .as_millis()
35}
36
37/// Converts top-left-origin physical pixels to wgpu normalized device coordinates.
38///
39/// `(0, 0)` maps to `(-1, 1)` and `(w, h)` maps to `(1, -1)`. Zero dimensions
40/// produce IEEE infinities or NaN rather than being clamped.
41///
42/// # Examples
43///
44/// ```
45/// use ailloli_ui_render_wgpu::passes::to_ndc;
46/// assert_eq!(to_ndc(100.0, 50.0, 0.0, 0.0), [-1.0, 1.0]);
47/// assert_eq!(to_ndc(100.0, 50.0, 100.0, 50.0), [1.0, -1.0]);
48/// ```
49pub fn to_ndc(w: f32, h: f32, x: f32, y: f32) -> [f32; 2] {
50    let nx = (x / w) * 2.0 - 1.0;
51    let ny = 1.0 - (y / h) * 2.0;
52    [nx, ny]
53}
54
55/// Multiplies logical rectangle coordinates and size by device-pixel ratio.
56fn scale_rect_to_physical(r: Rect, scale: Scale) -> Rect {
57    Rect::new(
58        r.x * scale.dpr,
59        r.y * scale.dpr,
60        r.w * scale.dpr,
61        r.h * scale.dpr,
62    )
63}
64
65/// Appends a solid logical-pixel rectangle at DPR 1.
66///
67/// Six vertices (two triangles) are appended, even for empty or negative
68/// rectangles.
69///
70/// # Examples
71///
72/// ```
73/// use ailloli_ui_core::{Color, Rect};
74/// use ailloli_ui_render_wgpu::passes::push_rect;
75/// let mut vertices = Vec::new();
76/// push_rect(&mut vertices, 100.0, 50.0, Rect::new(0.0, 0.0, 10.0, 5.0), Color::WHITE);
77/// assert_eq!(vertices.len(), 6);
78/// ```
79pub fn push_rect(out: &mut Vec<Vertex>, w: f32, h: f32, r: Rect, c: Color) {
80    push_rect_scaled(out, w, h, Scale::new(1.0), r, c);
81}
82
83/// Appends a solid rectangle after scaling logical geometry to physical pixels.
84///
85/// `w` and `h` are the physical render-target extent used for NDC conversion.
86///
87/// # Examples
88///
89/// ```
90/// use ailloli_ui_core::{math::Scale, Color, Rect};
91/// use ailloli_ui_render_wgpu::passes::push_rect_scaled;
92/// let mut vertices = Vec::new();
93/// push_rect_scaled(&mut vertices, 20.0, 20.0, Scale::new(2.0),
94///     Rect::new(0.0, 0.0, 10.0, 10.0), Color::WHITE);
95/// assert_eq!(vertices[2].pos, [1.0, -1.0]);
96/// ```
97pub fn push_rect_scaled(out: &mut Vec<Vertex>, w: f32, h: f32, scale: Scale, r: Rect, c: Color) {
98    let r = scale_rect_to_physical(r, scale);
99    let x0 = r.x;
100    let y0 = r.y;
101    let x1 = r.x + r.w;
102    let y1 = r.y + r.h;
103
104    let p0 = to_ndc(w, h, x0, y0);
105    let p1 = to_ndc(w, h, x1, y0);
106    let p2 = to_ndc(w, h, x1, y1);
107    let p3 = to_ndc(w, h, x0, y1);
108
109    let color = c.to_array();
110
111    out.push(Vertex { pos: p0, color });
112    out.push(Vertex { pos: p1, color });
113    out.push(Vertex { pos: p2, color });
114    out.push(Vertex { pos: p0, color });
115    out.push(Vertex { pos: p2, color });
116    out.push(Vertex { pos: p3, color });
117}
118
119/// Builds a full-UV image quad at DPR 1.
120///
121/// # Examples
122///
123/// ```no_run
124/// use ailloli_ui_render_wgpu::passes::make_tex_rect;
125/// use ailloli_ui_runtime::DrawImage;
126/// fn build(image: DrawImage) { assert_eq!(make_tex_rect(100.0, 100.0, image).len(), 6); }
127/// ```
128pub fn make_tex_rect(w: f32, h: f32, img: DrawImage) -> [TexVertex; 6] {
129    make_tex_rect_scaled(w, h, Scale::new(1.0), img)
130}
131
132/// Builds a full-UV image quad after scaling logical bounds to physical pixels.
133///
134/// Tint components pass through unchanged and UVs cover the entire texture.
135///
136/// # Examples
137///
138/// ```no_run
139/// use ailloli_ui_core::math::Scale;
140/// use ailloli_ui_render_wgpu::passes::make_tex_rect_scaled;
141/// use ailloli_ui_runtime::DrawImage;
142/// fn build(image: DrawImage) {
143///     let quad = make_tex_rect_scaled(200.0, 100.0, Scale::new(2.0), image);
144///     assert_eq!(quad[0].uv, [0.0, 0.0]);
145/// }
146/// ```
147pub fn make_tex_rect_scaled(w: f32, h: f32, scale: Scale, img: DrawImage) -> [TexVertex; 6] {
148    let mut img = img;
149    img.rect = scale_rect_to_physical(img.rect, scale);
150    let x0 = img.rect.x;
151    let y0 = img.rect.y;
152    let x1 = img.rect.x + img.rect.w;
153    let y1 = img.rect.y + img.rect.h;
154
155    let p0 = to_ndc(w, h, x0, y0);
156    let p1 = to_ndc(w, h, x1, y0);
157    let p2 = to_ndc(w, h, x1, y1);
158    let p3 = to_ndc(w, h, x0, y1);
159
160    let uv0 = [0.0, 0.0];
161    let uv1 = [1.0, 0.0];
162    let uv2 = [1.0, 1.0];
163    let uv3 = [0.0, 1.0];
164
165    let tint = [img.tint.r, img.tint.g, img.tint.b, img.tint.a];
166
167    [
168        TexVertex {
169            pos: p0,
170            uv: uv0,
171            tint,
172        },
173        TexVertex {
174            pos: p1,
175            uv: uv1,
176            tint,
177        },
178        TexVertex {
179            pos: p2,
180            uv: uv2,
181            tint,
182        },
183        TexVertex {
184            pos: p0,
185            uv: uv0,
186            tint,
187        },
188        TexVertex {
189            pos: p2,
190            uv: uv2,
191            tint,
192        },
193        TexVertex {
194            pos: p3,
195            uv: uv3,
196            tint,
197        },
198    ]
199}
200
201/// Appends a rounded-rectangle SDF quad at DPR 1.
202///
203/// # Examples
204///
205/// ```no_run
206/// use ailloli_ui_render_wgpu::passes::push_rrect;
207/// use ailloli_ui_runtime::DrawRRect;
208/// fn build(shape: DrawRRect) { let mut out = Vec::new(); push_rrect(&mut out, 10.0, 10.0, shape); assert_eq!(out.len(), 6); }
209/// ```
210pub fn push_rrect(out: &mut Vec<RRectVertex>, w: f32, h: f32, rr: DrawRRect) {
211    push_rrect_scaled(out, w, h, Scale::new(1.0), rr);
212}
213
214/// Appends a rounded-rectangle SDF quad in physical-pixel geometry.
215///
216/// Logical bounds and radius are multiplied by DPR; size is clamped to at least
217/// one and radius to at least zero before being copied to all six vertices.
218///
219/// # Examples
220///
221/// ```no_run
222/// use ailloli_ui_core::math::Scale;
223/// use ailloli_ui_render_wgpu::passes::push_rrect_scaled;
224/// use ailloli_ui_runtime::DrawRRect;
225/// fn build(shape: DrawRRect) { let mut out = Vec::new(); push_rrect_scaled(&mut out, 20.0, 20.0, Scale::new(2.0), shape); assert_eq!(out.len(), 6); }
226/// ```
227pub fn push_rrect_scaled(
228    out: &mut Vec<RRectVertex>,
229    w: f32,
230    h: f32,
231    scale: Scale,
232    mut rr: DrawRRect,
233) {
234    rr.rect = scale_rect_to_physical(rr.rect, scale);
235    rr.radius *= scale.dpr;
236    let x0 = rr.rect.x;
237    let y0 = rr.rect.y;
238    let x1 = rr.rect.x + rr.rect.w;
239    let y1 = rr.rect.y + rr.rect.h;
240
241    let p0 = to_ndc(w, h, x0, y0);
242    let p1 = to_ndc(w, h, x1, y0);
243    let p2 = to_ndc(w, h, x1, y1);
244    let p3 = to_ndc(w, h, x0, y1);
245
246    let uv0 = [0.0, 0.0];
247    let uv1 = [1.0, 0.0];
248    let uv2 = [1.0, 1.0];
249    let uv3 = [0.0, 1.0];
250
251    let color = rr.color.to_array();
252    let size_px = [rr.rect.w.max(1.0), rr.rect.h.max(1.0)];
253    let radius = rr.radius.max(0.0);
254
255    out.push(RRectVertex {
256        pos: p0,
257        uv: uv0,
258        color,
259        size_px,
260        radius_px: radius,
261    });
262    out.push(RRectVertex {
263        pos: p1,
264        uv: uv1,
265        color,
266        size_px,
267        radius_px: radius,
268    });
269    out.push(RRectVertex {
270        pos: p2,
271        uv: uv2,
272        color,
273        size_px,
274        radius_px: radius,
275    });
276    out.push(RRectVertex {
277        pos: p0,
278        uv: uv0,
279        color,
280        size_px,
281        radius_px: radius,
282    });
283    out.push(RRectVertex {
284        pos: p2,
285        uv: uv2,
286        color,
287        size_px,
288        radius_px: radius,
289    });
290    out.push(RRectVertex {
291        pos: p3,
292        uv: uv3,
293        color,
294        size_px,
295        radius_px: radius,
296    });
297}
298
299/// Appends a rounded-border SDF quad in physical-pixel geometry.
300///
301/// The current shader uses the top-left radius for all corners. Width and radius
302/// are clamped nonnegative; bounds still append six vertices when empty.
303///
304/// # Examples
305///
306/// ```no_run
307/// use ailloli_ui_core::{math::Scale, Color};
308/// use ailloli_ui_render_wgpu::passes::push_border_rrect_scaled;
309/// use ailloli_ui_runtime::DrawBorder;
310/// fn build(border: DrawBorder) { let mut out = Vec::new(); push_border_rrect_scaled(&mut out, 100.0, 100.0, Scale::new(1.0), border, 1.0, Color::WHITE); assert_eq!(out.len(), 6); }
311/// ```
312pub fn push_border_rrect_scaled(
313    out: &mut Vec<BorderRRectVertex>,
314    w: f32,
315    h: f32,
316    scale: Scale,
317    mut border: DrawBorder,
318    width: f32,
319    color: Color,
320) {
321    border.rect = scale_rect_to_physical(border.rect, scale);
322    let width = (width * scale.dpr).max(0.0);
323    let radius = (border.radius.tl * scale.dpr).max(0.0);
324    let x0 = border.rect.x;
325    let y0 = border.rect.y;
326    let x1 = border.rect.x + border.rect.w;
327    let y1 = border.rect.y + border.rect.h;
328
329    let p0 = to_ndc(w, h, x0, y0);
330    let p1 = to_ndc(w, h, x1, y0);
331    let p2 = to_ndc(w, h, x1, y1);
332    let p3 = to_ndc(w, h, x0, y1);
333
334    let uv0 = [0.0, 0.0];
335    let uv1 = [1.0, 0.0];
336    let uv2 = [1.0, 1.0];
337    let uv3 = [0.0, 1.0];
338
339    let color = color.to_array();
340    let size_px = [border.rect.w.max(1.0), border.rect.h.max(1.0)];
341
342    out.push(BorderRRectVertex {
343        pos: p0,
344        uv: uv0,
345        color,
346        size_px,
347        radius_px: radius,
348        width_px: width,
349    });
350    out.push(BorderRRectVertex {
351        pos: p1,
352        uv: uv1,
353        color,
354        size_px,
355        radius_px: radius,
356        width_px: width,
357    });
358    out.push(BorderRRectVertex {
359        pos: p2,
360        uv: uv2,
361        color,
362        size_px,
363        radius_px: radius,
364        width_px: width,
365    });
366    out.push(BorderRRectVertex {
367        pos: p0,
368        uv: uv0,
369        color,
370        size_px,
371        radius_px: radius,
372        width_px: width,
373    });
374    out.push(BorderRRectVertex {
375        pos: p2,
376        uv: uv2,
377        color,
378        size_px,
379        radius_px: radius,
380        width_px: width,
381    });
382    out.push(BorderRRectVertex {
383        pos: p3,
384        uv: uv3,
385        color,
386        size_px,
387        radius_px: radius,
388        width_px: width,
389    });
390}
391
392/// Appends a paint-only outer box-shadow SDF quad.
393///
394/// Inset shadows, transparent colors, and nonpositive paint/shape bounds append
395/// nothing. Radius includes spread, is scaled to physical pixels, and is capped
396/// at half the smaller shape axis; blur is clamped nonnegative.
397///
398/// # Examples
399///
400/// ```no_run
401/// use ailloli_ui_core::math::Scale;
402/// use ailloli_ui_render_wgpu::passes::push_box_shadow_scaled;
403/// use ailloli_ui_runtime::DrawBoxShadow;
404/// fn build(shadow: DrawBoxShadow) { let mut out = Vec::new(); push_box_shadow_scaled(&mut out, 100.0, 100.0, Scale::new(1.0), shadow); assert!(out.is_empty() || out.len() == 6); }
405/// ```
406pub fn push_box_shadow_scaled(
407    out: &mut Vec<BoxShadowVertex>,
408    w: f32,
409    h: f32,
410    scale: Scale,
411    shadow: DrawBoxShadow,
412) {
413    if shadow.shadow.inset || shadow.shadow.color.a <= 0.0 {
414        return;
415    }
416
417    let paint_rect = scale_rect_to_physical(shadow.shadow.paint_bounds(shadow.rect), scale);
418    let shape_rect = scale_rect_to_physical(shadow.shadow.shape_rect(shadow.rect), scale);
419    if paint_rect.w <= 0.0 || paint_rect.h <= 0.0 || shape_rect.w <= 0.0 || shape_rect.h <= 0.0 {
420        return;
421    }
422
423    let x0 = paint_rect.x;
424    let y0 = paint_rect.y;
425    let x1 = paint_rect.x + paint_rect.w;
426    let y1 = paint_rect.y + paint_rect.h;
427
428    let p0 = to_ndc(w, h, x0, y0);
429    let p1 = to_ndc(w, h, x1, y0);
430    let p2 = to_ndc(w, h, x1, y1);
431    let p3 = to_ndc(w, h, x0, y1);
432
433    let uv0 = [0.0, 0.0];
434    let uv1 = [1.0, 0.0];
435    let uv2 = [1.0, 1.0];
436    let uv3 = [0.0, 1.0];
437
438    let color = shadow.shadow.color.to_array();
439    let paint_size_px = [paint_rect.w.max(1.0), paint_rect.h.max(1.0)];
440    let shape_offset_px = [shape_rect.x - paint_rect.x, shape_rect.y - paint_rect.y];
441    let shape_size_px = [shape_rect.w.max(1.0), shape_rect.h.max(1.0)];
442    let radius_px = ((shadow.radius.tl + shadow.shadow.spread).max(0.0) * scale.dpr)
443        .min(shape_size_px[0].min(shape_size_px[1]) * 0.5);
444    let blur_px = (shadow.shadow.blur_radius * scale.dpr).max(0.0);
445
446    out.push(BoxShadowVertex {
447        pos: p0,
448        uv: uv0,
449        color,
450        paint_size_px,
451        shape_offset_px,
452        shape_size_px,
453        radius_px,
454        blur_px,
455    });
456    out.push(BoxShadowVertex {
457        pos: p1,
458        uv: uv1,
459        color,
460        paint_size_px,
461        shape_offset_px,
462        shape_size_px,
463        radius_px,
464        blur_px,
465    });
466    out.push(BoxShadowVertex {
467        pos: p2,
468        uv: uv2,
469        color,
470        paint_size_px,
471        shape_offset_px,
472        shape_size_px,
473        radius_px,
474        blur_px,
475    });
476    out.push(BoxShadowVertex {
477        pos: p0,
478        uv: uv0,
479        color,
480        paint_size_px,
481        shape_offset_px,
482        shape_size_px,
483        radius_px,
484        blur_px,
485    });
486    out.push(BoxShadowVertex {
487        pos: p2,
488        uv: uv2,
489        color,
490        paint_size_px,
491        shape_offset_px,
492        shape_size_px,
493        radius_px,
494        blur_px,
495    });
496    out.push(BoxShadowVertex {
497        pos: p3,
498        uv: uv3,
499        color,
500        paint_size_px,
501        shape_offset_px,
502        shape_size_px,
503        radius_px,
504        blur_px,
505    });
506}
507
508/// Appends a circular-progress SDF quad in physical-pixel geometry.
509///
510/// Empty bounds append nothing. Thickness is clamped to half the smaller axis,
511/// fraction to `[0, 1]`, and the start angle is preserved in radians.
512///
513/// # Examples
514///
515/// ```no_run
516/// use ailloli_ui_core::math::Scale;
517/// use ailloli_ui_render_wgpu::passes::push_ring_progress_scaled;
518/// use ailloli_ui_runtime::DrawRingProgress;
519/// fn build(ring: DrawRingProgress) { let mut out = Vec::new(); push_ring_progress_scaled(&mut out, 100.0, 100.0, Scale::new(1.0), ring); assert!(out.is_empty() || out.len() == 6); }
520/// ```
521pub fn push_ring_progress_scaled(
522    out: &mut Vec<RingProgressVertex>,
523    w: f32,
524    h: f32,
525    scale: Scale,
526    mut ring: DrawRingProgress,
527) {
528    ring.rect = scale_rect_to_physical(ring.rect, scale);
529    if ring.rect.w <= 0.0 || ring.rect.h <= 0.0 {
530        return;
531    }
532
533    let x0 = ring.rect.x;
534    let y0 = ring.rect.y;
535    let x1 = ring.rect.x + ring.rect.w;
536    let y1 = ring.rect.y + ring.rect.h;
537
538    let p0 = to_ndc(w, h, x0, y0);
539    let p1 = to_ndc(w, h, x1, y0);
540    let p2 = to_ndc(w, h, x1, y1);
541    let p3 = to_ndc(w, h, x0, y1);
542
543    let uv0 = [0.0, 0.0];
544    let uv1 = [1.0, 0.0];
545    let uv2 = [1.0, 1.0];
546    let uv3 = [0.0, 1.0];
547
548    let size_px = [ring.rect.w.max(1.0), ring.rect.h.max(1.0)];
549    let thickness_px = (ring.thickness * scale.dpr)
550        .max(0.0)
551        .min(size_px[0].min(size_px[1]) * 0.5);
552    let fraction = ring.fraction.clamp(0.0, 1.0);
553    let track_color = ring.track_color.to_array();
554    let fill_color = ring.fill_color.to_array();
555    let start_angle = ring.start_angle;
556
557    out.push(RingProgressVertex {
558        pos: p0,
559        uv: uv0,
560        track_color,
561        fill_color,
562        size_px,
563        thickness_px,
564        fraction,
565        start_angle,
566    });
567    out.push(RingProgressVertex {
568        pos: p1,
569        uv: uv1,
570        track_color,
571        fill_color,
572        size_px,
573        thickness_px,
574        fraction,
575        start_angle,
576    });
577    out.push(RingProgressVertex {
578        pos: p2,
579        uv: uv2,
580        track_color,
581        fill_color,
582        size_px,
583        thickness_px,
584        fraction,
585        start_angle,
586    });
587    out.push(RingProgressVertex {
588        pos: p0,
589        uv: uv0,
590        track_color,
591        fill_color,
592        size_px,
593        thickness_px,
594        fraction,
595        start_angle,
596    });
597    out.push(RingProgressVertex {
598        pos: p2,
599        uv: uv2,
600        track_color,
601        fill_color,
602        size_px,
603        thickness_px,
604        fraction,
605        start_angle,
606    });
607    out.push(RingProgressVertex {
608        pos: p3,
609        uv: uv3,
610        track_color,
611        fill_color,
612        size_px,
613        thickness_px,
614        fraction,
615        start_angle,
616    });
617}
618
619#[allow(clippy::too_many_arguments)]
620/// Appends textured glyph quads at DPR 1 using a complete atlas frame cycle.
621///
622/// This convenience path calls `start_frame` and `finish_frame` itself. Missing
623/// faces and unrasterizable glyphs are skipped. `text_draws` receives contiguous
624/// half-open vertex ranges grouped by atlas page.
625///
626/// # Examples
627///
628/// ```no_run
629/// use std::{collections::HashMap, sync::Arc};
630/// use ailloli_ui_render_wgpu::{passes::push_text, text::TextAtlas};
631/// use ailloli_ui_runtime::DrawText;
632/// fn build(atlas: &mut TextAtlas, device: &wgpu::Device, queue: &wgpu::Queue,
633///     layout: &wgpu::BindGroupLayout, text: &DrawText) {
634///     let mut vertices = Vec::new(); let mut draws = Vec::new();
635///     let faces: HashMap<u64, Arc<[u8]>> = HashMap::new();
636///     push_text(&mut vertices, &mut draws, 100.0, 50.0, atlas, device, queue, layout, &faces, text);
637/// }
638/// ```
639pub fn push_text(
640    out: &mut Vec<TexVertex>,
641    text_draws: &mut Vec<(u8, std::ops::Range<u32>)>,
642    w: f32,
643    h: f32,
644    atlas: &mut TextAtlas,
645    device: &wgpu::Device,
646    queue: &wgpu::Queue,
647    bind_group_layout: &wgpu::BindGroupLayout,
648    face_blobs: &HashMap<u64, Arc<[u8]>>,
649    dt: &DrawText,
650) {
651    atlas.start_frame();
652    push_text_scaled(
653        out,
654        text_draws,
655        [w, h],
656        Scale::new(1.0),
657        atlas,
658        device,
659        queue,
660        bind_group_layout,
661        face_blobs,
662        dt,
663    );
664    atlas.finish_frame();
665}
666
667#[allow(clippy::too_many_arguments)]
668/// Appends textured glyph quads for an already-started atlas frame.
669///
670/// Glyph sizes are rounded and clamped to `8..=128` physical pixels. Positions
671/// are snapped to whole physical pixels. Callers must finish the atlas frame
672/// after encoding all text that relies on pinned pages.
673///
674/// # Examples
675///
676/// ```no_run
677/// use std::{collections::HashMap, sync::Arc};
678/// use ailloli_ui_core::math::Scale;
679/// use ailloli_ui_render_wgpu::{passes::push_text_scaled, text::TextAtlas};
680/// use ailloli_ui_runtime::DrawText;
681/// fn build(atlas: &mut TextAtlas, device: &wgpu::Device, queue: &wgpu::Queue,
682///     layout: &wgpu::BindGroupLayout, text: &DrawText) {
683///     let mut vertices = Vec::new(); let mut draws = Vec::new();
684///     let faces: HashMap<u64, Arc<[u8]>> = HashMap::new(); atlas.start_frame();
685///     push_text_scaled(&mut vertices, &mut draws, [100.0, 50.0], Scale::new(1.0), atlas, device, queue, layout, &faces, text);
686///     atlas.finish_frame();
687/// }
688/// ```
689pub fn push_text_scaled(
690    out: &mut Vec<TexVertex>,
691    text_draws: &mut Vec<(u8, std::ops::Range<u32>)>,
692    surface: [f32; 2],
693    scale: Scale,
694    atlas: &mut TextAtlas,
695    device: &wgpu::Device,
696    queue: &wgpu::Queue,
697    bind_group_layout: &wgpu::BindGroupLayout,
698    face_blobs: &HashMap<u64, Arc<[u8]>>,
699    dt: &DrawText,
700) {
701    let [w, h] = surface;
702    let (origin_x, origin_y) = text_origin_from_baseline(dt);
703
704    let fallback_color = dt.color;
705    let scale_100 = (scale.dpr * 100.0).round().clamp(1.0, u16::MAX as f32) as u16;
706    let mut current_page: Option<u8> = None;
707    let mut current_start: u32 = out.len() as u32;
708    for gi in dt.layout.glyphs() {
709        let color = gi.color.unwrap_or(fallback_color).to_array();
710        let physical_px_size = ((gi.px_size as f32) * scale.dpr).round();
711        let key = GlyphKey {
712            face_id: gi.face_id,
713            font_index: gi.font_index,
714            px_size: physical_px_size.clamp(8.0, 128.0) as u16,
715            glyph_id: gi.glyph_id,
716            scale_100,
717        };
718        let Some(blob) = face_blobs.get(&gi.face_id) else {
719            atlas.record_missing_face();
720            continue;
721        };
722        let Some((page_idx, g)) =
723            atlas.get_or_rasterize_pinned(device, queue, bind_group_layout, key, blob.as_ref())
724        else {
725            continue;
726        };
727        if g.size_px[0] <= 0.0 || g.size_px[1] <= 0.0 {
728            continue;
729        }
730
731        if Some(page_idx) != current_page {
732            if let Some(p) = current_page {
733                let end = out.len() as u32;
734                if end > current_start {
735                    text_draws.push((p, current_start..end));
736                }
737            }
738            current_page = Some(page_idx);
739            current_start = out.len() as u32;
740        }
741
742        let pen_x = (origin_x + gi.x) * scale.dpr;
743        let pen_y = (origin_y + gi.y) * scale.dpr;
744
745        let x0 = (pen_x + g.offset_px[0]).round();
746        let y0 = (pen_y + g.offset_px[1]).round();
747        let x1 = x0 + g.size_px[0];
748        let y1 = y0 + g.size_px[1];
749
750        let p0 = to_ndc(w, h, x0, y0);
751        let p1 = to_ndc(w, h, x1, y0);
752        let p2 = to_ndc(w, h, x1, y1);
753        let p3 = to_ndc(w, h, x0, y1);
754
755        let uv0 = g.uv_min;
756        let uv2 = g.uv_max;
757        let uv1 = [uv2[0], uv0[1]];
758        let uv3 = [uv0[0], uv2[1]];
759
760        out.push(TexVertex {
761            pos: p0,
762            uv: uv0,
763            tint: color,
764        });
765        out.push(TexVertex {
766            pos: p1,
767            uv: uv1,
768            tint: color,
769        });
770        out.push(TexVertex {
771            pos: p2,
772            uv: uv2,
773            tint: color,
774        });
775        out.push(TexVertex {
776            pos: p0,
777            uv: uv0,
778            tint: color,
779        });
780        out.push(TexVertex {
781            pos: p2,
782            uv: uv2,
783            tint: color,
784        });
785        out.push(TexVertex {
786            pos: p3,
787            uv: uv3,
788            tint: color,
789        });
790    }
791
792    if let Some(p) = current_page {
793        let end = out.len() as u32;
794        if end > current_start {
795            text_draws.push((p, current_start..end));
796        }
797    }
798}
799
800/// Contract: `DrawText.pos` is expressed in baseline coordinates (pos.y = baseline).
801///
802/// The returned origin subtracts the first layout line's baseline from `pos.y`;
803/// an empty line list uses a zero baseline.
804///
805/// # Examples
806///
807/// ```
808/// let pos_y = 30.0_f32;
809/// let first_baseline = 12.0_f32;
810/// assert_eq!(pos_y - first_baseline, 18.0);
811/// ```
812pub(crate) fn text_origin_from_baseline(dt: &DrawText) -> (f32, f32) {
813    let first_baseline = dt.layout.lines.first().map(|l| l.baseline_y).unwrap_or(0.0);
814    (dt.pos[0], dt.pos[1] - first_baseline)
815}
816
817/// Applies one logical scissor rectangle at DPR 1.
818///
819/// An empty rectangle leaves the current render-pass scissor unchanged.
820///
821/// # Examples
822///
823/// ```no_run
824/// use ailloli_ui_core::Rect;
825/// use ailloli_ui_render_wgpu::passes::set_scissor_rect;
826/// fn set(pass: &mut wgpu::RenderPass<'_>) { set_scissor_rect(pass, 100.0, 50.0, Rect::new(1.0, 2.0, 10.0, 10.0)); }
827/// ```
828pub fn set_scissor_rect(rpass: &mut wgpu::RenderPass<'_>, w: f32, h: f32, clip: Rect) {
829    set_scissor_rect_scaled(rpass, w, h, Scale::new(1.0), clip);
830}
831
832/// Snaps a logical scissor outward at DPR and applies its nonempty intersection.
833///
834/// Surface dimensions are truncated to `u32`. An empty intersection leaves the
835/// existing scissor unchanged.
836///
837/// # Examples
838///
839/// ```no_run
840/// use ailloli_ui_core::{math::Scale, Rect};
841/// use ailloli_ui_render_wgpu::passes::set_scissor_rect_scaled;
842/// fn set(pass: &mut wgpu::RenderPass<'_>) { set_scissor_rect_scaled(pass, 200.0, 100.0, Scale::new(2.0), Rect::new(1.0, 2.0, 3.0, 4.0)); }
843/// ```
844pub fn set_scissor_rect_scaled(
845    rpass: &mut wgpu::RenderPass<'_>,
846    w_px: f32,
847    h_px: f32,
848    scale: Scale,
849    clip_logical: Rect,
850) {
851    let (x0, y0, w, h) = match scissor_rect_u32(w_px as u32, h_px as u32, scale, clip_logical) {
852        Some(v) => v,
853        None => return,
854    };
855    rpass.set_scissor_rect(x0, y0, w, h);
856}
857
858/// Applies the layer scissor, or resets to the full surface when there is no clip.
859///
860/// WGPU can retain scissor state across render passes in the same encoder on some
861/// backends; always setting an explicit rect avoids a narrow scissor leaking to
862/// later layers (e.g. window chrome after an editor viewport pass).
863///
864/// # Examples
865///
866/// ```no_run
867/// use ailloli_ui_core::math::Scale;
868/// use ailloli_ui_render_wgpu::passes::apply_layer_scissor;
869/// fn reset(pass: &mut wgpu::RenderPass<'_>) { apply_layer_scissor(pass, 100.0, 50.0, Scale::new(1.0), None); }
870/// ```
871pub fn apply_layer_scissor(
872    rpass: &mut wgpu::RenderPass<'_>,
873    w_px: f32,
874    h_px: f32,
875    scale: Scale,
876    clip_logical: Option<Rect>,
877) {
878    let surface_w = w_px as u32;
879    let surface_h = h_px as u32;
880    if let Some(clip) = clip_logical {
881        if let Some((x0, y0, w, h)) = scissor_rect_u32(surface_w, surface_h, scale, clip) {
882            rpass.set_scissor_rect(x0, y0, w, h);
883            return;
884        }
885    }
886    rpass.set_scissor_rect(0, 0, surface_w.max(1), surface_h.max(1));
887}
888
889/// Converts a snapped logical rectangle to a bounded nonempty wgpu scissor.
890fn scissor_rect_u32(
891    surface_w_px: u32,
892    surface_h_px: u32,
893    scale: Scale,
894    clip_logical: Rect,
895) -> Option<(u32, u32, u32, u32)> {
896    let pr = snap_rect_to_physical(clip_logical, scale);
897
898    let max_w = surface_w_px as i32;
899    let max_h = surface_h_px as i32;
900    let x0 = pr.x.clamp(0, max_w) as u32;
901    let y0 = pr.y.clamp(0, max_h) as u32;
902    let x1 = (pr.x + pr.w).clamp(0, max_w) as u32;
903    let y1 = (pr.y + pr.h).clamp(0, max_h) as u32;
904    let w = x1.saturating_sub(x0);
905    let h = y1.saturating_sub(y0);
906    if w == 0 || h == 0 {
907        None
908    } else {
909        Some((x0, y0, w, h))
910    }
911}
912
913#[cfg(test)]
914/// Verifies baseline placement, logical scaling, scissor snapping, and radii.
915mod tests {
916    use super::*;
917
918    use ailloli_ui_core::{style::Color, FontId, TextStyle};
919    use ailloli_ui_runtime::DrawText as RuntimeDrawText;
920    use ailloli_ui_text::{TextLayoutParams, TextSystem, WrapMode};
921
922    #[test]
923    fn draw_text_pos_is_baseline_contract() {
924        let mut ts = TextSystem::new();
925        let style = TextStyle::new(FontId::Ui, 16, Color::new(1.0, 1.0, 1.0, 1.0));
926        let prep = ts.layout_cached(TextLayoutParams {
927            text: "A",
928            style,
929            max_width: None,
930            wrap_mode: WrapMode::NoWrap,
931        });
932        let baseline = prep.lines.first().map(|l| l.baseline_y).unwrap_or(0.0);
933
934        let dt = RuntimeDrawText {
935            pos: [10.0, 20.0 + baseline],
936            color: style.color,
937            decoration: ailloli_ui_core::TextDecoration::None,
938            layout: std::sync::Arc::clone(&prep),
939        };
940
941        let (_x, y) = text_origin_from_baseline(&dt);
942        assert!((y - 20.0).abs() <= f32::EPSILON);
943    }
944
945    #[test]
946    fn scissor_uses_core_snap_rect_to_physical_with_dpr_2() {
947        let scale = Scale::new(2.0);
948        // 1.0 logical px => 2 physical px after snap.
949        let clip = Rect::new(1.0, 2.0, 3.0, 4.0);
950        let (x0, y0, w, h) = scissor_rect_u32(100, 100, scale, clip).expect("non-empty");
951        assert_eq!((x0, y0, w, h), (2, 4, 6, 8));
952    }
953
954    #[test]
955    fn rect_geometry_is_scaled_to_physical_before_ndc() {
956        let mut vertices = Vec::new();
957        push_rect_scaled(
958            &mut vertices,
959            200.0,
960            100.0,
961            Scale::new(2.0),
962            Rect::new(0.0, 0.0, 100.0, 50.0),
963            Color::new(1.0, 0.0, 0.0, 1.0),
964        );
965
966        assert_eq!(vertices.len(), 6);
967        assert_eq!(vertices[0].pos, [-1.0, 1.0]);
968        assert_eq!(vertices[1].pos, [1.0, 1.0]);
969        assert_eq!(vertices[2].pos, [1.0, -1.0]);
970    }
971
972    #[test]
973    fn rrect_size_and_radius_are_scaled_to_physical() {
974        let mut vertices = Vec::new();
975        push_rrect_scaled(
976            &mut vertices,
977            200.0,
978            100.0,
979            Scale::new(2.0),
980            ailloli_ui_runtime::DrawRRect {
981                rect: Rect::new(0.0, 0.0, 100.0, 50.0),
982                radius: 4.0,
983                color: Color::new(1.0, 0.0, 0.0, 1.0),
984            },
985        );
986
987        assert_eq!(vertices[0].size_px, [200.0, 100.0]);
988        assert_eq!(vertices[0].radius_px, 8.0);
989    }
990}