Skip to main content

hephaestus/primitives/
ribbon.rs

1//! Polyline- and polygon-as-ribbon tessellation.
2//!
3//! A "ribbon" is a stroked open polyline or closed polygon expressed
4//! as a triangle [`Mesh`] with per-vertex colour and per-vertex
5//! half-width. Drawing happens via
6//! [`SceneBuilder::draw_mesh`](crate::scene::SceneBuilder); the Vello
7//! backend decomposes the mesh into per-triangle linear-gradient
8//! fills, which gives perfect Gouraud-equivalent colour blending
9//! along ribbon strips (because the two shoulders at each polyline
10//! vertex carry the same colour, so the gradient axis runs cleanly
11//! between adjacent segments).
12//!
13//! # Entry points
14//!
15//! Open polylines (with end caps):
16//! - [`polyline_ribbon`] — constant colour, constant half-width.
17//! - [`polyline_gradient`] — per-vertex colour, constant half-width.
18//! - [`polyline_ribbon_full`] — per-vertex colour and per-vertex
19//!   half-width.
20//!
21//! Closed polygons (no caps; the loop closes from `points[n - 1]`
22//! back to `points[0]` — do not repeat the first vertex):
23//! - [`polygon_ribbon`] — constant colour, constant half-width.
24//! - [`polygon_gradient`] — per-vertex colour, constant half-width.
25//! - [`polygon_ribbon_full`] — per-vertex colour and per-vertex
26//!   half-width.
27//!
28//! Quad-strip band between two arbitrary co-indexed polylines:
29//! - [`ribbon_band_mesh`] — fills the band between curve A and curve
30//!   B with per-vertex colour on each side. Used by `RibbonGeom`
31//!   under free-form orientation and under non-linear projections
32//!   when fill varies.
33//!
34//! Caps: butt / square / round (open only — [`RibbonOptions::cap`]
35//! is ignored by the `polygon_*` entry points). Joins: miter (with
36//! auto-bevel fallback when the miter exceeds
37//! [`RibbonOptions::miter_limit`]), bevel, round.
38//!
39//! All distances are in **panel pixels**. Callers convert from pt at
40//! their own draw sites (`px = pt * dpi / 72.0`).
41
42use std::ops::RangeInclusive;
43
44use super::tolerance::{
45    ARC_FAN_MAX_STEP, ARC_FAN_MIN_STEP, ARC_FAN_TOLERANCE, DEGENERATE_EPS as EPSILON,
46};
47use crate::color::Color;
48use crate::geometry::{Point, Vec2};
49use crate::mesh::Mesh;
50use crate::stroke::{Cap, Join};
51
52/// Colour used by the entry points that accept an optional per-vertex
53/// colour slice when none is supplied.
54const DEFAULT_COLOR: Color = Color::new([0.0, 0.0, 0.0, 1.0]);
55
56/// Segment-count bounds for a round **cap** fan. The floor keeps a
57/// hairline cap from collapsing to a triangle; the ceiling caps the
58/// vertex cost of a very wide one.
59const CAP_FAN_SEGMENTS: RangeInclusive<usize> = 4..=64;
60/// Segment-count bounds for a round **join** fan. A join sweeps only
61/// the turn angle rather than a half-circle, so it needs fewer
62/// segments than a cap at the same radius.
63const JOIN_FAN_SEGMENTS: RangeInclusive<usize> = 2..=32;
64
65/// Per-segment seam-bleed in panel pixels. Each interior quad is
66/// extended this far past its natural endpoint at both ends along the
67/// local segment tangent, so adjacent quads overlap and SrcOver
68/// compositing on the overlap region renders fully opaque — hiding
69/// the AA seam that would otherwise appear at segment boundaries.
70/// `0.75 px` is enough to cover a 1-px AA edge on each side while
71/// keeping the gradient-axis distortion below 1.5% for typical
72/// segment lengths.
73const SEAM_BLEED_PX: f64 = 0.75;
74
75// ── Options ────────────────────────────────────────────────────────────────
76
77/// Tessellation options for [`polyline_ribbon`] / [`polyline_gradient`]
78/// / [`polyline_ribbon_full`]. Caps and joins reuse [`crate::stroke::Cap`]
79/// and [`crate::stroke::Join`] — same three variants each.
80#[derive(Clone, Copy, Debug)]
81pub struct RibbonOptions {
82    /// Half-width in panel pixels. Used by entry points that don't
83    /// take a per-vertex half-width slice; ignored by
84    /// [`polyline_ribbon_full`] when `half_widths` is `Some`.
85    pub half_width: f64,
86    /// End-cap style for open ribbons. Ignored by `polygon_*` entry
87    /// points (closed loops have no endpoints to cap).
88    pub cap: Cap,
89    /// Corner-join style at each interior vertex.
90    pub join: Join,
91    /// Maximum ratio `1 / cos(turn_angle / 2)` allowed at a mitre
92    /// join. Joins exceeding this fall back to bevel for that join
93    /// only. Matches the SVG default of `4.0`.
94    pub miter_limit: f64,
95}
96
97impl Default for RibbonOptions {
98    fn default() -> Self {
99        Self {
100            half_width: 1.0,
101            cap: Cap::Butt,
102            join: Join::Miter,
103            miter_limit: 4.0,
104        }
105    }
106}
107
108// ── Public entry points ─────────────────────────────────────────────────────
109
110/// Constant-colour, constant-width ribbon. Equivalent to a uniformly
111/// stroked polyline expressed as a mesh.
112pub fn polyline_ribbon(points: &[Point], color: Color, opts: &RibbonOptions) -> Mesh {
113    ribbon(
114        "polyline_ribbon",
115        points,
116        ColorSource::Constant(color),
117        None,
118        opts,
119        false,
120    )
121}
122
123/// Per-vertex coloured, constant-width ribbon. `colors.len()` must
124/// equal `points.len()`.
125pub fn polyline_gradient(points: &[Point], colors: &[Color], opts: &RibbonOptions) -> Mesh {
126    ribbon(
127        "polyline_gradient",
128        points,
129        ColorSource::PerVertex(colors),
130        None,
131        opts,
132        false,
133    )
134}
135
136/// Full ribbon: optionally per-vertex coloured, optionally per-vertex
137/// half-width. `colors` defaults to opaque black and `half_widths` to
138/// [`RibbonOptions::half_width`]; a supplied slice must match
139/// `points.len()`.
140pub fn polyline_ribbon_full(
141    points: &[Point],
142    colors: Option<&[Color]>,
143    half_widths: Option<&[f64]>,
144    opts: &RibbonOptions,
145) -> Mesh {
146    ribbon(
147        "polyline_ribbon_full",
148        points,
149        ColorSource::from_optional(colors),
150        half_widths,
151        opts,
152        false,
153    )
154}
155
156/// Constant-colour, constant-width closed-polygon ribbon. The loop
157/// closes from `points[n - 1]` back to `points[0]` — do **not**
158/// repeat the first vertex. [`RibbonOptions::cap`] is ignored (a
159/// closed loop has no endpoints to cap). Returns an empty mesh when
160/// `points.len() < 3`.
161pub fn polygon_ribbon(points: &[Point], color: Color, opts: &RibbonOptions) -> Mesh {
162    ribbon(
163        "polygon_ribbon",
164        points,
165        ColorSource::Constant(color),
166        None,
167        opts,
168        true,
169    )
170}
171
172/// Per-vertex coloured, constant-width closed-polygon ribbon.
173/// `colors.len()` must equal `points.len()`. The wrap segment
174/// interpolates `colors[n - 1] → colors[0]` like any other segment,
175/// so the gradient closes seamlessly. See [`polygon_ribbon`] for the
176/// closure convention.
177pub fn polygon_gradient(points: &[Point], colors: &[Color], opts: &RibbonOptions) -> Mesh {
178    ribbon(
179        "polygon_gradient",
180        points,
181        ColorSource::PerVertex(colors),
182        None,
183        opts,
184        true,
185    )
186}
187
188/// Full closed-polygon ribbon: optionally per-vertex coloured,
189/// optionally per-vertex half-width. `colors` defaults to opaque black
190/// and `half_widths` to [`RibbonOptions::half_width`]; a supplied slice
191/// must match `points.len()`. See [`polygon_ribbon`] for the closure
192/// convention.
193pub fn polygon_ribbon_full(
194    points: &[Point],
195    colors: Option<&[Color]>,
196    half_widths: Option<&[f64]>,
197    opts: &RibbonOptions,
198) -> Mesh {
199    ribbon(
200        "polygon_ribbon_full",
201        points,
202        ColorSource::from_optional(colors),
203        half_widths,
204        opts,
205        true,
206    )
207}
208
209/// Validate the per-vertex slices on behalf of one of the six entry
210/// points — `who` names it in the panic — then tessellate.
211fn ribbon(
212    who: &str,
213    points: &[Point],
214    colors: ColorSource<'_>,
215    half_widths: Option<&[f64]>,
216    opts: &RibbonOptions,
217    closed: bool,
218) -> Mesh {
219    if let ColorSource::PerVertex(c) = colors {
220        assert_eq!(
221            points.len(),
222            c.len(),
223            "{who}: points.len() ({}) != colors.len() ({})",
224            points.len(),
225            c.len(),
226        );
227    }
228    if let Some(w) = half_widths {
229        assert_eq!(
230            points.len(),
231            w.len(),
232            "{who}: points.len() ({}) != half_widths.len() ({})",
233            points.len(),
234            w.len(),
235        );
236    }
237    ribbon_inner(points, colors, half_widths, opts, closed)
238}
239
240/// Build a filled quad-strip mesh between two co-indexed polylines.
241///
242/// `curve_a` and `curve_b` must have the same length. Each interior
243/// segment from index `i` to `i + 1` emits a quad with corners
244/// `(curve_a[i], curve_b[i], curve_b[i + 1], curve_a[i + 1])`,
245/// tessellated as two triangles in the canonical `[A, B, C, A, C, D]`
246/// pattern that the Vello backend's quad-pair detector folds into a
247/// single bilinear-gradient quad fill. Per-vertex colours come from
248/// `colors_a` (curve A side) and `colors_b` (curve B side).
249///
250/// Returns an empty mesh when either curve has fewer than two points.
251/// Panics on length mismatch.
252///
253/// Adjacent quads overlap by a small fraction of `SEAM_BLEED_PX` along
254/// the local sweep tangent at every interior boundary so the AA seam
255/// between independent quad fills is hidden. The overlap is much
256/// smaller than the polyline-ribbon's `SEAM_BLEED_PX` because each
257/// quad of the band gets its own linear-gradient brush (the
258/// Vello backend's quad-pair detector folds the strip into one gradient
259/// fill per quad); two adjacent quads paint the overlap region twice
260/// with the **same** boundary colour, so SrcOver compositing stacks
261/// the alpha when the fill is translucent and reveals the seam as a
262/// darker band. A small bleed is enough to bridge the AA edge without
263/// producing a perceptible double-coat.
264///
265/// For a uniformly-coloured band a plain `fill` on the path that
266/// traces curve A forward then curve B in reverse is cheaper — the
267/// per-vertex colour is the point of the mesh path.
268pub fn ribbon_band_mesh(
269    curve_a: &[Point],
270    curve_b: &[Point],
271    colors_a: &[Color],
272    colors_b: &[Color],
273) -> Mesh {
274    assert_eq!(
275        curve_a.len(),
276        curve_b.len(),
277        "ribbon_band_mesh: curve_a.len() ({}) != curve_b.len() ({})",
278        curve_a.len(),
279        curve_b.len(),
280    );
281    assert_eq!(
282        curve_a.len(),
283        colors_a.len(),
284        "ribbon_band_mesh: colors_a.len() must match curve_a.len()"
285    );
286    assert_eq!(
287        curve_b.len(),
288        colors_b.len(),
289        "ribbon_band_mesh: colors_b.len() must match curve_b.len()"
290    );
291    let n = curve_a.len();
292    if n < 2 {
293        return Mesh::new(Vec::new(), Vec::new(), Vec::new());
294    }
295
296    let segs = n - 1;
297    let mut vertices: Vec<Point> = Vec::with_capacity(4 * segs);
298    let mut colors: Vec<Color> = Vec::with_capacity(4 * segs);
299    let mut indices: Vec<u32> = Vec::with_capacity(6 * segs);
300
301    for i in 0..segs {
302        // Sweep tangent for segment i: midpoint(a[i], b[i]) → midpoint(a[i+1], b[i+1]).
303        let m0 = Vec2::new(
304            (curve_a[i].x + curve_b[i].x) * 0.5,
305            (curve_a[i].y + curve_b[i].y) * 0.5,
306        );
307        let m1 = Vec2::new(
308            (curve_a[i + 1].x + curve_b[i + 1].x) * 0.5,
309            (curve_a[i + 1].y + curve_b[i + 1].y) * 0.5,
310        );
311        let delta = m1 - m0;
312        let len = delta.hypot();
313        let tangent = if len > EPSILON {
314            delta / len
315        } else {
316            Vec2::new(0.0, 0.0)
317        };
318        // Bleed interior seams; outer-most quad edges (segment 0's near
319        // end and the last segment's far end) carry the band's actual
320        // endpoints — leave them alone so any caller-drawn endcap lines
321        // up flush. Use a third of the polyline-ribbon's bleed: enough
322        // to cover the AA edge without stacking enough alpha to be
323        // perceptible on translucent fills.
324        let interior_bleed = SEAM_BLEED_PX / 3.0;
325        let near_bleed = if i > 0 { interior_bleed } else { 0.0 };
326        let far_bleed = if i + 1 < segs { interior_bleed } else { 0.0 };
327        let near_off = tangent * near_bleed;
328        let far_off = tangent * far_bleed;
329
330        let base = vertices.len() as u32;
331        vertices.push(curve_a[i] - near_off);
332        vertices.push(curve_b[i] - near_off);
333        vertices.push(curve_b[i + 1] + far_off);
334        vertices.push(curve_a[i + 1] + far_off);
335        colors.push(colors_a[i]);
336        colors.push(colors_b[i]);
337        colors.push(colors_b[i + 1]);
338        colors.push(colors_a[i + 1]);
339        indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
340    }
341
342    Mesh::new(vertices, colors, indices)
343}
344
345// ── Inner machinery ─────────────────────────────────────────────────────────
346
347#[derive(Clone, Copy)]
348enum ColorSource<'a> {
349    Constant(Color),
350    PerVertex(&'a [Color]),
351}
352
353impl<'a> ColorSource<'a> {
354    /// Per-vertex when a colour slice is supplied, opaque black
355    /// otherwise.
356    fn from_optional(colors: Option<&'a [Color]>) -> Self {
357        match colors {
358            Some(c) => ColorSource::PerVertex(c),
359            None => ColorSource::Constant(DEFAULT_COLOR),
360        }
361    }
362
363    fn at(&self, i: usize) -> Color {
364        match self {
365            ColorSource::Constant(c) => *c,
366            ColorSource::PerVertex(slice) => slice[i],
367        }
368    }
369}
370
371/// Per-vertex layout info computed in pass 1.
372struct VertexLayout {
373    /// Inbound shoulder pair `(left, right)` — at the end of the
374    /// previous segment. Equal to `out` for mitre / endpoint cases.
375    in_left: Point,
376    in_right: Point,
377    /// Outbound shoulder pair `(left, right)` — at the start of the
378    /// next segment.
379    out_left: Point,
380    out_right: Point,
381    /// `true` when the vertex is a bevel join (in_pair ≠ out_pair). A
382    /// bevel-fill triangle is emitted on the outside of the turn.
383    is_bevel: bool,
384    /// Which side bulges out at this bevel join — `true` for "left",
385    /// `false` for "right". Ignored when `is_bevel` is false.
386    bevel_outside_left: bool,
387}
388
389fn ribbon_inner(
390    points: &[Point],
391    colors: ColorSource<'_>,
392    half_widths: Option<&[f64]>,
393    opts: &RibbonOptions,
394    closed: bool,
395) -> Mesh {
396    let n = points.len();
397    // Open polylines need >= 2 points; closed polygons need >= 3
398    // (anything less is degenerate).
399    let min_pts = if closed { 3 } else { 2 };
400    if n < min_pts {
401        return Mesh::new(Vec::new(), Vec::new(), Vec::new());
402    }
403
404    // Per-vertex half-widths (panel-px). Falls back to opts.half_width.
405    let hw = |i: usize| -> f64 {
406        match half_widths {
407            Some(w) => w[i],
408            None => opts.half_width,
409        }
410    };
411
412    // Compute unit segment tangents. `seg_tangent[i]` is the tangent
413    // of segment `points[i] → points[(i + 1) % n]`. Open polylines
414    // have n-1 segments; closed polygons have n (the last one wraps
415    // back to vertex 0).
416    let n_segs = if closed { n } else { n - 1 };
417    let mut seg_tangent: Vec<Vec2> = Vec::with_capacity(n_segs);
418    for i in 0..n_segs {
419        let delta = points[(i + 1) % n] - points[i];
420        let len = delta.hypot();
421        if len <= EPSILON {
422            // Degenerate segment — re-use last tangent if any,
423            // otherwise +x. The resulting ribbon will still be valid;
424            // a duplicated polyline vertex just creates a zero-area
425            // quad.
426            let last = seg_tangent.last().copied().unwrap_or(Vec2::new(1.0, 0.0));
427            seg_tangent.push(last);
428        } else {
429            seg_tangent.push(delta / len);
430        }
431    }
432
433    // Compute per-vertex layout.
434    let mut layouts: Vec<VertexLayout> = Vec::with_capacity(n);
435    for i in 0..n {
436        // For closed loops vertex 0's inbound segment is the wrap
437        // (seg_tangent[n - 1]), and vertex n-1's outbound is the same
438        // wrap. For open polylines the endpoint branch below picks
439        // whichever tangent it actually needs, so the dummy values
440        // here are unused.
441        let t_in = if i == 0 {
442            if closed {
443                seg_tangent[n - 1]
444            } else {
445                seg_tangent[0]
446            }
447        } else {
448            seg_tangent[i - 1]
449        };
450        let t_out = if i + 1 == n {
451            if closed {
452                seg_tangent[n - 1]
453            } else {
454                seg_tangent[n - 2]
455            }
456        } else {
457            seg_tangent[i]
458        };
459        let pi = points[i];
460        let w = hw(i);
461
462        if !closed && (i == 0 || i + 1 == n) {
463            // Endpoint: single perpendicular offset.
464            let t = if i == 0 { t_out } else { t_in };
465            let n_left = perp_left(t);
466            let l = pi + n_left * w;
467            let r = pi - n_left * w;
468            layouts.push(VertexLayout {
469                in_left: l,
470                in_right: r,
471                out_left: l,
472                out_right: r,
473                is_bevel: false,
474                bevel_outside_left: false,
475            });
476            continue;
477        }
478
479        // Interior vertex.
480        let perp_in = perp_left(t_in);
481        let perp_out = perp_left(t_out);
482        // Determine outside direction: a left turn (cross > 0) bulges
483        // on the right side; right turn bulges on the left.
484        let cross = t_in.x * t_out.y - t_in.y * t_out.x;
485        let dot = t_in.x * t_out.x + t_in.y * t_out.y;
486        let bevel_outside_left = cross < 0.0;
487
488        // Try miter: shoulder pair at the bisector position.
489        let denom = 1.0 + dot;
490        let miter_mag = if denom > EPSILON {
491            // 1 / cos(α/2) where α is the turn angle. Equivalent to
492            // `(perp_in + perp_out) / denom`'s magnitude divided by 1
493            // (the unit perpendicular length). Cheaper to compute via
494            // the half-angle identity.
495            (2.0 / denom).sqrt()
496        } else {
497            f64::INFINITY
498        };
499
500        let want_miter = match opts.join {
501            Join::Miter => miter_mag <= opts.miter_limit && denom > EPSILON,
502            // Round and bevel both emit two shoulder pairs; round
503            // additionally fills the outside notch with a fan, bevel
504            // fills it with one triangle.
505            _ => false,
506        };
507
508        if want_miter {
509            let mitre = (perp_in + perp_out) * (w / denom);
510            let l = pi + mitre;
511            let r = pi - mitre;
512            layouts.push(VertexLayout {
513                in_left: l,
514                in_right: r,
515                out_left: l,
516                out_right: r,
517                is_bevel: false,
518                bevel_outside_left,
519            });
520        } else {
521            // Bevel (or round, handled as bevel + fan in the emit
522            // step). Two shoulder pairs perpendicular to each segment.
523            let in_l = pi + perp_in * w;
524            let in_r = pi - perp_in * w;
525            let out_l = pi + perp_out * w;
526            let out_r = pi - perp_out * w;
527            layouts.push(VertexLayout {
528                in_left: in_l,
529                in_right: in_r,
530                out_left: out_l,
531                out_right: out_r,
532                is_bevel: true,
533                bevel_outside_left,
534            });
535        }
536    }
537
538    // Build the mesh. Output buffers.
539    let mut vertices: Vec<Point> = Vec::new();
540    let mut vcolors: Vec<Color> = Vec::new();
541    let mut indices: Vec<u32> = Vec::new();
542
543    // Helper: push a single vertex with colour, return its index.
544    let push_vertex =
545        |vertices: &mut Vec<Point>, vcolors: &mut Vec<Color>, p: Point, c: Color| -> u32 {
546            let idx = vertices.len() as u32;
547            vertices.push(p);
548            vcolors.push(c);
549            idx
550        };
551
552    // Emission order: path-order. For a self-intersecting polyline,
553    // later geometry draws on top of earlier geometry under SrcOver —
554    // so emitting "start cap → segments → joins → end cap" in path
555    // order ensures the path's tail correctly occludes its head when
556    // they cross. The previous ordering (caps last) caused the start
557    // cap to draw OVER segments that happened to pass through it.
558    //
559    // **Seam-bleed**: each interior segment quad is extended by
560    // `SEAM_BLEED_PX` along its local tangent at both ends, so
561    // adjacent quads overlap by ~2 × bleed in their shared boundary
562    // region. SrcOver compositing on the overlap renders fully
563    // opaque, eliminating the AA seam between adjacent fills. The
564    // gradient stops are computed against the original (unbled)
565    // axis, so the bleed introduces a tiny ε/L colour shift at the
566    // original endpoints — invisible for typical segment lengths.
567    // Endpoint edges (segment 0's near / last segment's far) are NOT
568    // bled, so cap geometry attaches at the natural shoulder
569    // positions.
570
571    // 1. Start cap (open polylines only — closed polygons have no
572    //    endpoints to cap).
573    if !closed {
574        emit_cap(
575            &mut vertices,
576            &mut vcolors,
577            &mut indices,
578            points[0],
579            layouts[0].out_left,
580            layouts[0].out_right,
581            -seg_tangent[0],
582            colors.at(0),
583            opts.cap,
584            hw(0),
585        );
586    }
587
588    // 2. Per-segment quads, interleaved with joins at the segment's
589    //    *end* vertex (the start vertex of the next segment).
590    //
591    // Endpoint-edge bleed: for caps with geometry (square / round),
592    // bleed the segment's endpoint edge **into the cap region** so
593    // the segment overlaps the cap's interior — eliminating the AA
594    // seam between the segment quad and the cap polygon. For butt
595    // caps there's no cap geometry, so the bleed would just extend
596    // the line by ε past its nominal endpoint — skip it. For closed
597    // polygons every segment is interior, so the cap-bleed path is
598    // unused.
599    let cap_bleed_amount = match opts.cap {
600        Cap::Butt => 0.0,
601        Cap::Square | Cap::Round => SEAM_BLEED_PX,
602    };
603    for i in 0..n_segs {
604        let i_next = (i + 1) % n;
605        let ci = colors.at(i);
606        let cj = colors.at(i_next);
607        let t = seg_tangent[i];
608        // For closed loops every boundary is interior; for open
609        // polylines the segment's near boundary is the start cap when
610        // i == 0, and the far boundary is the end cap when i == n-2.
611        let near_bleed_amount = if closed || i > 0 {
612            SEAM_BLEED_PX
613        } else {
614            cap_bleed_amount
615        };
616        let far_bleed_amount = if closed || i + 1 < n - 1 {
617            SEAM_BLEED_PX
618        } else {
619            cap_bleed_amount
620        };
621        let near_bleed = t * near_bleed_amount;
622        let far_bleed = t * far_bleed_amount;
623        let a_pos = layouts[i].out_left - near_bleed;
624        let b_pos = layouts[i].out_right - near_bleed;
625        let c_pos = layouts[i_next].in_right + far_bleed;
626        let d_pos = layouts[i_next].in_left + far_bleed;
627        let a = push_vertex(&mut vertices, &mut vcolors, a_pos, ci);
628        let b = push_vertex(&mut vertices, &mut vcolors, b_pos, ci);
629        let c = push_vertex(&mut vertices, &mut vcolors, c_pos, cj);
630        let d = push_vertex(&mut vertices, &mut vcolors, d_pos, cj);
631        indices.extend_from_slice(&[a, b, c, a, c, d]);
632
633        // Join at vertex i_next, if it's a bevel/round.
634        // Open: only interior vertices (vertex 0 and n-1 are caps).
635        // Closed: every vertex is interior, including the wrap-back
636        // to vertex 0 emitted by the last segment.
637        let is_interior_join = closed || i_next < n - 1;
638        if is_interior_join && layouts[i_next].is_bevel {
639            emit_join_fill(
640                &mut vertices,
641                &mut vcolors,
642                &mut indices,
643                points[i_next],
644                &layouts[i_next],
645                colors.at(i_next),
646                opts.join,
647            );
648        }
649    }
650
651    // 3. End cap (open polylines only).
652    if !closed {
653        let last = n - 1;
654        emit_cap(
655            &mut vertices,
656            &mut vcolors,
657            &mut indices,
658            points[last],
659            layouts[last].in_right,
660            layouts[last].in_left,
661            seg_tangent[n - 2],
662            colors.at(last),
663            opts.cap,
664            hw(last),
665        );
666    }
667
668    Mesh::new(vertices, vcolors, indices)
669}
670
671/// Emit the bevel / round fill triangle(s) at a single interior
672/// vertex. For mitre joins that didn't fall back to bevel, `is_bevel`
673/// is false and the caller skips this entirely.
674fn emit_join_fill(
675    vertices: &mut Vec<Point>,
676    vcolors: &mut Vec<Color>,
677    indices: &mut Vec<u32>,
678    pi: Point,
679    layout: &VertexLayout,
680    color: Color,
681    join: Join,
682) {
683    let (outside_in, outside_out) = if layout.bevel_outside_left {
684        (layout.in_left, layout.out_left)
685    } else {
686        (layout.in_right, layout.out_right)
687    };
688    match join {
689        Join::Bevel | Join::Miter => {
690            let i_p = vertices.len() as u32;
691            vertices.push(pi);
692            vcolors.push(color);
693            let i_oi = vertices.len() as u32;
694            vertices.push(outside_in);
695            vcolors.push(color);
696            let i_oo = vertices.len() as u32;
697            vertices.push(outside_out);
698            vcolors.push(color);
699            indices.extend_from_slice(&[i_p, i_oi, i_oo]);
700        }
701        Join::Round => {
702            // The fan fills the outside notch, so it takes the shorter
703            // of the two sweeps between the outside shoulders.
704            let va = outside_in - pi;
705            emit_arc_fan(
706                vertices,
707                vcolors,
708                indices,
709                pi,
710                outside_in,
711                va.hypot(),
712                va.y.atan2(va.x),
713                normalized_delta(va, outside_out - pi),
714                JOIN_FAN_SEGMENTS,
715                color,
716            );
717        }
718    }
719}
720
721/// Emit cap geometry at one endpoint. `outward` is the unit vector
722/// pointing away from the polyline at this endpoint (start cap:
723/// `-tangent_of_first_segment`; end cap: `+tangent_of_last_segment`).
724/// `(a, b)` are the two shoulder vertices already placed at the
725/// endpoint, ordered so that a→b crosses outward to the right of the
726/// outward direction (i.e., `a = left_relative_to_outward,
727/// b = right_relative_to_outward`).
728#[allow(clippy::too_many_arguments, clippy::ptr_arg)]
729fn emit_cap(
730    vertices: &mut Vec<Point>,
731    vcolors: &mut Vec<Color>,
732    indices: &mut Vec<u32>,
733    endpoint: Point,
734    a: Point,
735    b: Point,
736    outward: Vec2,
737    color: Color,
738    cap: Cap,
739    half_width: f64,
740) {
741    match cap {
742        Cap::Butt => {} // No cap geometry.
743        Cap::Square => {
744            // Extrude (a, b) by `half_width` along `outward`, emit a
745            // quad.
746            let a_ext = a + outward * half_width;
747            let b_ext = b + outward * half_width;
748            let i_a = vertices.len() as u32;
749            vertices.push(a);
750            vcolors.push(color);
751            let i_b = vertices.len() as u32;
752            vertices.push(b);
753            vcolors.push(color);
754            let i_be = vertices.len() as u32;
755            vertices.push(b_ext);
756            vcolors.push(color);
757            let i_ae = vertices.len() as u32;
758            vertices.push(a_ext);
759            vcolors.push(color);
760            indices.extend_from_slice(&[i_a, i_b, i_be, i_a, i_be, i_ae]);
761        }
762        Cap::Round => {
763            // Sweep from shoulder `a` round to shoulder `b` on the
764            // outward side: the semicircle, not the (zero-length) sweep
765            // straight across the endpoint.
766            let va = a - endpoint;
767            let mut delta = normalized_delta(va, b - endpoint);
768            // The two shoulders sit on opposite sides of the endpoint,
769            // so the semicircle is whichever direction has magnitude
770            // ≈ π. When the natural (-π, π] delta is shorter than that,
771            // the cap has to go the other way round.
772            if delta.abs() < std::f64::consts::PI - 1e-6 {
773                delta = if delta >= 0.0 {
774                    delta - std::f64::consts::TAU
775                } else {
776                    delta + std::f64::consts::TAU
777                };
778            }
779            emit_arc_fan(
780                vertices,
781                vcolors,
782                indices,
783                endpoint,
784                a,
785                half_width.max(EPSILON),
786                va.y.atan2(va.x),
787                delta,
788                CAP_FAN_SEGMENTS,
789                color,
790            );
791        }
792    }
793}
794
795/// Signed angle from `from` to `to`, normalised into `(-π, π]` — the
796/// shorter of the two ways round.
797fn normalized_delta(from: Vec2, to: Vec2) -> f64 {
798    let mut delta = to.y.atan2(to.x) - from.y.atan2(from.x);
799    while delta > std::f64::consts::PI {
800        delta -= std::f64::consts::TAU;
801    }
802    while delta <= -std::f64::consts::PI {
803        delta += std::f64::consts::TAU;
804    }
805    delta
806}
807
808/// Emit a triangle fan approximating the circular arc of radius `r`
809/// centred at `center`, running `delta` radians (signed) from angle
810/// `theta_a`. The fan's first rim vertex is `start`, which the caller
811/// supplies so the fan meets the neighbouring geometry exactly;
812/// subsequent rim vertices are placed on the arc.
813///
814/// Two complementary bounds set the angular step: the chord error
815/// `ε = R · (1 − cos(Δθ/2))` keeps positional deviation within
816/// [`ARC_FAN_TOLERANCE`] at any radius, and [`ARC_FAN_MAX_STEP`] keeps
817/// small radii from reading as faceted. The denser of the two wins,
818/// and `seg_clamp` bounds the resulting count.
819#[allow(clippy::too_many_arguments)]
820fn emit_arc_fan(
821    vertices: &mut Vec<Point>,
822    vcolors: &mut Vec<Color>,
823    indices: &mut Vec<u32>,
824    center: Point,
825    start: Point,
826    r: f64,
827    theta_a: f64,
828    delta: f64,
829    seg_clamp: RangeInclusive<usize>,
830    color: Color,
831) {
832    let chord_step = (1.0 - (ARC_FAN_TOLERANCE / r.max(EPSILON)).clamp(0.0, 1.0)).acos() * 2.0;
833    let theta_step = chord_step.clamp(ARC_FAN_MIN_STEP, ARC_FAN_MAX_STEP);
834    let segments = (delta.abs() / theta_step).ceil() as usize;
835    let n_steps = segments.clamp(*seg_clamp.start(), *seg_clamp.end());
836    let step = delta / n_steps as f64;
837
838    let i_center = vertices.len() as u32;
839    vertices.push(center);
840    vcolors.push(color);
841    let i_start = vertices.len() as u32;
842    vertices.push(start);
843    vcolors.push(color);
844    let mut prev = i_start;
845    for k in 1..=n_steps {
846        let theta = theta_a + step * k as f64;
847        let p = Point::new(center.x + r * theta.cos(), center.y + r * theta.sin());
848        let idx = vertices.len() as u32;
849        vertices.push(p);
850        vcolors.push(color);
851        indices.extend_from_slice(&[i_center, prev, idx]);
852        prev = idx;
853    }
854}
855
856#[inline]
857fn perp_left(v: Vec2) -> Vec2 {
858    Vec2::new(-v.y, v.x)
859}
860
861// ── Tests ──────────────────────────────────────────────────────────────────
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866
867    fn pt(x: f64, y: f64) -> Point {
868        Point::new(x, y)
869    }
870    fn red() -> Color {
871        Color::new([1.0, 0.0, 0.0, 1.0])
872    }
873    fn green() -> Color {
874        Color::new([0.0, 1.0, 0.0, 1.0])
875    }
876    fn blue() -> Color {
877        Color::new([0.0, 0.0, 1.0, 1.0])
878    }
879
880    fn approx(a: f64, b: f64) -> bool {
881        (a - b).abs() < 1e-9
882    }
883
884    #[test]
885    fn polyline_ribbon_two_point_butt() {
886        // Straight line along +x, half_width 1. Two segments end up
887        // sharing shoulders — total 4 vertices (the two shoulder
888        // pairs), 2 triangles.
889        let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
890        let opts = RibbonOptions {
891            half_width: 1.0,
892            cap: Cap::Butt,
893            join: Join::Miter,
894            miter_limit: 4.0,
895        };
896        let mesh = polyline_ribbon(&pts, red(), &opts);
897        assert_eq!(mesh.vertex_count(), 4);
898        assert_eq!(mesh.triangle_count(), 2);
899        // Shoulders sit at (0, ±1) and (10, ±1).
900        let mut ys: Vec<f64> = mesh.vertices.iter().map(|p| p.y).collect();
901        ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
902        assert!(approx(ys[0], -1.0));
903        assert!(approx(ys[1], -1.0));
904        assert!(approx(ys[2], 1.0));
905        assert!(approx(ys[3], 1.0));
906    }
907
908    #[test]
909    fn polyline_ribbon_constant_color_all_vertices_match() {
910        let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
911        let mesh = polyline_ribbon(&pts, red(), &RibbonOptions::default());
912        for c in &mesh.colors {
913            assert_eq!(*c, red());
914        }
915    }
916
917    #[test]
918    fn polyline_gradient_endpoint_colors_preserved() {
919        // 2-point polyline; vertex 0 gets red, vertex 1 gets blue.
920        // Both shoulders at vertex 0 carry red; both at vertex 1
921        // carry blue. With butt caps + miter (no joins), there are
922        // exactly 4 vertices.
923        let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
924        let cols = [red(), blue()];
925        let mesh = polyline_gradient(&pts, &cols, &RibbonOptions::default());
926        assert_eq!(mesh.vertex_count(), 4);
927        // The two left-most x vertices (x ≈ 0) carry red; the two
928        // right-most (x ≈ 10) carry blue.
929        for (p, c) in mesh.vertices.iter().zip(mesh.colors.iter()) {
930            if approx(p.x, 0.0) {
931                assert_eq!(*c, red());
932            } else if approx(p.x, 10.0) {
933                assert_eq!(*c, blue());
934            }
935        }
936    }
937
938    #[test]
939    fn polyline_gradient_interior_color_shared_across_segments() {
940        // 3-vertex polyline; interior vertex's shoulders carry green.
941        // Miter join → single shoulder pair at interior. Each segment
942        // quad gets a small bleed (SEAM_BLEED_PX) along the local
943        // tangent to eliminate the AA seam between adjacent fills, so
944        // shoulders near the interior vertex are emitted at slightly
945        // staggered x-coordinates. All such shoulders should still
946        // carry the green colour.
947        let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(20.0, 0.0)];
948        let cols = [red(), green(), blue()];
949        let mesh = polyline_gradient(&pts, &cols, &RibbonOptions::default());
950        // Anything within ±2 × bleed of x=10 (the interior vertex) is
951        // an interior-shoulder emission; all should be green.
952        let interior_greens = mesh
953            .vertices
954            .iter()
955            .zip(mesh.colors.iter())
956            .filter(|(p, _)| (p.x - 10.0).abs() < 2.0)
957            .map(|(_, c)| *c)
958            .collect::<Vec<_>>();
959        assert!(!interior_greens.is_empty());
960        for c in &interior_greens {
961            assert_eq!(*c, green(), "interior shoulder should be green");
962        }
963    }
964
965    #[test]
966    fn polyline_ribbon_full_variable_width_shoulder_offsets() {
967        // Straight line along +x with widths [1, 2, 1]. Shoulder
968        // y-coords should be ±1, ±2, ±1 at the (approximate) x
969        // positions 0, 5, 10. Seam-bleed splits the interior x=5
970        // shoulders into a stagger around x ≈ 4.25 and x ≈ 5.75, but
971        // the y-coords remain unchanged.
972        let pts = [pt(0.0, 0.0), pt(5.0, 0.0), pt(10.0, 0.0)];
973        let widths = [1.0_f64, 2.0, 1.0];
974        let mesh = polyline_ribbon_full(&pts, None, Some(&widths), &RibbonOptions::default());
975        // Bucket shoulders by approximate x (within ±1 of the
976        // expected polyline-vertex x).
977        let mut shoulders_at_x: Vec<(f64, Vec<f64>)> =
978            vec![(0.0, Vec::new()), (5.0, Vec::new()), (10.0, Vec::new())];
979        for p in &mesh.vertices {
980            for (x, ys) in shoulders_at_x.iter_mut() {
981                if (p.x - *x).abs() < 1.0 {
982                    ys.push(p.y);
983                }
984            }
985        }
986        for (x, ys) in shoulders_at_x {
987            let expected: Vec<f64> = if approx(x, 5.0) {
988                vec![-2.0, 2.0]
989            } else {
990                vec![-1.0, 1.0]
991            };
992            let mut sorted = ys.clone();
993            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
994            sorted.dedup_by(|a, b| approx(*a, *b));
995            assert_eq!(
996                sorted.len(),
997                expected.len(),
998                "at x={x}, unique shoulder ys = {sorted:?}"
999            );
1000            for (s, e) in sorted.iter().zip(expected.iter()) {
1001                assert!(approx(*s, *e), "at x={x}, got {s}, expected {e}");
1002            }
1003        }
1004    }
1005
1006    #[test]
1007    fn polyline_ribbon_90_corner_mitre() {
1008        // Three points forming a right-turn 90° corner at (10, 0).
1009        // miter_mag = 1/cos(45°) ≈ 1.4142, within default miter_limit
1010        // of 4 → miter join, single shoulder pair at the corner.
1011        let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0)];
1012        let opts = RibbonOptions {
1013            half_width: 1.0,
1014            join: Join::Miter,
1015            ..RibbonOptions::default()
1016        };
1017        let mesh = polyline_ribbon(&pts, red(), &opts);
1018        // No bevel triangle → 2 segments × 2 tris = 4 triangles.
1019        assert_eq!(mesh.triangle_count(), 4);
1020        // The outer-corner mitre sits at (11, -1) in the layout, but
1021        // segment 0's far-end shoulders are bled forward by
1022        // SEAM_BLEED_PX (= 0.75) along seg_tangent[0] = (1, 0). So
1023        // the emitted vertex lands at (11.75, -1). Segment 1's
1024        // near-end shoulders are bled backward by SEAM_BLEED_PX along
1025        // -seg_tangent[1] = (0, -1), landing at (11, -0.75). Both
1026        // are valid bled-mitre emissions; test for *either*.
1027        let near_mitre = mesh.vertices.iter().find(|p| {
1028            (approx(p.x, 11.75) && approx(p.y, -1.0)) || (approx(p.x, 11.0) && approx(p.y, -0.25))
1029        });
1030        assert!(
1031            near_mitre.is_some(),
1032            "expected bled outer-mitre near (11, -1); got vertices = {:?}",
1033            mesh.vertices
1034        );
1035    }
1036
1037    #[test]
1038    fn polyline_ribbon_sharp_corner_clamps_to_bevel() {
1039        // Near-U-turn — mitre would extend far beyond miter_limit, so
1040        // the miter-join setting falls back to a bevel at this vertex.
1041        // The bevel emits an extra fill triangle.
1042        let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(0.0, 0.1)];
1043        let opts = RibbonOptions {
1044            half_width: 1.0,
1045            join: Join::Miter,
1046            miter_limit: 2.0,
1047            ..RibbonOptions::default()
1048        };
1049        let mesh = polyline_ribbon(&pts, red(), &opts);
1050        // 2 segments × 2 tris = 4, plus 1 bevel-fill = 5.
1051        assert_eq!(mesh.triangle_count(), 5);
1052    }
1053
1054    #[test]
1055    fn polyline_ribbon_bevel_join_emits_extra_triangle() {
1056        let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0)];
1057        let opts = RibbonOptions {
1058            half_width: 1.0,
1059            join: Join::Bevel,
1060            ..RibbonOptions::default()
1061        };
1062        let mesh = polyline_ribbon(&pts, red(), &opts);
1063        // 4 segment triangles + 1 bevel fill.
1064        assert_eq!(mesh.triangle_count(), 5);
1065    }
1066
1067    #[test]
1068    fn polyline_ribbon_round_join_emits_fan() {
1069        let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0)];
1070        let opts = RibbonOptions {
1071            half_width: 5.0, // larger radius → more fan segments
1072            join: Join::Round,
1073            ..RibbonOptions::default()
1074        };
1075        let mesh = polyline_ribbon(&pts, red(), &opts);
1076        // 4 segment triangles + N fan triangles (N >= 2).
1077        assert!(
1078            mesh.triangle_count() >= 6,
1079            "got {} triangles",
1080            mesh.triangle_count()
1081        );
1082    }
1083
1084    #[test]
1085    fn polyline_ribbon_square_cap_extends_endpoint() {
1086        let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
1087        let opts = RibbonOptions {
1088            half_width: 1.0,
1089            cap: Cap::Square,
1090            ..RibbonOptions::default()
1091        };
1092        let mesh = polyline_ribbon(&pts, red(), &opts);
1093        // Square caps add 2 triangles per cap.
1094        // 2 segment + 2 (start cap) + 2 (end cap) = 6 triangles.
1095        assert_eq!(mesh.triangle_count(), 6);
1096        // Bounding box should now extend past x ∈ [-1, 11] (one
1097        // half-width beyond each endpoint).
1098        let bb = mesh.bounding_box();
1099        assert!(approx(bb.x0, -1.0));
1100        assert!(approx(bb.x1, 11.0));
1101    }
1102
1103    #[test]
1104    fn polyline_ribbon_round_cap_emits_fan() {
1105        let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
1106        let opts = RibbonOptions {
1107            half_width: 5.0,
1108            cap: Cap::Round,
1109            ..RibbonOptions::default()
1110        };
1111        let mesh = polyline_ribbon(&pts, red(), &opts);
1112        // 2 segment + ≥4 fan triangles per round cap.
1113        assert!(mesh.triangle_count() >= 2 + 2 * 4);
1114    }
1115
1116    #[test]
1117    fn polyline_ribbon_butt_cap_emits_no_cap_triangles() {
1118        let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
1119        let opts = RibbonOptions {
1120            half_width: 1.0,
1121            cap: Cap::Butt,
1122            ..RibbonOptions::default()
1123        };
1124        let mesh = polyline_ribbon(&pts, red(), &opts);
1125        assert_eq!(mesh.triangle_count(), 2);
1126    }
1127
1128    #[test]
1129    fn polyline_ribbon_bounding_box_straight_butt() {
1130        let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
1131        let opts = RibbonOptions {
1132            half_width: 1.0,
1133            cap: Cap::Butt,
1134            ..RibbonOptions::default()
1135        };
1136        let mesh = polyline_ribbon(&pts, red(), &opts);
1137        let bb = mesh.bounding_box();
1138        assert!(approx(bb.x0, 0.0));
1139        assert!(approx(bb.x1, 10.0));
1140        assert!(approx(bb.y0, -1.0));
1141        assert!(approx(bb.y1, 1.0));
1142    }
1143
1144    #[test]
1145    fn polyline_ribbon_under_two_points_returns_empty() {
1146        let pts = [pt(0.0, 0.0)];
1147        let mesh = polyline_ribbon(&pts, red(), &RibbonOptions::default());
1148        assert!(mesh.is_empty());
1149    }
1150
1151    #[test]
1152    #[should_panic(expected = "colors.len()")]
1153    fn polyline_gradient_panics_on_length_mismatch() {
1154        let pts = [pt(0.0, 0.0), pt(10.0, 0.0)];
1155        let cols = [red(), green(), blue()];
1156        let _ = polyline_gradient(&pts, &cols, &RibbonOptions::default());
1157    }
1158
1159    // ── Closed-polygon ribbon ──────────────────────────────────────
1160
1161    #[test]
1162    fn polygon_ribbon_equilateral_triangle_segment_count() {
1163        // Interior angle 60°, turn angle 120°, miter_mag = 2 < 4 →
1164        // mitre at every vertex, no bevel fills. 3 wrap segments × 2
1165        // tris per segment = 6.
1166        let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
1167        let opts = RibbonOptions {
1168            half_width: 1.0,
1169            join: Join::Miter,
1170            ..RibbonOptions::default()
1171        };
1172        let mesh = polygon_ribbon(&pts, red(), &opts);
1173        assert_eq!(mesh.triangle_count(), 6);
1174    }
1175
1176    #[test]
1177    fn polygon_ribbon_square_bevel_emits_four_extra_triangles() {
1178        // 4 segments × 2 + one bevel fill at each of 4 corners
1179        // (including the wrap-back to vertex 0).
1180        let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0), pt(0.0, 10.0)];
1181        let opts = RibbonOptions {
1182            half_width: 1.0,
1183            join: Join::Bevel,
1184            ..RibbonOptions::default()
1185        };
1186        let mesh = polygon_ribbon(&pts, red(), &opts);
1187        assert_eq!(mesh.triangle_count(), 12);
1188    }
1189
1190    #[test]
1191    fn polygon_ribbon_too_few_points_returns_empty() {
1192        // < 3 points → empty mesh; a 2-point closed loop is degenerate.
1193        for pts in [&[][..], &[pt(0.0, 0.0)], &[pt(0.0, 0.0), pt(10.0, 0.0)]] {
1194            let mesh = polygon_ribbon(pts, red(), &RibbonOptions::default());
1195            assert!(
1196                mesh.is_empty(),
1197                "expected empty mesh for {} points",
1198                pts.len()
1199            );
1200        }
1201    }
1202
1203    #[test]
1204    fn polygon_ribbon_cap_setting_is_ignored() {
1205        // Closed polygon has no endpoints — varying `cap` must not
1206        // change the mesh.
1207        let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
1208        let make = |cap| {
1209            let opts = RibbonOptions {
1210                half_width: 1.0,
1211                cap,
1212                join: Join::Miter,
1213                ..RibbonOptions::default()
1214            };
1215            polygon_ribbon(&pts, red(), &opts).triangle_count()
1216        };
1217        let butt = make(Cap::Butt);
1218        assert_eq!(butt, make(Cap::Square));
1219        assert_eq!(butt, make(Cap::Round));
1220    }
1221
1222    #[test]
1223    fn polygon_gradient_wrap_segment_closes_color_loop() {
1224        // Triangle with vertex colours [red, green, blue]. The wrap
1225        // segment (vertex 2 → vertex 0) must emit red shoulders at
1226        // its far end, otherwise the loop wouldn't actually close.
1227        let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
1228        let cols = [red(), green(), blue()];
1229        let opts = RibbonOptions {
1230            half_width: 1.0,
1231            join: Join::Miter,
1232            ..RibbonOptions::default()
1233        };
1234        let mesh = polygon_gradient(&pts, &cols, &opts);
1235        let mut counts = [0_usize; 3];
1236        for c in &mesh.colors {
1237            if *c == red() {
1238                counts[0] += 1;
1239            } else if *c == green() {
1240                counts[1] += 1;
1241            } else if *c == blue() {
1242                counts[2] += 1;
1243            }
1244        }
1245        // Each colour should appear at multiple shoulder emissions
1246        // (incoming AND outgoing segment at its vertex).
1247        assert!(counts[0] >= 2, "expected red shoulders, got {counts:?}");
1248        assert!(counts[1] >= 2, "expected green shoulders, got {counts:?}");
1249        assert!(counts[2] >= 2, "expected blue shoulders, got {counts:?}");
1250    }
1251
1252    #[test]
1253    fn polygon_ribbon_full_variable_width_widens_with_width() {
1254        // Square loop at two width settings: the bounding box should
1255        // grow as the width grows. Exact equality is hard because the
1256        // seam-bleed shifts shoulders along the local tangent (which
1257        // for a square is the same axis as the bounding-box edge),
1258        // but the *outward* extent must still scale with width.
1259        let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(10.0, 10.0), pt(0.0, 10.0)];
1260        let opts = RibbonOptions {
1261            half_width: 1.0,
1262            join: Join::Miter,
1263            ..RibbonOptions::default()
1264        };
1265        let m_thin = polygon_ribbon_full(&pts, None, Some(&[1.0_f64; 4]), &opts);
1266        let m_thick = polygon_ribbon_full(&pts, None, Some(&[5.0_f64; 4]), &opts);
1267        let bb_thin = m_thin.bounding_box();
1268        let bb_thick = m_thick.bounding_box();
1269        // Outer extent grows by ~4 px on each side as w goes 1 → 5.
1270        assert!(
1271            bb_thick.x0 < bb_thin.x0 - 3.0,
1272            "expected thicker x0 ({}) at least 3 px outside thin x0 ({})",
1273            bb_thick.x0,
1274            bb_thin.x0,
1275        );
1276        assert!(
1277            bb_thick.x1 > bb_thin.x1 + 3.0,
1278            "expected thicker x1 ({}) at least 3 px outside thin x1 ({})",
1279            bb_thick.x1,
1280            bb_thin.x1,
1281        );
1282        assert!(bb_thick.y0 < bb_thin.y0 - 3.0);
1283        assert!(bb_thick.y1 > bb_thin.y1 + 3.0);
1284    }
1285
1286    #[test]
1287    fn polygon_ribbon_full_per_vertex_width_changes_shoulder_offsets() {
1288        // Triangle with widths [1, 4, 1]. Vertex 1 (the wide one)
1289        // should produce shoulder pairs farther from the polyline
1290        // than vertex 0 or 2.
1291        let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
1292        let widths = [1.0_f64, 4.0, 1.0];
1293        let opts = RibbonOptions {
1294            half_width: 1.0,
1295            join: Join::Miter,
1296            ..RibbonOptions::default()
1297        };
1298        let mesh = polygon_ribbon_full(&pts, None, Some(&widths), &opts);
1299        // Find max shoulder offset from each vertex (taking shoulder
1300        // as "any mesh vertex within ~3 px of the polyline vertex
1301        // along the polyline" is too fragile, so just measure
1302        // shoulder-vertex distance from polyline vertex and bucket
1303        // by closest polyline vertex).
1304        let mut max_offset = [0.0_f64; 3];
1305        for v in &mesh.vertices {
1306            let d = [
1307                (*v - pts[0]).hypot(),
1308                (*v - pts[1]).hypot(),
1309                (*v - pts[2]).hypot(),
1310            ];
1311            let (idx, dist) = d
1312                .iter()
1313                .enumerate()
1314                .min_by(|a, b| a.1.partial_cmp(b.1).unwrap())
1315                .unwrap();
1316            if *dist > max_offset[idx] {
1317                max_offset[idx] = *dist;
1318            }
1319        }
1320        // Vertex 1 (width 4) should sit further from its vertex than
1321        // vertices 0/2 (width 1).
1322        assert!(
1323            max_offset[1] > max_offset[0] + 2.0,
1324            "max shoulder offsets per vertex: {max_offset:?}",
1325        );
1326        assert!(max_offset[1] > max_offset[2] + 2.0);
1327    }
1328
1329    #[test]
1330    #[should_panic(expected = "colors.len()")]
1331    fn polygon_gradient_panics_on_length_mismatch() {
1332        let pts = [pt(0.0, 0.0), pt(10.0, 0.0), pt(5.0, 8.66)];
1333        let cols = [red(), green()];
1334        let _ = polygon_gradient(&pts, &cols, &RibbonOptions::default());
1335    }
1336
1337    // ── Quad-strip band mesh ───────────────────────────────────────
1338
1339    #[test]
1340    fn ribbon_band_mesh_two_point_strip() {
1341        // Smallest case: one quad between two two-point curves.
1342        let a = [pt(0.0, 0.0), pt(10.0, 0.0)];
1343        let b = [pt(0.0, 5.0), pt(10.0, 5.0)];
1344        let mesh = ribbon_band_mesh(&a, &b, &[red(); 2], &[blue(); 2]);
1345        assert_eq!(mesh.vertex_count(), 4);
1346        assert_eq!(mesh.triangle_count(), 2);
1347        let bb = mesh.bounding_box();
1348        assert!(approx(bb.x0, 0.0));
1349        assert!(approx(bb.x1, 10.0));
1350        assert!(approx(bb.y0, 0.0));
1351        assert!(approx(bb.y1, 5.0));
1352    }
1353
1354    #[test]
1355    fn ribbon_band_mesh_quad_pair_index_pattern() {
1356        // Each segment must emit the canonical [base, base+1, base+2,
1357        // base, base+2, base+3] index pattern so the Vello quad-pair
1358        // detector folds it into a single quad fill.
1359        let a = [pt(0.0, 0.0), pt(10.0, 0.0), pt(20.0, 0.0)];
1360        let b = [pt(0.0, 5.0), pt(10.0, 5.0), pt(20.0, 5.0)];
1361        let mesh = ribbon_band_mesh(&a, &b, &[red(); 3], &[blue(); 3]);
1362        assert_eq!(mesh.indices.len(), 12);
1363        // Segment 0: 0,1,2,0,2,3
1364        assert_eq!(&mesh.indices[0..6], &[0, 1, 2, 0, 2, 3]);
1365        // Segment 1: 4,5,6,4,6,7
1366        assert_eq!(&mesh.indices[6..12], &[4, 5, 6, 4, 6, 7]);
1367    }
1368
1369    #[test]
1370    fn ribbon_band_mesh_per_side_colors_preserved() {
1371        let a = [pt(0.0, 0.0), pt(10.0, 0.0)];
1372        let b = [pt(0.0, 5.0), pt(10.0, 5.0)];
1373        let mesh = ribbon_band_mesh(&a, &b, &[red(), red()], &[blue(), blue()]);
1374        for (p, c) in mesh.vertices.iter().zip(mesh.colors.iter()) {
1375            if approx(p.y, 0.0) {
1376                assert_eq!(*c, red());
1377            } else if approx(p.y, 5.0) {
1378                assert_eq!(*c, blue());
1379            }
1380        }
1381    }
1382
1383    #[test]
1384    fn ribbon_band_mesh_under_two_points_returns_empty() {
1385        let a = [pt(0.0, 0.0)];
1386        let b = [pt(0.0, 5.0)];
1387        let mesh = ribbon_band_mesh(&a, &b, &[red()], &[blue()]);
1388        assert!(mesh.is_empty());
1389    }
1390
1391    #[test]
1392    #[should_panic(expected = "curve_a.len()")]
1393    fn ribbon_band_mesh_panics_on_curve_length_mismatch() {
1394        let a = [pt(0.0, 0.0), pt(10.0, 0.0)];
1395        let b = [pt(0.0, 5.0)];
1396        let _ = ribbon_band_mesh(&a, &b, &[red(); 2], &[blue(); 1]);
1397    }
1398
1399    #[test]
1400    #[should_panic(expected = "colors_a.len()")]
1401    fn ribbon_band_mesh_panics_on_colors_a_mismatch() {
1402        let a = [pt(0.0, 0.0), pt(10.0, 0.0)];
1403        let b = [pt(0.0, 5.0), pt(10.0, 5.0)];
1404        let _ = ribbon_band_mesh(&a, &b, &[red()], &[blue(); 2]);
1405    }
1406}