ailloli_ui_render_wgpu/passes/
stroke.rs

1//! Antialiased CPU tessellation for stroked polylines with bevel joins.
2
3use ailloli_ui_core::math::Scale;
4use ailloli_ui_core::Point;
5use ailloli_ui_runtime::DrawPolyline;
6
7use crate::passes::to_ndc;
8use crate::vertices::StrokeVertex;
9
10/// Physical-pixel segment length below which a line is treated as degenerate.
11const MIN_SEGMENT_LENGTH_PX: f32 = 0.001;
12/// Physical-pixel antialias fringe added on each side of a stroke.
13const AA_FRINGE_PX: f32 = 1.0;
14
15/// Appends antialiased triangles for a logical-coordinate polyline.
16///
17/// Stroke width and points are multiplied by DPR. Non-finite points, consecutive
18/// physical points closer than `0.001` pixel, nonpositive/non-finite widths, and
19/// transparent strokes are skipped. Each open path gets one-pixel AA fringes,
20/// square AA caps, and bevel joins; output is always a triangle-list multiple of
21/// three.
22///
23/// # Panics
24///
25/// Panics only if internal segment construction reports a nonempty collection
26/// whose final segment cannot be retrieved.
27///
28/// # Examples
29///
30/// ```
31/// use ailloli_ui_core::{math::Scale, Color, Point, StrokeStyle};
32/// use ailloli_ui_render_wgpu::passes::push_polyline_scaled;
33/// use ailloli_ui_runtime::DrawPolyline;
34/// let line = DrawPolyline { points: vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0)],
35///     stroke: StrokeStyle::new(2.0, Color::WHITE) };
36/// let mut vertices = Vec::new();
37/// push_polyline_scaled(&mut vertices, 100.0, 100.0, Scale::new(1.0), &line);
38/// assert_eq!(vertices.len(), 30);
39/// ```
40pub fn push_polyline_scaled(
41    out: &mut Vec<StrokeVertex>,
42    w: f32,
43    h: f32,
44    scale: Scale,
45    polyline: &DrawPolyline,
46) {
47    let width = polyline.stroke.width * scale.dpr;
48    if !width.is_finite() || width <= 0.0 || polyline.stroke.color.a <= 0.0 {
49        return;
50    }
51
52    let points = clean_points_physical(&polyline.points, scale);
53    if points.len() < 2 {
54        return;
55    }
56
57    let color = polyline.stroke.color.to_array();
58    let half_width = (width * 0.5).max(0.0);
59
60    let segments = build_segments(&points);
61    if segments.is_empty() {
62        return;
63    }
64
65    for segment in &segments {
66        push_segment_body(out, w, h, segment, half_width, color);
67    }
68    push_start_cap(out, w, h, &segments[0], half_width, color);
69    push_end_cap(
70        out,
71        w,
72        h,
73        segments.last().expect("non-empty segments"),
74        half_width,
75        color,
76    );
77    for pair in segments.windows(2) {
78        push_bevel_join(out, w, h, pair[0], pair[1], half_width, color);
79    }
80}
81
82/// Drops invalid and near-duplicate points while converting to physical pixels.
83fn clean_points_physical(points: &[Point], scale: Scale) -> Vec<[f32; 2]> {
84    let mut out = Vec::with_capacity(points.len());
85    for point in points {
86        if !point.x.is_finite() || !point.y.is_finite() {
87            continue;
88        }
89        let p = [point.x * scale.dpr, point.y * scale.dpr];
90        if out
91            .last()
92            .is_some_and(|last| distance(*last, p) < MIN_SEGMENT_LENGTH_PX)
93        {
94            continue;
95        }
96        out.push(p);
97    }
98    out
99}
100
101#[derive(Debug, Clone, Copy)]
102/// One normalized physical-pixel line segment and its left normal.
103struct Segment {
104    /// Physical-pixel start point.
105    a: [f32; 2],
106    /// Physical-pixel end point.
107    b: [f32; 2],
108    /// Unit direction from `a` to `b`.
109    dir: [f32; 2],
110    /// Perpendicular unit vector used to expand stroke width.
111    normal: [f32; 2],
112}
113
114/// Creates finite, nondegenerate segments between cleaned points.
115fn build_segments(points: &[[f32; 2]]) -> Vec<Segment> {
116    let mut segments = Vec::with_capacity(points.len().saturating_sub(1));
117    for pair in points.windows(2) {
118        let a = pair[0];
119        let b = pair[1];
120        let dx = b[0] - a[0];
121        let dy = b[1] - a[1];
122        let len = (dx * dx + dy * dy).sqrt();
123        if !len.is_finite() || len < MIN_SEGMENT_LENGTH_PX {
124            continue;
125        }
126        let dir = [dx / len, dy / len];
127        let normal = [-dir[1], dir[0]];
128        segments.push(Segment { a, b, dir, normal });
129    }
130    segments
131}
132
133/// Emits the opaque stroke body and transparent one-pixel side fringes.
134fn push_segment_body(
135    out: &mut Vec<StrokeVertex>,
136    w: f32,
137    h: f32,
138    segment: &Segment,
139    half_width: f32,
140    color: [f32; 4],
141) {
142    let a = segment.a;
143    let b = segment.b;
144    let normal = segment.normal;
145    let outer_width = half_width + AA_FRINGE_PX;
146    let transparent = [color[0], color[1], color[2], 0.0];
147
148    let a_l = add(a, mul(normal, half_width));
149    let b_l = add(b, mul(normal, half_width));
150    let b_r = add(b, mul(normal, -half_width));
151    let a_r = add(a, mul(normal, -half_width));
152    emit_quad(out, w, h, a_l, b_l, b_r, a_r, color, color, color, color);
153
154    let a_lo = add(a, mul(normal, outer_width));
155    let b_lo = add(b, mul(normal, outer_width));
156    emit_quad(
157        out,
158        w,
159        h,
160        a_lo,
161        b_lo,
162        b_l,
163        a_l,
164        transparent,
165        transparent,
166        color,
167        color,
168    );
169
170    let a_ro = add(a, mul(normal, -outer_width));
171    let b_ro = add(b, mul(normal, -outer_width));
172    emit_quad(
173        out,
174        w,
175        h,
176        a_r,
177        b_r,
178        b_ro,
179        a_ro,
180        color,
181        color,
182        transparent,
183        transparent,
184    );
185}
186
187/// Emits the one-pixel antialiasing fringe before the first endpoint.
188fn push_start_cap(
189    out: &mut Vec<StrokeVertex>,
190    w: f32,
191    h: f32,
192    segment: &Segment,
193    half_width: f32,
194    color: [f32; 4],
195) {
196    let a = segment.a;
197    let dir = segment.dir;
198    let normal = segment.normal;
199    let transparent = [color[0], color[1], color[2], 0.0];
200    let a_l = add(a, mul(normal, half_width));
201    let a_r = add(a, mul(normal, -half_width));
202    let start_outer_l = sub(a_l, mul(dir, AA_FRINGE_PX));
203    let start_outer_r = sub(a_r, mul(dir, AA_FRINGE_PX));
204    emit_quad(
205        out,
206        w,
207        h,
208        start_outer_l,
209        a_l,
210        a_r,
211        start_outer_r,
212        transparent,
213        color,
214        color,
215        transparent,
216    );
217}
218
219/// Emits the one-pixel antialiasing fringe after the last endpoint.
220fn push_end_cap(
221    out: &mut Vec<StrokeVertex>,
222    w: f32,
223    h: f32,
224    segment: &Segment,
225    half_width: f32,
226    color: [f32; 4],
227) {
228    let b = segment.b;
229    let dir = segment.dir;
230    let normal = segment.normal;
231    let transparent = [color[0], color[1], color[2], 0.0];
232    let b_l = add(b, mul(normal, half_width));
233    let b_r = add(b, mul(normal, -half_width));
234    let end_outer_l = add(b_l, mul(dir, AA_FRINGE_PX));
235    let end_outer_r = add(b_r, mul(dir, AA_FRINGE_PX));
236    emit_quad(
237        out,
238        w,
239        h,
240        b_l,
241        end_outer_l,
242        end_outer_r,
243        b_r,
244        color,
245        transparent,
246        transparent,
247        color,
248    );
249}
250
251/// Fills a connected pair with a bevel and matching outside AA fringes.
252fn push_bevel_join(
253    out: &mut Vec<StrokeVertex>,
254    w: f32,
255    h: f32,
256    prev: Segment,
257    next: Segment,
258    half_width: f32,
259    color: [f32; 4],
260) {
261    if distance(prev.b, next.a) > MIN_SEGMENT_LENGTH_PX {
262        return;
263    }
264
265    let p = prev.b;
266    let transparent = [color[0], color[1], color[2], 0.0];
267    let outer_width = half_width + AA_FRINGE_PX;
268
269    let prev_l = add(p, mul(prev.normal, half_width));
270    let next_l = add(p, mul(next.normal, half_width));
271    let prev_r = add(p, mul(prev.normal, -half_width));
272    let next_r = add(p, mul(next.normal, -half_width));
273    emit_triangle(out, w, h, p, prev_l, next_l, color, color, color);
274    emit_triangle(out, w, h, p, next_r, prev_r, color, color, color);
275
276    let prev_lo = add(p, mul(prev.normal, outer_width));
277    let next_lo = add(p, mul(next.normal, outer_width));
278    emit_quad(
279        out,
280        w,
281        h,
282        prev_lo,
283        next_lo,
284        next_l,
285        prev_l,
286        transparent,
287        transparent,
288        color,
289        color,
290    );
291
292    let prev_ro = add(p, mul(prev.normal, -outer_width));
293    let next_ro = add(p, mul(next.normal, -outer_width));
294    emit_quad(
295        out,
296        w,
297        h,
298        next_r,
299        prev_r,
300        prev_ro,
301        next_ro,
302        color,
303        color,
304        transparent,
305        transparent,
306    );
307}
308
309#[allow(clippy::too_many_arguments)]
310/// Emits two triangles for a four-corner physical-pixel quad.
311fn emit_quad(
312    out: &mut Vec<StrokeVertex>,
313    w: f32,
314    h: f32,
315    p0: [f32; 2],
316    p1: [f32; 2],
317    p2: [f32; 2],
318    p3: [f32; 2],
319    c0: [f32; 4],
320    c1: [f32; 4],
321    c2: [f32; 4],
322    c3: [f32; 4],
323) {
324    out.extend_from_slice(&[
325        vertex(w, h, p0, c0),
326        vertex(w, h, p1, c1),
327        vertex(w, h, p2, c2),
328        vertex(w, h, p0, c0),
329        vertex(w, h, p2, c2),
330        vertex(w, h, p3, c3),
331    ]);
332}
333
334#[allow(clippy::too_many_arguments)]
335/// Emits one physical-pixel triangle with per-corner colors.
336fn emit_triangle(
337    out: &mut Vec<StrokeVertex>,
338    w: f32,
339    h: f32,
340    p0: [f32; 2],
341    p1: [f32; 2],
342    p2: [f32; 2],
343    c0: [f32; 4],
344    c1: [f32; 4],
345    c2: [f32; 4],
346) {
347    out.extend_from_slice(&[
348        vertex(w, h, p0, c0),
349        vertex(w, h, p1, c1),
350        vertex(w, h, p2, c2),
351    ]);
352}
353
354/// Converts a physical-pixel position and color into a stroke vertex.
355fn vertex(w: f32, h: f32, p: [f32; 2], color: [f32; 4]) -> StrokeVertex {
356    StrokeVertex {
357        pos: to_ndc(w, h, p[0], p[1]),
358        pos_px: p,
359        color,
360    }
361}
362
363/// Adds two physical-pixel vectors component-wise.
364fn add(a: [f32; 2], b: [f32; 2]) -> [f32; 2] {
365    [a[0] + b[0], a[1] + b[1]]
366}
367
368/// Subtracts two physical-pixel vectors component-wise.
369fn sub(a: [f32; 2], b: [f32; 2]) -> [f32; 2] {
370    [a[0] - b[0], a[1] - b[1]]
371}
372
373/// Multiplies a physical-pixel vector by a scalar.
374fn mul(a: [f32; 2], s: f32) -> [f32; 2] {
375    [a[0] * s, a[1] * s]
376}
377
378/// Returns Euclidean distance between two physical-pixel positions.
379fn distance(a: [f32; 2], b: [f32; 2]) -> f32 {
380    let dx = b[0] - a[0];
381    let dy = b[1] - a[1];
382    (dx * dx + dy * dy).sqrt()
383}
384
385#[cfg(test)]
386/// Verifies stroke tessellation, filtering, scale, caps, and bevel joins.
387mod tests {
388    use super::*;
389    use ailloli_ui_core::{Color, StrokeStyle};
390
391    /// Creates a white test polyline with a caller-selected logical width.
392    fn polyline(points: Vec<Point>, width: f32) -> DrawPolyline {
393        DrawPolyline {
394            points,
395            stroke: StrokeStyle::new(width, Color::WHITE),
396        }
397    }
398
399    /// Converts renderer NDC Y back to top-origin physical pixels.
400    fn ndc_y_to_px(surface_h: f32, ndc_y: f32) -> f32 {
401        (1.0 - ndc_y) * surface_h * 0.5
402    }
403
404    #[test]
405    fn horizontal_vertical_and_diagonal_segments_produce_triangles() {
406        for points in [
407            vec![Point::new(10.0, 10.0), Point::new(40.0, 10.0)],
408            vec![Point::new(10.0, 10.0), Point::new(10.0, 40.0)],
409            vec![Point::new(10.0, 10.0), Point::new(40.0, 40.0)],
410        ] {
411            let mut out = Vec::new();
412            push_polyline_scaled(
413                &mut out,
414                100.0,
415                100.0,
416                Scale::new(1.0),
417                &polyline(points, 3.0),
418            );
419            assert!(!out.is_empty());
420            assert_eq!(out.len() % 3, 0);
421        }
422    }
423
424    #[test]
425    fn skips_non_finite_and_too_short_segments() {
426        let mut out = Vec::new();
427        push_polyline_scaled(
428            &mut out,
429            100.0,
430            100.0,
431            Scale::new(1.0),
432            &polyline(
433                vec![
434                    Point::new(10.0, 10.0),
435                    Point::new(10.000_1, 10.0),
436                    Point::new(f32::NAN, 20.0),
437                    Point::new(30.0, 10.0),
438                ],
439                3.0,
440            ),
441        );
442
443        assert!(!out.is_empty());
444        assert_eq!(out.len(), 30);
445    }
446
447    #[test]
448    fn logical_width_is_scaled_to_physical_pixels() {
449        let mut out = Vec::new();
450        push_polyline_scaled(
451            &mut out,
452            200.0,
453            100.0,
454            Scale::new(2.0),
455            &polyline(vec![Point::new(10.0, 10.0), Point::new(30.0, 10.0)], 3.0),
456        );
457
458        let (min_y, max_y) = out.iter().fold((f32::MAX, f32::MIN), |(min_y, max_y), v| {
459            let y = v.pos_px[1];
460            let y_from_ndc = ndc_y_to_px(100.0, v.pos[1]);
461            assert!((y - y_from_ndc).abs() <= 0.001);
462            (min_y.min(y), max_y.max(y))
463        });
464
465        assert!((min_y - 16.0).abs() <= 0.001, "min_y={min_y}");
466        assert!((max_y - 24.0).abs() <= 0.001, "max_y={max_y}");
467    }
468
469    #[test]
470    fn zigzag_generates_bevel_joins_without_internal_caps() {
471        let mut out = Vec::new();
472        push_polyline_scaled(
473            &mut out,
474            200.0,
475            120.0,
476            Scale::new(1.0),
477            &polyline(
478                vec![
479                    Point::new(10.0, 60.0),
480                    Point::new(50.0, 20.0),
481                    Point::new(90.0, 70.0),
482                ],
483                4.0,
484            ),
485        );
486
487        assert!(
488            out.len() > 60,
489            "expected join geometry, vertices={}",
490            out.len()
491        );
492        assert_eq!(out.len() % 3, 0);
493    }
494}