ailloli_ui_render_wgpu/
cmd_bounds.rs

1//! Device-space bounds for draw commands (isolated compositor).
2
3use ailloli_ui_core::math::Scale;
4use ailloli_ui_core::Rect;
5use ailloli_ui_runtime::{DrawCmd, DrawPolyline, DrawText};
6
7use crate::frame_prep::PreparedResources;
8use crate::isolated_plan::IsolatedEffectChain;
9use crate::passes::primitives::text_origin_from_baseline;
10use crate::passes::to_ndc;
11
12/// Padding multiplier for blur kernel (physical pixels).
13///
14/// # Examples
15///
16/// ```
17/// use ailloli_ui_render_wgpu::cmd_bounds::BLUR_PADDING_FACTOR;
18/// assert_eq!(BLUR_PADDING_FACTOR, 3.0);
19/// ```
20pub const BLUR_PADDING_FACTOR: f32 = 3.0;
21
22/// Union of axis-aligned bounds for all commands in a layer (physical pixels).
23///
24/// Returns `None` for an empty command slice. Logical geometry is multiplied by
25/// `scale.dpr`; text initially uses layout bounds and decorations, while
26/// polylines include half their nonnegative stroke width plus one logical pixel.
27///
28/// # Examples
29///
30/// ```
31/// use ailloli_ui_core::math::Scale;
32/// use ailloli_ui_render_wgpu::cmd_bounds::union_cmd_bounds;
33/// assert_eq!(union_cmd_bounds(&[], Scale::new(2.0)), None);
34/// ```
35pub fn union_cmd_bounds(cmds: &[DrawCmd], scale: Scale) -> Option<Rect> {
36    let mut acc: Option<Rect> = None;
37    for cmd in cmds {
38        let r = cmd_bounds(cmd, scale);
39        acc = Some(match acc {
40            None => r,
41            Some(a) => union_rect(a, r),
42        });
43    }
44    acc
45}
46
47/// Computes command bounds using prepared glyph extents when available.
48///
49/// Missing prepared glyphs fall back to the text layout rectangle. Non-text
50/// commands are identical to [`union_cmd_bounds`].
51///
52/// # Examples
53///
54/// ```no_run
55/// use ailloli_ui_core::math::Scale;
56/// use ailloli_ui_render_wgpu::{cmd_bounds::union_cmd_bounds_prepared, PreparedResources};
57/// fn empty(prepared: &PreparedResources) {
58///     assert_eq!(union_cmd_bounds_prepared(&[], Scale::new(1.0), prepared), None);
59/// }
60/// ```
61pub fn union_cmd_bounds_prepared(
62    cmds: &[DrawCmd],
63    scale: Scale,
64    prepared: &PreparedResources,
65) -> Option<Rect> {
66    let mut acc: Option<Rect> = None;
67    for cmd in cmds {
68        let r = cmd_bounds_prepared(cmd, scale, prepared);
69        acc = Some(match acc {
70            None => r,
71            Some(a) => union_rect(a, r),
72        });
73    }
74    acc
75}
76
77/// Returns conservative physical bounds for one unprepared draw command.
78fn cmd_bounds(cmd: &DrawCmd, scale: Scale) -> Rect {
79    match cmd {
80        DrawCmd::Rect(dr) => scale_rect(dr.rect, scale),
81        DrawCmd::RRect(rr) => scale_rect(rr.rect, scale),
82        DrawCmd::Border(border) => scale_rect(border.rect, scale),
83        DrawCmd::BoxShadow(shadow) => scale_rect(shadow.shadow.paint_bounds(shadow.rect), scale),
84        DrawCmd::RingProgress(ring) => scale_rect(ring.rect, scale),
85        DrawCmd::Polyline(polyline) => polyline_bounds(polyline, scale),
86        DrawCmd::Text(dt) => text_bounds(dt, scale),
87        DrawCmd::Image(img) => scale_rect(img.rect, scale),
88    }
89}
90
91/// Returns prepared glyph bounds for text and ordinary bounds for other commands.
92fn cmd_bounds_prepared(cmd: &DrawCmd, scale: Scale, prepared: &PreparedResources) -> Rect {
93    match cmd {
94        DrawCmd::Text(dt) => {
95            text_bounds_prepared(dt, scale, prepared).unwrap_or_else(|| text_bounds(dt, scale))
96        }
97        _ => cmd_bounds(cmd, scale),
98    }
99}
100
101/// Uses the shaped layout box plus every decoration rectangle as text bounds.
102fn text_bounds(dt: &DrawText, scale: Scale) -> Rect {
103    let (ox, oy) = text_origin_from_baseline(dt);
104    let w = dt.layout.width();
105    let h = dt.layout.height();
106    let mut bounds = scale_rect(Rect::new(ox, oy, w, h), scale);
107    for decoration in dt.decoration_rects(scale.dpr) {
108        bounds = union_rect(bounds, scale_rect(decoration, scale));
109    }
110    bounds
111}
112
113/// Unions physical atlas glyph rectangles and decoration rectangles.
114///
115/// Returns `None` only when neither a prepared glyph nor a decoration exists.
116fn text_bounds_prepared(dt: &DrawText, scale: Scale, prepared: &PreparedResources) -> Option<Rect> {
117    let scale_100 = (scale.dpr * 100.0).round().clamp(1.0, u16::MAX as f32) as u16;
118    let (origin_x, origin_y) = text_origin_from_baseline(dt);
119    let mut acc: Option<Rect> = None;
120    for gi in dt.layout.glyphs() {
121        let physical_px_size = ((gi.px_size as f32) * scale.dpr).round();
122        let key = crate::text::GlyphKey {
123            face_id: gi.face_id,
124            font_index: gi.font_index,
125            px_size: physical_px_size.clamp(8.0, 128.0) as u16,
126            glyph_id: gi.glyph_id,
127            scale_100,
128        };
129        let Some(&(_, g)) = prepared.glyphs.get(&key) else {
130            continue;
131        };
132        let pen_x = (origin_x + gi.x) * scale.dpr;
133        let pen_y = (origin_y + gi.y) * scale.dpr;
134        let x0 = pen_x + g.offset_px[0];
135        let y0 = pen_y + g.offset_px[1];
136        let r = Rect::new(x0, y0, g.size_px[0], g.size_px[1]);
137        acc = Some(match acc {
138            None => r,
139            Some(a) => union_rect(a, r),
140        });
141    }
142    for decoration in dt.decoration_rects(scale.dpr) {
143        let rect = scale_rect(decoration, scale);
144        acc = Some(match acc {
145            None => rect,
146            Some(bounds) => union_rect(bounds, rect),
147        });
148    }
149    acc
150}
151
152/// Computes conservative stroked bounds, ignoring non-finite points.
153///
154/// A polyline without a finite point maps to the zero rectangle.
155fn polyline_bounds(polyline: &DrawPolyline, scale: Scale) -> Rect {
156    let mut min_x = f32::INFINITY;
157    let mut min_y = f32::INFINITY;
158    let mut max_x = f32::NEG_INFINITY;
159    let mut max_y = f32::NEG_INFINITY;
160    for point in &polyline.points {
161        if !point.x.is_finite() || !point.y.is_finite() {
162            continue;
163        }
164        min_x = min_x.min(point.x);
165        min_y = min_y.min(point.y);
166        max_x = max_x.max(point.x);
167        max_y = max_y.max(point.y);
168    }
169
170    if !min_x.is_finite() {
171        return Rect::new(0.0, 0.0, 0.0, 0.0);
172    }
173
174    let pad = polyline.stroke.width.max(0.0) * 0.5 + 1.0;
175    scale_rect(
176        Rect::new(
177            min_x - pad,
178            min_y - pad,
179            (max_x - min_x) + pad * 2.0,
180            (max_y - min_y) + pad * 2.0,
181        ),
182        scale,
183    )
184}
185
186/// Multiplies every rectangle component by the device-pixel ratio.
187fn scale_rect(r: Rect, scale: Scale) -> Rect {
188    Rect::new(
189        r.x * scale.dpr,
190        r.y * scale.dpr,
191        r.w * scale.dpr,
192        r.h * scale.dpr,
193    )
194}
195
196/// Returns the smallest axis-aligned rectangle containing both inputs.
197fn union_rect(a: Rect, b: Rect) -> Rect {
198    let x0 = a.x.min(b.x);
199    let y0 = a.y.min(b.y);
200    let x1 = (a.x + a.w).max(b.x + b.w);
201    let y1 = (a.y + a.h).max(b.y + b.h);
202    Rect::new(x0, y0, x1 - x0, y1 - y0)
203}
204
205/// Inflates bounds for every blur in an isolated effect chain.
206///
207/// Each blur adds `radius_px * BLUR_PADDING_FACTOR` independently on all sides.
208/// Negative or NaN radii are not sanitized here; budget planning must clamp
209/// inputs before this step.
210///
211/// # Examples
212///
213/// ```
214/// use ailloli_ui_core::Rect;
215/// use ailloli_ui_render_wgpu::{cmd_bounds::inflate_for_effects, IsolatedEffect, IsolatedEffectChain};
216/// let effects = IsolatedEffectChain { effects: vec![IsolatedEffect::Blur { radius_px: 2.0 }] };
217/// assert_eq!(inflate_for_effects(Rect::new(10.0, 10.0, 20.0, 20.0), &effects),
218///     Rect::new(4.0, 4.0, 32.0, 32.0));
219/// ```
220pub fn inflate_for_effects(bounds: Rect, effects: &IsolatedEffectChain) -> Rect {
221    let mut r = bounds;
222    for e in &effects.effects {
223        if let crate::isolated_plan::IsolatedEffect::Blur { radius_px } = e {
224            let pad = radius_px * BLUR_PADDING_FACTOR;
225            r = Rect::new(r.x - pad, r.y - pad, r.w + pad * 2.0, r.h + pad * 2.0);
226        }
227    }
228    r
229}
230
231/// Snaps physical bounds outward and clamps their far edges to a surface.
232///
233/// The returned tuple is `(snapped_bounds, origin, integer_size)`. Each size is
234/// forced to at least one pixel, even for empty or fully out-of-surface bounds;
235/// callers must reject invalid bounds earlier. Float-to-`u32` casts saturate
236/// negative and excessively large values according to Rust cast semantics.
237///
238/// # Examples
239///
240/// ```
241/// use ailloli_ui_core::Rect;
242/// use ailloli_ui_render_wgpu::cmd_bounds::snap_and_clamp_bounds;
243/// let (bounds, origin, size) = snap_and_clamp_bounds(
244///     Rect::new(1.25, 2.5, 3.0, 4.0), [100.0, 100.0]);
245/// assert_eq!(origin, [1.0, 2.0]);
246/// assert_eq!(size, [4, 5]);
247/// assert_eq!(bounds, Rect::new(1.0, 2.0, 4.0, 5.0));
248/// ```
249pub fn snap_and_clamp_bounds(bounds: Rect, surface: [f32; 2]) -> (Rect, [f32; 2], [u32; 2]) {
250    let [sw, sh] = surface;
251    let x0 = bounds.x.floor().max(0.0);
252    let y0 = bounds.y.floor().max(0.0);
253    let x1 = (bounds.x + bounds.w).ceil().min(sw);
254    let y1 = (bounds.y + bounds.h).ceil().min(sh);
255    let w = (x1 - x0).max(1.0);
256    let h = (y1 - y0).max(1.0);
257    let snapped = Rect::new(x0, y0, w, h);
258    (snapped, [x0, y0], [w as u32, h as u32])
259}
260
261/// Convert global physical scissor to local coordinates inside offscreen pass.
262///
263/// `None` remains `None`. Position is translated by `origin` and clamped to
264/// zero; width and height are capped independently to `local_size`. Empty
265/// results return `None`.
266///
267/// # Examples
268///
269/// ```
270/// use ailloli_ui_core::Rect;
271/// use ailloli_ui_render_wgpu::cmd_bounds::scissor_to_local;
272/// let local = scissor_to_local(Some(Rect::new(50.0, 50.0, 20.0, 10.0)),
273///     [40.0, 45.0], [100, 100]);
274/// assert_eq!(local, Some(Rect::new(10.0, 5.0, 20.0, 10.0)));
275/// ```
276pub fn scissor_to_local(
277    scissor: Option<Rect>,
278    origin: [f32; 2],
279    local_size: [u32; 2],
280) -> Option<Rect> {
281    let s = scissor?;
282    let lw = local_size[0] as f32;
283    let lh = local_size[1] as f32;
284    let local = Rect::new(
285        (s.x - origin[0]).max(0.0),
286        (s.y - origin[1]).max(0.0),
287        s.w.min(lw),
288        s.h.min(lh),
289    );
290    if local.w <= 0.0 || local.h <= 0.0 {
291        return None;
292    }
293    Some(local)
294}
295
296/// NDC quad for compositing an offscreen texture into destination rect.
297///
298/// Appends six vertices (two triangles) with UVs covering `[0, 1]` and returns
299/// their half-open `u32` range. `dest` and `surface` are physical pixels.
300///
301/// # Panics
302///
303/// Debug builds panic if the arena length cannot fit in `u32` during range
304/// arithmetic; such an arena is already too large for wgpu draw indices.
305///
306/// # Examples
307///
308/// ```
309/// use ailloli_ui_core::Rect;
310/// use ailloli_ui_render_wgpu::cmd_bounds::push_composite_quad;
311/// let mut vertices = Vec::new();
312/// let range = push_composite_quad(&mut vertices, [100.0, 50.0],
313///     Rect::new(0.0, 0.0, 10.0, 10.0), [1.0; 4]);
314/// assert_eq!(range, 0..6);
315/// assert_eq!(vertices.len(), 6);
316/// ```
317pub fn push_composite_quad(
318    arena: &mut Vec<crate::vertices::TexVertex>,
319    surface: [f32; 2],
320    dest: Rect,
321    tint: [f32; 4],
322) -> std::ops::Range<u32> {
323    let [w, h] = surface;
324    let x0 = dest.x;
325    let y0 = dest.y;
326    let x1 = dest.x + dest.w;
327    let y1 = dest.y + dest.h;
328    let start = arena.len() as u32;
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    arena.extend_from_slice(&[
334        crate::vertices::TexVertex {
335            pos: p0,
336            uv: [0.0, 0.0],
337            tint,
338        },
339        crate::vertices::TexVertex {
340            pos: p1,
341            uv: [1.0, 0.0],
342            tint,
343        },
344        crate::vertices::TexVertex {
345            pos: p2,
346            uv: [1.0, 1.0],
347            tint,
348        },
349        crate::vertices::TexVertex {
350            pos: p0,
351            uv: [0.0, 0.0],
352            tint,
353        },
354        crate::vertices::TexVertex {
355            pos: p2,
356            uv: [1.0, 1.0],
357            tint,
358        },
359        crate::vertices::TexVertex {
360            pos: p3,
361            uv: [0.0, 1.0],
362            tint,
363        },
364    ]);
365    start..arena.len() as u32
366}
367
368/// NDC quad in local offscreen space (origin top-left of the pass).
369///
370/// This has the same six-vertex layout as [`push_composite_quad`], but the
371/// supplied surface extent is the local offscreen allocation.
372///
373/// # Examples
374///
375/// ```
376/// use ailloli_ui_core::Rect;
377/// use ailloli_ui_render_wgpu::cmd_bounds::push_composite_quad_local;
378/// let mut vertices = Vec::new();
379/// let range = push_composite_quad_local(&mut vertices, [32.0, 32.0],
380///     Rect::new(4.0, 4.0, 8.0, 8.0), [0.5, 0.5, 0.5, 1.0]);
381/// assert_eq!((range.start, range.end), (0, 6));
382/// ```
383pub fn push_composite_quad_local(
384    arena: &mut Vec<crate::vertices::TexVertex>,
385    local_surface: [f32; 2],
386    dest: Rect,
387    tint: [f32; 4],
388) -> std::ops::Range<u32> {
389    let [w, h] = local_surface;
390    let x0 = dest.x;
391    let y0 = dest.y;
392    let x1 = dest.x + dest.w;
393    let y1 = dest.y + dest.h;
394    let start = arena.len() as u32;
395    let p0 = to_ndc(w, h, x0, y0);
396    let p1 = to_ndc(w, h, x1, y0);
397    let p2 = to_ndc(w, h, x1, y1);
398    let p3 = to_ndc(w, h, x0, y1);
399    arena.extend_from_slice(&[
400        crate::vertices::TexVertex {
401            pos: p0,
402            uv: [0.0, 0.0],
403            tint,
404        },
405        crate::vertices::TexVertex {
406            pos: p1,
407            uv: [1.0, 0.0],
408            tint,
409        },
410        crate::vertices::TexVertex {
411            pos: p2,
412            uv: [1.0, 1.0],
413            tint,
414        },
415        crate::vertices::TexVertex {
416            pos: p0,
417            uv: [0.0, 0.0],
418            tint,
419        },
420        crate::vertices::TexVertex {
421            pos: p2,
422            uv: [1.0, 1.0],
423            tint,
424        },
425        crate::vertices::TexVertex {
426            pos: p3,
427            uv: [0.0, 1.0],
428            tint,
429        },
430    ]);
431    start..arena.len() as u32
432}
433
434#[cfg(test)]
435/// Verifies command bounds, effect inflation, and local scissor translation.
436mod tests {
437    use super::*;
438    use crate::isolated_plan::{IsolatedEffect, IsolatedEffectChain};
439
440    #[test]
441    fn render_bounds_includes_blur_padding() {
442        let bounds = Rect::new(10.0, 10.0, 20.0, 20.0);
443        let effects = IsolatedEffectChain {
444            effects: vec![IsolatedEffect::Blur { radius_px: 8.0 }],
445        };
446        let inflated = inflate_for_effects(bounds, &effects);
447        let pad = 8.0 * BLUR_PADDING_FACTOR;
448        assert!((inflated.x - (bounds.x - pad)).abs() < 0.01);
449        assert!((inflated.w - (bounds.w + pad * 2.0)).abs() < 0.01);
450    }
451
452    #[test]
453    fn local_scissor_shifts_to_origin() {
454        let global = Rect::new(50.0, 50.0, 30.0, 30.0);
455        let local = scissor_to_local(Some(global), [40.0, 40.0], [128, 128]).unwrap();
456        assert!((local.x - 10.0).abs() < 0.01);
457        assert!((local.y - 10.0).abs() < 0.01);
458    }
459
460    #[test]
461    fn command_bounds_include_box_shadow_inflation() {
462        let cmds = vec![DrawCmd::BoxShadow(ailloli_ui_runtime::DrawBoxShadow {
463            rect: Rect::new(10.0, 20.0, 30.0, 10.0),
464            radius: ailloli_ui_core::Radius::uniform(4.0),
465            shadow: ailloli_ui_core::BoxShadow::new(
466                5.0,
467                -2.0,
468                3.0,
469                1.0,
470                ailloli_ui_core::Color::BLACK,
471            ),
472        })];
473
474        let bounds = union_cmd_bounds(&cmds, Scale::new(2.0)).expect("bounds");
475        assert_eq!(bounds, Rect::new(22.0, 28.0, 76.0, 36.0));
476    }
477
478    #[test]
479    fn command_bounds_include_ring_progress_rect() {
480        let cmds = vec![DrawCmd::RingProgress(
481            ailloli_ui_runtime::DrawRingProgress {
482                rect: Rect::new(10.0, 20.0, 30.0, 30.0),
483                thickness: 4.0,
484                fraction: 0.66,
485                track_color: ailloli_ui_core::Color::BLACK,
486                fill_color: ailloli_ui_core::Color::WHITE,
487                start_angle: -std::f32::consts::FRAC_PI_2,
488            },
489        )];
490
491        let bounds = union_cmd_bounds(&cmds, Scale::new(2.0)).expect("bounds");
492        assert_eq!(bounds, Rect::new(20.0, 40.0, 60.0, 60.0));
493    }
494
495    #[test]
496    fn command_bounds_include_polyline_width() {
497        let cmds = vec![DrawCmd::Polyline(ailloli_ui_runtime::DrawPolyline {
498            points: vec![
499                ailloli_ui_core::Point::new(10.0, 20.0),
500                ailloli_ui_core::Point::new(40.0, 30.0),
501            ],
502            stroke: ailloli_ui_core::StrokeStyle::new(4.0, ailloli_ui_core::Color::WHITE),
503        })];
504
505        let bounds = union_cmd_bounds(&cmds, Scale::new(2.0)).expect("bounds");
506        assert_eq!(bounds, Rect::new(14.0, 34.0, 72.0, 32.0));
507    }
508
509    #[test]
510    fn command_bounds_empty_polyline_is_zero() {
511        let cmds = vec![DrawCmd::Polyline(ailloli_ui_runtime::DrawPolyline {
512            points: vec![ailloli_ui_core::Point::new(f32::NAN, 20.0)],
513            stroke: ailloli_ui_core::StrokeStyle::new(4.0, ailloli_ui_core::Color::WHITE),
514        })];
515
516        let bounds = union_cmd_bounds(&cmds, Scale::new(2.0)).expect("bounds");
517        assert_eq!(bounds, Rect::new(0.0, 0.0, 0.0, 0.0));
518    }
519}