1use 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
10const MIN_SEGMENT_LENGTH_PX: f32 = 0.001;
12const AA_FRINGE_PX: f32 = 1.0;
14
15pub 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
82fn 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)]
102struct Segment {
104 a: [f32; 2],
106 b: [f32; 2],
108 dir: [f32; 2],
110 normal: [f32; 2],
112}
113
114fn 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
133fn 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
187fn 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
219fn 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
251fn 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)]
310fn 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)]
335fn 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
354fn 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
363fn add(a: [f32; 2], b: [f32; 2]) -> [f32; 2] {
365 [a[0] + b[0], a[1] + b[1]]
366}
367
368fn sub(a: [f32; 2], b: [f32; 2]) -> [f32; 2] {
370 [a[0] - b[0], a[1] - b[1]]
371}
372
373fn mul(a: [f32; 2], s: f32) -> [f32; 2] {
375 [a[0] * s, a[1] * s]
376}
377
378fn 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)]
386mod tests {
388 use super::*;
389 use ailloli_ui_core::{Color, StrokeStyle};
390
391 fn polyline(points: Vec<Point>, width: f32) -> DrawPolyline {
393 DrawPolyline {
394 points,
395 stroke: StrokeStyle::new(width, Color::WHITE),
396 }
397 }
398
399 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}