Skip to main content

rustial_engine/
tessellator.rs

1// ---------------------------------------------------------------------------
2//! # Vector tessellation -- polygon fill and line stroke
3//!
4//! This module converts geographic vector geometries into GPU-ready
5//! triangle meshes.  It provides the following public functions:
6//!
7//! - [`triangulate_polygon`] -- fill a simple polygon with triangles
8//!   using **ear-clipping** (handles concave polygons correctly).
9//! - [`triangulate_polygon_with_holes`] -- fill a polygon with interior
10//!   rings (holes) using ear-clipping.
11//! - [`stroke_line`] -- expand a polyline into a thick ribbon of
12//!   triangles with configurable half-width.
13//!
14//! Both operate in the **degree plane** (lon as X, lat as Y).  The
15//! caller ([`VectorLayer::tessellate`](crate::layers::VectorLayer::tessellate))
16//! projects the resulting vertices into Web Mercator world space before
17//! GPU upload.
18//!
19//! ## Coordinate space
20//!
21//! Positions are in raw (lon, lat) degrees.  At the equator 1 degree
22//! ~ 111 km in both axes, so the geometry is approximately isotropic.
23//! For high-latitude features the caller should consider projecting
24//! first, but for typical map-display use cases the distortion is
25//! masked by the Mercator projection.
26// ---------------------------------------------------------------------------
27
28use crate::layers::{LineCap, LineJoin};
29use rustial_math::GeoCoord;
30
31// ---------------------------------------------------------------------------
32// Internal helpers
33// ---------------------------------------------------------------------------
34
35/// A lightweight 2D vector used for tangent / normal calculations.
36///
37/// Intentionally kept private -- callers work with `GeoCoord` and the
38/// `[f64; 2]` output of [`stroke_line`].
39#[derive(Debug, Clone, Copy)]
40struct Vec2 {
41    x: f64,
42    y: f64,
43}
44
45/// Normalize a 2D vector to unit length.
46///
47/// Returns the zero vector when the input length is below `1e-15`
48/// (sub-nanometre in degree space), avoiding division by zero for
49/// coincident or nearly-coincident points.
50#[inline]
51fn normalize(v: Vec2) -> Vec2 {
52    let len = (v.x * v.x + v.y * v.y).sqrt();
53    if len < 1e-15 {
54        Vec2 { x: 0.0, y: 0.0 }
55    } else {
56        Vec2 {
57            x: v.x / len,
58            y: v.y / len,
59        }
60    }
61}
62
63// ---------------------------------------------------------------------------
64// Public API -- polygon triangulation (ear-clipping)
65// ---------------------------------------------------------------------------
66
67/// Strip the closing duplicate vertex from a ring if present.
68///
69/// Returns the effective vertex count after removing a closing duplicate
70/// that matches the first vertex within 1e-12 degrees.  The GeoJSON
71/// convention repeats the first vertex at the end; this function detects
72/// that and excludes it from triangulation.
73fn effective_ring_len(coords: &[GeoCoord]) -> usize {
74    let n = coords.len();
75    if n > 3
76        && (coords[0].lat - coords[n - 1].lat).abs() < 1e-12
77        && (coords[0].lon - coords[n - 1].lon).abs() < 1e-12
78    {
79        n - 1
80    } else {
81        n
82    }
83}
84
85/// Signed area of a ring (lon as X, lat as Y) using the shoelace formula.
86/// Positive for counter-clockwise, negative for clockwise.
87fn _signed_ring_area(coords: &[GeoCoord], len: usize) -> f64 {
88    let mut area = 0.0;
89    let mut j = len - 1;
90    for i in 0..len {
91        area += (coords[j].lon - coords[i].lon) * (coords[j].lat + coords[i].lat);
92        j = i;
93    }
94    area * 0.5
95}
96
97/// Test whether point `p` lies strictly inside triangle `(a, b, c)`.
98///
99/// Uses the cross-product sign test.  Points exactly on an edge return
100/// `false` to avoid degenerate ear removal.
101#[allow(clippy::too_many_arguments)]
102fn point_in_triangle(
103    px: f64,
104    py: f64,
105    ax: f64,
106    ay: f64,
107    bx: f64,
108    by: f64,
109    cx: f64,
110    cy: f64,
111) -> bool {
112    let d1 = (px - bx) * (ay - by) - (ax - bx) * (py - by);
113    let d2 = (px - cx) * (by - cy) - (bx - cx) * (py - cy);
114    let d3 = (px - ax) * (cy - ay) - (cx - ax) * (py - ay);
115    let has_neg = (d1 < 0.0) || (d2 < 0.0) || (d3 < 0.0);
116    let has_pos = (d1 > 0.0) || (d2 > 0.0) || (d3 > 0.0);
117    !(has_neg && has_pos)
118}
119
120/// Cross product of vectors (b - a) and (c - a), returning just the Z component.
121fn cross_z(ax: f64, ay: f64, bx: f64, by: f64, cx: f64, cy: f64) -> f64 {
122    (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
123}
124
125/// Ear-clipping triangulation of a flat coordinate array with hole bridging.
126///
127/// `flat` contains `[lon, lat, lon, lat, ...]` for the exterior ring
128/// followed by each hole ring.  `hole_starts` contains the index into
129/// `flat` (counting pairs, i.e. vertex indices) where each hole begins.
130///
131/// Returns triangle indices into the flat vertex array (vertex index,
132/// not byte index).
133fn earcut_flat(flat: &[f64], hole_starts: &[usize]) -> Vec<u32> {
134    let total_verts = flat.len() / 2;
135    if total_verts < 3 {
136        return Vec::new();
137    }
138
139    // Build the initial doubly-linked list of vertex indices.
140    let mut prev = vec![0usize; total_verts];
141    let mut next = vec![0usize; total_verts];
142
143    // Link the exterior ring (vertices 0..hole_starts[0] or 0..total_verts).
144    let outer_end = if hole_starts.is_empty() {
145        total_verts
146    } else {
147        hole_starts[0]
148    };
149
150    if outer_end < 3 {
151        return Vec::new();
152    }
153
154    // Ensure CCW winding for the outer ring.
155    let outer_ccw = ring_area_flat(flat, 0, outer_end) > 0.0;
156    link_ring(&mut prev, &mut next, 0, outer_end, outer_ccw);
157
158    // Process holes: ensure CW winding, then bridge each hole into the
159    // outer polygon.
160    let mut outer_start = 0usize;
161    for (hi, &h_start) in hole_starts.iter().enumerate() {
162        let h_end = if hi + 1 < hole_starts.len() {
163            hole_starts[hi + 1]
164        } else {
165            total_verts
166        };
167        if h_end - h_start < 3 {
168            continue;
169        }
170        let hole_ccw = ring_area_flat(flat, h_start, h_end) > 0.0;
171        // Holes should be CW (negative area in our convention).
172        link_ring(&mut prev, &mut next, h_start, h_end, !hole_ccw);
173
174        // Bridge: find the rightmost vertex of the hole, then find a
175        // visible vertex on the outer boundary to bridge to.
176        let bridge_hole = rightmost_vertex(flat, h_start, h_end);
177        outer_start = bridge_hole_to_outer(
178            flat,
179            &mut prev,
180            &mut next,
181            outer_start,
182            outer_end,
183            bridge_hole,
184        );
185    }
186
187    // Run ear-clipping on the linked list.
188    let mut indices = Vec::with_capacity((total_verts - 2) * 3);
189    ear_clip(
190        flat,
191        &prev,
192        &mut next.clone(),
193        outer_start,
194        &mut indices,
195        total_verts,
196    );
197    indices
198}
199
200/// Signed area of a ring in a flat `[lon, lat, ...]` array.
201fn ring_area_flat(flat: &[f64], start: usize, end: usize) -> f64 {
202    let mut area = 0.0;
203    let mut j = end - 1;
204    for i in start..end {
205        let jx = flat[j * 2];
206        let jy = flat[j * 2 + 1];
207        let ix = flat[i * 2];
208        let iy = flat[i * 2 + 1];
209        area += (jx - ix) * (jy + iy);
210        j = i;
211    }
212    area * 0.5
213}
214
215/// Link a ring into a circular doubly-linked list.
216/// If `forward` is true, link in index order; otherwise reverse.
217fn link_ring(prev: &mut [usize], next: &mut [usize], start: usize, end: usize, forward: bool) {
218    if forward {
219        for i in start..end {
220            let p = if i == start { end - 1 } else { i - 1 };
221            let n = if i == end - 1 { start } else { i + 1 };
222            prev[i] = p;
223            next[i] = n;
224        }
225    } else {
226        for i in start..end {
227            let p = if i == end - 1 { start } else { i + 1 };
228            let n = if i == start { end - 1 } else { i - 1 };
229            prev[i] = p;
230            next[i] = n;
231        }
232    }
233}
234
235/// Find the vertex with the largest X (lon) in a ring.
236fn rightmost_vertex(flat: &[f64], start: usize, end: usize) -> usize {
237    let mut best = start;
238    for i in (start + 1)..end {
239        if flat[i * 2] > flat[best * 2]
240            || (flat[i * 2] == flat[best * 2] && flat[i * 2 + 1] < flat[best * 2 + 1])
241        {
242            best = i;
243        }
244    }
245    best
246}
247
248/// Bridge a hole vertex into the outer polygon by inserting mutual links.
249fn bridge_hole_to_outer(
250    flat: &[f64],
251    prev: &mut [usize],
252    next: &mut [usize],
253    outer_start: usize,
254    _outer_end: usize,
255    hole_vertex: usize,
256) -> usize {
257    // Find the outer vertex closest to `hole_vertex` that is visible.
258    let hx = flat[hole_vertex * 2];
259    let hy = flat[hole_vertex * 2 + 1];
260    let mut best = outer_start;
261    let mut best_dist = f64::INFINITY;
262    let mut i = outer_start;
263    loop {
264        let ix = flat[i * 2];
265        let iy = flat[i * 2 + 1];
266        let d = (ix - hx) * (ix - hx) + (iy - hy) * (iy - hy);
267        if d < best_dist {
268            best_dist = d;
269            best = i;
270        }
271        i = next[i];
272        if i == outer_start {
273            break;
274        }
275    }
276
277    // Splice the hole into the outer ring at `best`.
278    // After bridging: ... -> best -> hole_vertex -> ... hole ring ... -> hole_vertex -> best -> ...
279    // We create two copies conceptually but reuse the same indices by
280    // re-linking:  best.next = hole_vertex, and at the end of the hole
281    // ring we link back to best.
282    let best_next = next[best];
283    let hole_prev = prev[hole_vertex];
284
285    next[best] = hole_vertex;
286    prev[hole_vertex] = best;
287    next[hole_prev] = best_next;
288    prev[best_next] = hole_prev;
289
290    best
291}
292
293/// Core ear-clipping loop.
294fn ear_clip(
295    flat: &[f64],
296    orig_prev: &[usize],
297    next: &mut [usize],
298    start: usize,
299    indices: &mut Vec<u32>,
300    _total_verts: usize,
301) {
302    // We need a mutable prev as well.
303    let mut prev = orig_prev.to_vec();
304    let mut remaining = {
305        // Count vertices in the linked list starting from `start`.
306        let mut count = 1usize;
307        let mut i = next[start];
308        while i != start {
309            count += 1;
310            i = next[i];
311        }
312        count
313    };
314
315    let mut ear = start;
316    let mut _stop = ear;
317    let mut pass = 0;
318
319    while remaining > 2 {
320        let a = prev[ear];
321        let b = ear;
322        let c = next[ear];
323
324        if is_ear(flat, &prev, next, a, b, c) {
325            indices.push(a as u32);
326            indices.push(b as u32);
327            indices.push(c as u32);
328
329            // Remove vertex b.
330            next[a] = c;
331            prev[c] = a;
332
333            remaining -= 1;
334            _stop = c;
335            ear = c;
336            pass = 0;
337        } else {
338            ear = next[ear];
339            pass += 1;
340            if pass >= remaining {
341                // No ear found in a full pass -- degenerate polygon.
342                // Fall back to fan triangulation of remaining vertices.
343                fan_remaining(next, start, ear, remaining, indices);
344                break;
345            }
346        }
347    }
348}
349
350/// Check if vertex `b` is an ear (the triangle a-b-c contains no other vertices).
351fn is_ear(flat: &[f64], prev: &[usize], next: &[usize], a: usize, b: usize, c: usize) -> bool {
352    let ax = flat[a * 2];
353    let ay = flat[a * 2 + 1];
354    let bx = flat[b * 2];
355    let by = flat[b * 2 + 1];
356    let cx = flat[c * 2];
357    let cy = flat[c * 2 + 1];
358
359    // The ear triangle must be convex (positive cross product for CCW).
360    if cross_z(ax, ay, bx, by, cx, cy) <= 0.0 {
361        return false;
362    }
363
364    // Check that no reflex vertex lies inside the triangle.
365    // Only reflex vertices (cross_z <= 0 for CCW winding) can be inside a
366    // valid ear of a simple polygon.  Convex vertices are skipped.
367    let mut p = next[c];
368    while p != a {
369        let px = flat[p * 2];
370        let py = flat[p * 2 + 1];
371        if point_in_triangle(px, py, ax, ay, bx, by, cx, cy)
372            && cross_z(
373                flat[prev[p] * 2],
374                flat[prev[p] * 2 + 1],
375                px,
376                py,
377                flat[next[p] * 2],
378                flat[next[p] * 2 + 1],
379            ) <= 0.0
380        {
381            return false;
382        }
383        p = next[p];
384    }
385    true
386}
387
388/// Fan-triangulate the remaining linked-list vertices as a last resort.
389fn fan_remaining(
390    next: &[usize],
391    _start: usize,
392    first: usize,
393    remaining: usize,
394    indices: &mut Vec<u32>,
395) {
396    if remaining < 3 {
397        return;
398    }
399    let anchor = first;
400    let mut b = next[anchor];
401    for _ in 0..remaining - 2 {
402        let c = next[b];
403        indices.push(anchor as u32);
404        indices.push(b as u32);
405        indices.push(c as u32);
406        b = c;
407    }
408}
409
410/// Triangulate a simple polygon (no holes) into a list of triangle indices.
411///
412/// Uses **ear-clipping** which handles both convex and concave polygons
413/// correctly.  For polygons with holes, use
414/// [`triangulate_polygon_with_holes`] instead.
415///
416/// # Returns
417///
418/// Indices into the input `coords` slice, grouped in triples.
419///
420/// # Closing vertex
421///
422/// If the last coordinate duplicates the first (within 1e-12 degrees),
423/// it is treated as a ring-closing sentinel and excluded from
424/// triangulation.  This matches the GeoJSON convention where polygon
425/// rings repeat the first vertex.
426///
427/// # Edge cases
428///
429/// | Input | Result |
430/// |-------|--------|
431/// | Fewer than 3 coordinates | empty `Vec` |
432/// | Exactly 3 unique coordinates | 1 triangle (3 indices) |
433/// | Closing duplicate reducing count below 3 | empty `Vec` |
434///
435/// # Example
436///
437/// ```
438/// use rustial_engine::triangulate_polygon;
439/// use rustial_engine::GeoCoord;
440///
441/// let square = vec![
442///     GeoCoord::from_lat_lon(0.0, 0.0),
443///     GeoCoord::from_lat_lon(0.0, 1.0),
444///     GeoCoord::from_lat_lon(1.0, 1.0),
445///     GeoCoord::from_lat_lon(1.0, 0.0),
446/// ];
447/// let indices = triangulate_polygon(&square);
448/// assert_eq!(indices.len(), 6); // 2 triangles
449/// ```
450pub fn triangulate_polygon(coords: &[GeoCoord]) -> Vec<u32> {
451    let n = coords.len();
452    if n < 3 {
453        return Vec::new();
454    }
455
456    let effective_len = effective_ring_len(coords);
457    if effective_len < 3 {
458        return Vec::new();
459    }
460
461    // Build flat coordinate array [lon, lat, lon, lat, ...].
462    let mut flat = Vec::with_capacity(effective_len * 2);
463    for c in &coords[..effective_len] {
464        flat.push(c.lon);
465        flat.push(c.lat);
466    }
467
468    earcut_flat(&flat, &[])
469}
470
471/// Triangulate a polygon with interior rings (holes).
472///
473/// The exterior ring and each hole are provided as separate slices.
474/// Closing duplicate vertices are stripped automatically.  Winding
475/// order is normalised internally (exterior CCW, holes CW).
476///
477/// Returns indices into a combined vertex array where the exterior
478/// ring vertices come first (`0..exterior.effective_len`), followed
479/// by hole vertices in order.
480///
481/// # Example
482///
483/// ```
484/// use rustial_engine::triangulate_polygon_with_holes;
485/// use rustial_engine::GeoCoord;
486///
487/// let exterior = vec![
488///     GeoCoord::from_lat_lon(0.0, 0.0),
489///     GeoCoord::from_lat_lon(0.0, 4.0),
490///     GeoCoord::from_lat_lon(4.0, 4.0),
491///     GeoCoord::from_lat_lon(4.0, 0.0),
492/// ];
493/// let hole = vec![
494///     GeoCoord::from_lat_lon(1.0, 1.0),
495///     GeoCoord::from_lat_lon(1.0, 3.0),
496///     GeoCoord::from_lat_lon(3.0, 3.0),
497///     GeoCoord::from_lat_lon(3.0, 1.0),
498/// ];
499/// let indices = triangulate_polygon_with_holes(&exterior, &[&hole]);
500/// assert!(!indices.is_empty());
501/// ```
502pub fn triangulate_polygon_with_holes(exterior: &[GeoCoord], holes: &[&[GeoCoord]]) -> Vec<u32> {
503    let ext_len = effective_ring_len(exterior);
504    if ext_len < 3 {
505        return Vec::new();
506    }
507
508    let hole_lens: Vec<usize> = holes.iter().map(|h| effective_ring_len(h)).collect();
509    let total_verts: usize = ext_len + hole_lens.iter().sum::<usize>();
510    let mut flat = Vec::with_capacity(total_verts * 2);
511
512    // Exterior ring.
513    for c in &exterior[..ext_len] {
514        flat.push(c.lon);
515        flat.push(c.lat);
516    }
517
518    // Holes.
519    let mut hole_starts = Vec::with_capacity(holes.len());
520    let mut offset = ext_len;
521    for (i, hole) in holes.iter().enumerate() {
522        let hl = hole_lens[i];
523        if hl < 3 {
524            continue;
525        }
526        hole_starts.push(offset);
527        for c in &hole[..hl] {
528            flat.push(c.lon);
529            flat.push(c.lat);
530        }
531        offset += hl;
532    }
533
534    earcut_flat(&flat, &hole_starts)
535}
536
537// ---------------------------------------------------------------------------
538// Public API -- line stroke
539// ---------------------------------------------------------------------------
540
541/// Expand a polyline into a thick triangle-strip ribbon.
542///
543/// Each input vertex is extruded along its local normal (perpendicular
544/// to the tangent) by `+/- half_width`, producing two output vertices.
545/// Adjacent quads are then connected with two triangles each.
546///
547/// # Tangent computation
548///
549/// | Vertex position | Tangent source |
550/// |-----------------|----------------|
551/// | First | Forward difference to next vertex |
552/// | Interior | Central difference (average of prev->curr and curr->next) |
553/// | Last | Backward difference from previous vertex |
554///
555/// At sharp bends the averaged tangent may cause the ribbon to narrow
556/// or self-intersect.  A miter-limit strategy is planned for a future
557/// release.
558///
559/// # Returns
560///
561/// `(positions, indices)` where:
562///
563/// - `positions` -- `[lon, lat]` pairs in degree space, two per input
564///   vertex (left and right of the centreline).
565/// - `indices` -- triangle indices into `positions` (6 per segment).
566///
567/// # Edge cases
568///
569/// | Input | Result |
570/// |-------|--------|
571/// | Fewer than 2 coordinates | `(empty, empty)` |
572/// | `half_width <= 0.0` | Degenerate zero-area ribbon (positions collapse onto the centreline) |
573/// | Coincident consecutive vertices | Zero-length tangent yields zero normal -- the two extruded vertices coincide, producing degenerate triangles that are invisible on screen |
574///
575/// # Example
576///
577/// ```
578/// use rustial_engine::stroke_line;
579/// use rustial_engine::GeoCoord;
580///
581/// let line = vec![
582///     GeoCoord::from_lat_lon(0.0, 0.0),
583///     GeoCoord::from_lat_lon(0.0, 1.0),
584/// ];
585/// let (positions, indices) = stroke_line(&line, 0.01);
586/// assert_eq!(positions.len(), 4); // 2 vertices * 2 sides
587/// assert_eq!(indices.len(), 6);   // 1 segment * 6 indices
588/// ```
589pub fn stroke_line(coords: &[GeoCoord], half_width: f64) -> (Vec<[f64; 2]>, Vec<u32>) {
590    if coords.len() < 2 {
591        return (Vec::new(), Vec::new());
592    }
593
594    let vertex_count = coords.len() * 2;
595    let segment_count = coords.len() - 1;
596    let mut positions = Vec::with_capacity(vertex_count);
597    let mut indices = Vec::with_capacity(segment_count * 6);
598
599    for i in 0..coords.len() {
600        let curr = Vec2 {
601            x: coords[i].lon,
602            y: coords[i].lat,
603        };
604
605        // Compute the tangent direction at this vertex.
606        let tangent = if i == 0 {
607            // First vertex: forward difference.
608            let next = Vec2 {
609                x: coords[1].lon,
610                y: coords[1].lat,
611            };
612            normalize(Vec2 {
613                x: next.x - curr.x,
614                y: next.y - curr.y,
615            })
616        } else if i == coords.len() - 1 {
617            // Last vertex: backward difference.
618            let prev = Vec2 {
619                x: coords[i - 1].lon,
620                y: coords[i - 1].lat,
621            };
622            normalize(Vec2 {
623                x: curr.x - prev.x,
624                y: curr.y - prev.y,
625            })
626        } else {
627            // Interior vertex: central difference (averaged tangent).
628            let prev = Vec2 {
629                x: coords[i - 1].lon,
630                y: coords[i - 1].lat,
631            };
632            let next = Vec2 {
633                x: coords[i + 1].lon,
634                y: coords[i + 1].lat,
635            };
636            normalize(Vec2 {
637                x: next.x - prev.x,
638                y: next.y - prev.y,
639            })
640        };
641
642        // The extrusion normal is the 90-degree rotation of the tangent.
643        let normal = Vec2 {
644            x: -tangent.y,
645            y: tangent.x,
646        };
647
648        // Left and right extruded positions.
649        positions.push([
650            curr.x + normal.x * half_width,
651            curr.y + normal.y * half_width,
652        ]);
653        positions.push([
654            curr.x - normal.x * half_width,
655            curr.y - normal.y * half_width,
656        ]);
657    }
658
659    // Connect each pair of extruded vertices into a quad (2 triangles).
660    //
661    //   left[i]  ---- left[i+1]        Indices per quad:
662    //      |    \         |              (base, base+1, base+2)
663    //      |     \        |              (base+1, base+3, base+2)
664    //   right[i] --- right[i+1]
665    //
666    for i in 0..segment_count as u32 {
667        let base = i * 2;
668        indices.push(base);
669        indices.push(base + 1);
670        indices.push(base + 2);
671        indices.push(base + 1);
672        indices.push(base + 3);
673        indices.push(base + 2);
674    }
675
676    (positions, indices)
677}
678
679// ---------------------------------------------------------------------------
680// stroke_line_styled — full line tessellation with cap, join, and per-vertex
681//                      line normals and cumulative distances
682// ---------------------------------------------------------------------------
683
684/// Output of [`stroke_line_styled`].
685#[derive(Debug, Clone)]
686pub struct StrokeLineResult {
687    /// Vertex positions `[lon, lat]` in degree space.
688    pub positions: Vec<[f64; 2]>,
689    /// Triangle indices into `positions`.
690    pub indices: Vec<u32>,
691    /// Per-vertex extrusion normal `[nx, ny]` (unit-length, perpendicular to
692    /// centreline).  Same length as `positions`.
693    pub normals: Vec<[f64; 2]>,
694    /// Per-vertex cumulative distance along the polyline centreline (degrees).
695    /// Same length as `positions`.
696    pub distances: Vec<f64>,
697    /// Per-vertex cap/join flag.  `1.0` for vertices belonging to round
698    /// cap or round join fan geometry (center and perimeter), `0.0` for
699    /// ribbon body, bevel, miter, and square cap vertices.
700    ///
701    /// The GPU shader uses this flag to switch from linear edge AA
702    /// (ribbon body) to SDF circle-based AA (round caps/joins).
703    pub cap_join: Vec<f32>,
704}
705
706/// Number of triangular fan segments used for round caps and round joins.
707const ROUND_SEGMENTS: u32 = 8;
708
709/// Circumscription scale factor: fan perimeter vertices are placed at
710/// `half_width * CIRCUMSCRIBE` so the polygon circumscribes the ideal
711/// circle.  The fragment shader then clips at SDF distance = 1.0,
712/// producing a pixel-perfect round edge regardless of segment count.
713const CIRCUMSCRIBE: f64 = {
714    // 1 / cos(π / (2 * ROUND_SEGMENTS))
715    // For 8 segments: 1 / cos(π/16) ≈ 1.01959
716    // Precomputed as a const because f64::cos isn't const-fn.
717    1.01959
718};
719
720/// Expand a polyline into a thick triangle ribbon with proper line caps,
721/// line joins, per-vertex extrusion normals, and cumulative distances.
722///
723/// This is the richer counterpart of [`stroke_line`].  The additional
724/// outputs enable the GPU line shader to evaluate dash patterns (via
725/// distances) and perform antialiasing (via normals), while the
726/// tessellation itself produces correct cap and join geometry.
727///
728/// # Parameters
729///
730/// - `coords` — polyline vertices in `(lon, lat)` degree space.
731/// - `half_width` — extrusion half-width in the same degree-space units.
732/// - `cap` — line cap style applied at both endpoints.
733/// - `join` — line join style applied at interior vertices.
734/// - `miter_limit` — ratio threshold for automatic miter → bevel fallback.
735pub fn stroke_line_styled(
736    coords: &[GeoCoord],
737    half_width: f64,
738    cap: LineCap,
739    join: LineJoin,
740    miter_limit: f32,
741) -> StrokeLineResult {
742    let empty = StrokeLineResult {
743        positions: Vec::new(),
744        indices: Vec::new(),
745        normals: Vec::new(),
746        distances: Vec::new(),
747        cap_join: Vec::new(),
748    };
749    if coords.len() < 2 {
750        return empty;
751    }
752
753    // Pre-compute per-segment tangent, normal, and length.
754    let seg_count = coords.len() - 1;
755    let mut seg_tangents = Vec::with_capacity(seg_count);
756    let mut seg_normals = Vec::with_capacity(seg_count);
757    let mut seg_lengths = Vec::with_capacity(seg_count);
758
759    for i in 0..seg_count {
760        let dx = coords[i + 1].lon - coords[i].lon;
761        let dy = coords[i + 1].lat - coords[i].lat;
762        let len = (dx * dx + dy * dy).sqrt();
763        let t = if len < 1e-15 {
764            Vec2 { x: 1.0, y: 0.0 }
765        } else {
766            Vec2 {
767                x: dx / len,
768                y: dy / len,
769            }
770        };
771        let n = Vec2 { x: -t.y, y: t.x };
772        seg_tangents.push(t);
773        seg_normals.push(n);
774        seg_lengths.push(len);
775    }
776
777    // Cumulative distance at each vertex along the centreline.
778    let mut cum_dist = Vec::with_capacity(coords.len());
779    cum_dist.push(0.0);
780    for i in 0..seg_count {
781        cum_dist.push(cum_dist[i] + seg_lengths[i]);
782    }
783
784    // --- Build output arrays ---
785    // Conservative capacity: 2 verts per original vertex + cap + join extras.
786    let cap_extra = match cap {
787        LineCap::Round => ROUND_SEGMENTS as usize * 2,
788        _ => 2,
789    };
790    let join_extra = match join {
791        LineJoin::Round => ROUND_SEGMENTS as usize,
792        _ => 2,
793    };
794    let est_verts = coords.len() * 2 + cap_extra * 2 + join_extra * seg_count;
795    let est_indices = seg_count * 6 + cap_extra * 6 + join_extra * 3 * seg_count;
796    let mut positions = Vec::with_capacity(est_verts);
797    let mut normals = Vec::with_capacity(est_verts);
798    let mut distances = Vec::with_capacity(est_verts);
799    let mut cap_join_flags = Vec::with_capacity(est_verts);
800    let mut indices = Vec::with_capacity(est_indices);
801
802    // Helper: push a vertex and return its index.
803    #[inline]
804    #[allow(clippy::too_many_arguments)]
805    fn push_vert(
806        positions: &mut Vec<[f64; 2]>,
807        normals: &mut Vec<[f64; 2]>,
808        distances: &mut Vec<f64>,
809        cap_join_flags: &mut Vec<f32>,
810        pos: [f64; 2],
811        nrm: [f64; 2],
812        dist: f64,
813        cj: f32,
814    ) -> u32 {
815        let idx = positions.len() as u32;
816        positions.push(pos);
817        normals.push(nrm);
818        distances.push(dist);
819        cap_join_flags.push(cj);
820        idx
821    }
822
823    // --- Start cap ---
824    let first_n = seg_normals[0];
825    let first_t = seg_tangents[0];
826    let cx = coords[0].lon;
827    let cy = coords[0].lat;
828
829    match cap {
830        LineCap::Butt => {
831            // Two vertices at the start, perpendicular to the first segment.
832            let l = push_vert(
833                &mut positions,
834                &mut normals,
835                &mut distances,
836                &mut cap_join_flags,
837                [cx + first_n.x * half_width, cy + first_n.y * half_width],
838                [first_n.x, first_n.y],
839                0.0,
840                0.0,
841            );
842            let r = push_vert(
843                &mut positions,
844                &mut normals,
845                &mut distances,
846                &mut cap_join_flags,
847                [cx - first_n.x * half_width, cy - first_n.y * half_width],
848                [-first_n.x, -first_n.y],
849                0.0,
850                0.0,
851            );
852            let _ = (l, r); // stored in the buffer for the first segment
853        }
854        LineCap::Square => {
855            // Extend half_width backwards along the tangent.
856            let bx = cx - first_t.x * half_width;
857            let by = cy - first_t.y * half_width;
858            push_vert(
859                &mut positions,
860                &mut normals,
861                &mut distances,
862                &mut cap_join_flags,
863                [bx + first_n.x * half_width, by + first_n.y * half_width],
864                [first_n.x, first_n.y],
865                0.0,
866                0.0,
867            );
868            push_vert(
869                &mut positions,
870                &mut normals,
871                &mut distances,
872                &mut cap_join_flags,
873                [bx - first_n.x * half_width, by - first_n.y * half_width],
874                [-first_n.x, -first_n.y],
875                0.0,
876                0.0,
877            );
878        }
879        LineCap::Round => {
880            // Semicircular fan at the start, sweeping from left-normal
881            // backwards through -tangent to right-normal.
882            let center_idx = push_vert(
883                &mut positions,
884                &mut normals,
885                &mut distances,
886                &mut cap_join_flags,
887                [cx, cy],
888                [0.0, 0.0],
889                0.0,
890                1.0,
891            );
892            // Start angle: direction of left normal.
893            let start_angle = first_n.y.atan2(first_n.x);
894            // We sweep π radians (semicircle) on the back side.
895            // Fan perimeter vertices are placed at the circumscribed radius
896            // so the polygon extends slightly beyond the ideal circle.
897            // The shader's SDF clip at normal_length = 1.0 produces a
898            // pixel-perfect round edge.
899            let hw_circ = half_width * CIRCUMSCRIBE;
900            let mut fan_verts = Vec::with_capacity(ROUND_SEGMENTS as usize + 1);
901            for k in 0..=ROUND_SEGMENTS {
902                let a = start_angle + std::f64::consts::PI * k as f64 / ROUND_SEGMENTS as f64;
903                let nx = a.cos();
904                let ny = a.sin();
905                let v = push_vert(
906                    &mut positions,
907                    &mut normals,
908                    &mut distances,
909                    &mut cap_join_flags,
910                    [cx + nx * hw_circ, cy + ny * hw_circ],
911                    [nx * CIRCUMSCRIBE, ny * CIRCUMSCRIBE],
912                    0.0,
913                    1.0,
914                );
915                fan_verts.push(v);
916            }
917            for k in 0..ROUND_SEGMENTS {
918                indices.push(center_idx);
919                indices.push(fan_verts[k as usize + 1]);
920                indices.push(fan_verts[k as usize]);
921            }
922            // The first and last fan vertices become the left/right of the
923            // ribbon at vertex 0.  We need two "current" vertices for the
924            // quad strip — reuse the first and last fan vertices.
925            // Push the ribbon-start pair referencing the same positions.
926            push_vert(
927                &mut positions,
928                &mut normals,
929                &mut distances,
930                &mut cap_join_flags,
931                [cx + first_n.x * half_width, cy + first_n.y * half_width],
932                [first_n.x, first_n.y],
933                0.0,
934                0.0,
935            );
936            push_vert(
937                &mut positions,
938                &mut normals,
939                &mut distances,
940                &mut cap_join_flags,
941                [cx - first_n.x * half_width, cy - first_n.y * half_width],
942                [-first_n.x, -first_n.y],
943                0.0,
944                0.0,
945            );
946        }
947    }
948
949    // The last two vertices in the buffer are always the current left/right
950    // pair that the next quad strip segment connects from.
951    // After the start cap they are at positions.len()-2 and positions.len()-1.
952
953    // --- Interior segments with joins ---
954    for i in 0..seg_count {
955        let n = seg_normals[i];
956        let dist = cum_dist[i + 1];
957        let vx = coords[i + 1].lon;
958        let vy = coords[i + 1].lat;
959
960        // The previous left/right pair.
961        let prev_left = positions.len() as u32 - 2;
962        let prev_right = positions.len() as u32 - 1;
963
964        if i < seg_count - 1 {
965            // Interior vertex — need a join.
966            let n_next = seg_normals[i + 1];
967
968            // Determine the miter direction (bisector normal).
969            let bx = n.x + n_next.x;
970            let by = n.y + n_next.y;
971            let blen = (bx * bx + by * by).sqrt();
972
973            // Cross product of segment tangents to determine turn direction.
974            let cross = seg_tangents[i].x * seg_tangents[i + 1].y
975                - seg_tangents[i].y * seg_tangents[i + 1].x;
976
977            if blen < 1e-12 {
978                // Near-reversal: segments are anti-parallel.  Use bevel.
979                let l = push_vert(
980                    &mut positions,
981                    &mut normals,
982                    &mut distances,
983                    &mut cap_join_flags,
984                    [vx + n.x * half_width, vy + n.y * half_width],
985                    [n.x, n.y],
986                    dist,
987                    0.0,
988                );
989                let r = push_vert(
990                    &mut positions,
991                    &mut normals,
992                    &mut distances,
993                    &mut cap_join_flags,
994                    [vx - n.x * half_width, vy - n.y * half_width],
995                    [-n.x, -n.y],
996                    dist,
997                    0.0,
998                );
999                // Quad from previous pair to current.
1000                indices.extend_from_slice(&[prev_left, prev_right, l, prev_right, r, l]);
1001                // Bevel triangle to next-segment normal.
1002                let l2 = push_vert(
1003                    &mut positions,
1004                    &mut normals,
1005                    &mut distances,
1006                    &mut cap_join_flags,
1007                    [vx + n_next.x * half_width, vy + n_next.y * half_width],
1008                    [n_next.x, n_next.y],
1009                    dist,
1010                    0.0,
1011                );
1012                let r2 = push_vert(
1013                    &mut positions,
1014                    &mut normals,
1015                    &mut distances,
1016                    &mut cap_join_flags,
1017                    [vx - n_next.x * half_width, vy - n_next.y * half_width],
1018                    [-n_next.x, -n_next.y],
1019                    dist,
1020                    0.0,
1021                );
1022                indices.extend_from_slice(&[l, r, l2, r, r2, l2]);
1023            } else {
1024                let miter_nx = bx / blen;
1025                let miter_ny = by / blen;
1026                // Miter length ratio = 1 / sin(half-angle).
1027                let dot = n.x * miter_nx + n.y * miter_ny;
1028                let miter_len = if dot.abs() > 1e-12 {
1029                    1.0 / dot
1030                } else {
1031                    miter_limit as f64 + 1.0
1032                };
1033
1034                let use_miter = matches!(join, LineJoin::Miter) && miter_len <= miter_limit as f64;
1035
1036                if use_miter {
1037                    // Miter join: single vertex pair along the bisector.
1038                    let hw_m = half_width * miter_len;
1039                    let l = push_vert(
1040                        &mut positions,
1041                        &mut normals,
1042                        &mut distances,
1043                        &mut cap_join_flags,
1044                        [vx + miter_nx * hw_m, vy + miter_ny * hw_m],
1045                        [miter_nx, miter_ny],
1046                        dist,
1047                        0.0,
1048                    );
1049                    let r = push_vert(
1050                        &mut positions,
1051                        &mut normals,
1052                        &mut distances,
1053                        &mut cap_join_flags,
1054                        [vx - miter_nx * hw_m, vy - miter_ny * hw_m],
1055                        [-miter_nx, -miter_ny],
1056                        dist,
1057                        0.0,
1058                    );
1059                    indices.extend_from_slice(&[prev_left, prev_right, l, prev_right, r, l]);
1060                } else if matches!(join, LineJoin::Round) {
1061                    // Close the current segment with the incoming normal.
1062                    let l_in = push_vert(
1063                        &mut positions,
1064                        &mut normals,
1065                        &mut distances,
1066                        &mut cap_join_flags,
1067                        [vx + n.x * half_width, vy + n.y * half_width],
1068                        [n.x, n.y],
1069                        dist,
1070                        0.0,
1071                    );
1072                    let r_in = push_vert(
1073                        &mut positions,
1074                        &mut normals,
1075                        &mut distances,
1076                        &mut cap_join_flags,
1077                        [vx - n.x * half_width, vy - n.y * half_width],
1078                        [-n.x, -n.y],
1079                        dist,
1080                        0.0,
1081                    );
1082                    indices
1083                        .extend_from_slice(&[prev_left, prev_right, l_in, prev_right, r_in, l_in]);
1084
1085                    // Round join fan on the outer side.
1086                    // Fan perimeter vertices use circumscribed radius for SDF AA.
1087                    let hw_circ = half_width * CIRCUMSCRIBE;
1088                    let center_idx = push_vert(
1089                        &mut positions,
1090                        &mut normals,
1091                        &mut distances,
1092                        &mut cap_join_flags,
1093                        [vx, vy],
1094                        [0.0, 0.0],
1095                        dist,
1096                        1.0,
1097                    );
1098
1099                    if cross > 0.0 {
1100                        // Left turn: outer side is the left (positive normal).
1101                        let a0 = n.y.atan2(n.x);
1102                        let a1 = n_next.y.atan2(n_next.x);
1103                        let mut da = a1 - a0;
1104                        if da < 0.0 {
1105                            da += 2.0 * std::f64::consts::PI;
1106                        }
1107                        let steps = ROUND_SEGMENTS;
1108                        let mut prev_v = l_in;
1109                        for k in 1..=steps {
1110                            let a = a0 + da * k as f64 / steps as f64;
1111                            let nx = a.cos();
1112                            let ny = a.sin();
1113                            let v = push_vert(
1114                                &mut positions,
1115                                &mut normals,
1116                                &mut distances,
1117                                &mut cap_join_flags,
1118                                [vx + nx * hw_circ, vy + ny * hw_circ],
1119                                [nx * CIRCUMSCRIBE, ny * CIRCUMSCRIBE],
1120                                dist,
1121                                1.0,
1122                            );
1123                            indices.extend_from_slice(&[center_idx, prev_v, v]);
1124                            prev_v = v;
1125                        }
1126                        // The new left/right pair for the next segment.
1127                        push_vert(
1128                            &mut positions,
1129                            &mut normals,
1130                            &mut distances,
1131                            &mut cap_join_flags,
1132                            [vx + n_next.x * half_width, vy + n_next.y * half_width],
1133                            [n_next.x, n_next.y],
1134                            dist,
1135                            0.0,
1136                        );
1137                        push_vert(
1138                            &mut positions,
1139                            &mut normals,
1140                            &mut distances,
1141                            &mut cap_join_flags,
1142                            [vx - n_next.x * half_width, vy - n_next.y * half_width],
1143                            [-n_next.x, -n_next.y],
1144                            dist,
1145                            0.0,
1146                        );
1147                    } else {
1148                        // Right turn: outer side is the right (negative normal).
1149                        let a0 = (-n.y).atan2(-n.x);
1150                        let a1 = (-n_next.y).atan2(-n_next.x);
1151                        let mut da = a1 - a0;
1152                        if da > 0.0 {
1153                            da -= 2.0 * std::f64::consts::PI;
1154                        }
1155                        let steps = ROUND_SEGMENTS;
1156                        let mut prev_v = r_in;
1157                        for k in 1..=steps {
1158                            let a = a0 + da * k as f64 / steps as f64;
1159                            let nx = a.cos();
1160                            let ny = a.sin();
1161                            let v = push_vert(
1162                                &mut positions,
1163                                &mut normals,
1164                                &mut distances,
1165                                &mut cap_join_flags,
1166                                [vx + nx * hw_circ, vy + ny * hw_circ],
1167                                [nx * CIRCUMSCRIBE, ny * CIRCUMSCRIBE],
1168                                dist,
1169                                1.0,
1170                            );
1171                            indices.extend_from_slice(&[center_idx, v, prev_v]);
1172                            prev_v = v;
1173                        }
1174                        push_vert(
1175                            &mut positions,
1176                            &mut normals,
1177                            &mut distances,
1178                            &mut cap_join_flags,
1179                            [vx + n_next.x * half_width, vy + n_next.y * half_width],
1180                            [n_next.x, n_next.y],
1181                            dist,
1182                            0.0,
1183                        );
1184                        push_vert(
1185                            &mut positions,
1186                            &mut normals,
1187                            &mut distances,
1188                            &mut cap_join_flags,
1189                            [vx - n_next.x * half_width, vy - n_next.y * half_width],
1190                            [-n_next.x, -n_next.y],
1191                            dist,
1192                            0.0,
1193                        );
1194                    }
1195                } else {
1196                    // Bevel join (also miter fallback).
1197                    let l_in = push_vert(
1198                        &mut positions,
1199                        &mut normals,
1200                        &mut distances,
1201                        &mut cap_join_flags,
1202                        [vx + n.x * half_width, vy + n.y * half_width],
1203                        [n.x, n.y],
1204                        dist,
1205                        0.0,
1206                    );
1207                    let r_in = push_vert(
1208                        &mut positions,
1209                        &mut normals,
1210                        &mut distances,
1211                        &mut cap_join_flags,
1212                        [vx - n.x * half_width, vy - n.y * half_width],
1213                        [-n.x, -n.y],
1214                        dist,
1215                        0.0,
1216                    );
1217                    indices
1218                        .extend_from_slice(&[prev_left, prev_right, l_in, prev_right, r_in, l_in]);
1219
1220                    // Bevel triangle to the next-segment normal.
1221                    let l2 = push_vert(
1222                        &mut positions,
1223                        &mut normals,
1224                        &mut distances,
1225                        &mut cap_join_flags,
1226                        [vx + n_next.x * half_width, vy + n_next.y * half_width],
1227                        [n_next.x, n_next.y],
1228                        dist,
1229                        0.0,
1230                    );
1231                    let r2 = push_vert(
1232                        &mut positions,
1233                        &mut normals,
1234                        &mut distances,
1235                        &mut cap_join_flags,
1236                        [vx - n_next.x * half_width, vy - n_next.y * half_width],
1237                        [-n_next.x, -n_next.y],
1238                        dist,
1239                        0.0,
1240                    );
1241                    indices.extend_from_slice(&[l_in, r_in, l2, r_in, r2, l2]);
1242                }
1243            }
1244        } else {
1245            // Last vertex — no join needed.  Emit the endpoint pair.
1246            let l = push_vert(
1247                &mut positions,
1248                &mut normals,
1249                &mut distances,
1250                &mut cap_join_flags,
1251                [vx + n.x * half_width, vy + n.y * half_width],
1252                [n.x, n.y],
1253                dist,
1254                0.0,
1255            );
1256            let r = push_vert(
1257                &mut positions,
1258                &mut normals,
1259                &mut distances,
1260                &mut cap_join_flags,
1261                [vx - n.x * half_width, vy - n.y * half_width],
1262                [-n.x, -n.y],
1263                dist,
1264                0.0,
1265            );
1266            indices.extend_from_slice(&[prev_left, prev_right, l, prev_right, r, l]);
1267        }
1268    }
1269
1270    // --- End cap ---
1271    let last_n = seg_normals[seg_count - 1];
1272    let last_t = seg_tangents[seg_count - 1];
1273    let ex = coords[coords.len() - 1].lon;
1274    let ey = coords[coords.len() - 1].lat;
1275    let end_dist = cum_dist[coords.len() - 1];
1276
1277    match cap {
1278        LineCap::Butt => { /* already terminated by the last quad */ }
1279        LineCap::Square => {
1280            let prev_left = positions.len() as u32 - 2;
1281            let prev_right = positions.len() as u32 - 1;
1282            let fx = ex + last_t.x * half_width;
1283            let fy = ey + last_t.y * half_width;
1284            let l = push_vert(
1285                &mut positions,
1286                &mut normals,
1287                &mut distances,
1288                &mut cap_join_flags,
1289                [fx + last_n.x * half_width, fy + last_n.y * half_width],
1290                [last_n.x, last_n.y],
1291                end_dist,
1292                0.0,
1293            );
1294            let r = push_vert(
1295                &mut positions,
1296                &mut normals,
1297                &mut distances,
1298                &mut cap_join_flags,
1299                [fx - last_n.x * half_width, fy - last_n.y * half_width],
1300                [-last_n.x, -last_n.y],
1301                end_dist,
1302                0.0,
1303            );
1304            indices.extend_from_slice(&[prev_left, prev_right, l, prev_right, r, l]);
1305        }
1306        LineCap::Round => {
1307            let center_idx = push_vert(
1308                &mut positions,
1309                &mut normals,
1310                &mut distances,
1311                &mut cap_join_flags,
1312                [ex, ey],
1313                [0.0, 0.0],
1314                end_dist,
1315                1.0,
1316            );
1317            // Semicircular fan on the forward side with circumscribed radius.
1318            let hw_circ = half_width * CIRCUMSCRIBE;
1319            let start_angle = last_n.y.atan2(last_n.x);
1320            let mut fan_verts = Vec::with_capacity(ROUND_SEGMENTS as usize + 1);
1321            for k in 0..=ROUND_SEGMENTS {
1322                let a = start_angle - std::f64::consts::PI * k as f64 / ROUND_SEGMENTS as f64;
1323                let nx = a.cos();
1324                let ny = a.sin();
1325                let v = push_vert(
1326                    &mut positions,
1327                    &mut normals,
1328                    &mut distances,
1329                    &mut cap_join_flags,
1330                    [ex + nx * hw_circ, ey + ny * hw_circ],
1331                    [nx * CIRCUMSCRIBE, ny * CIRCUMSCRIBE],
1332                    end_dist,
1333                    1.0,
1334                );
1335                fan_verts.push(v);
1336            }
1337            // Connect the fan to the last ribbon pair.
1338            let prev_left = center_idx - 2;
1339            let prev_right = center_idx - 1;
1340            indices.extend_from_slice(&[prev_left, fan_verts[0], center_idx]);
1341            indices.extend_from_slice(&[
1342                prev_right,
1343                center_idx,
1344                fan_verts[ROUND_SEGMENTS as usize],
1345            ]);
1346            for k in 0..ROUND_SEGMENTS {
1347                indices.push(center_idx);
1348                indices.push(fan_verts[k as usize]);
1349                indices.push(fan_verts[k as usize + 1]);
1350            }
1351        }
1352    }
1353
1354    StrokeLineResult {
1355        positions,
1356        indices,
1357        normals,
1358        distances,
1359        cap_join: cap_join_flags,
1360    }
1361}
1362
1363// ---------------------------------------------------------------------------
1364// Tests
1365// ---------------------------------------------------------------------------
1366
1367#[cfg(test)]
1368mod tests {
1369    use super::*;
1370
1371    // =====================================================================
1372    // triangulate_polygon
1373    // =====================================================================
1374
1375    #[test]
1376    fn triangulate_empty() {
1377        assert!(triangulate_polygon(&[]).is_empty());
1378    }
1379
1380    #[test]
1381    fn triangulate_single_point() {
1382        assert!(triangulate_polygon(&[GeoCoord::from_lat_lon(0.0, 0.0)]).is_empty());
1383    }
1384
1385    #[test]
1386    fn triangulate_two_points() {
1387        let coords = vec![
1388            GeoCoord::from_lat_lon(0.0, 0.0),
1389            GeoCoord::from_lat_lon(1.0, 1.0),
1390        ];
1391        assert!(triangulate_polygon(&coords).is_empty());
1392    }
1393
1394    #[test]
1395    fn triangulate_triangle() {
1396        let coords = vec![
1397            GeoCoord::from_lat_lon(0.0, 0.0),
1398            GeoCoord::from_lat_lon(0.0, 1.0),
1399            GeoCoord::from_lat_lon(1.0, 0.0),
1400        ];
1401        let indices = triangulate_polygon(&coords);
1402        assert_eq!(indices.len(), 3);
1403        // All three vertices must appear exactly once.
1404        let mut sorted = indices.clone();
1405        sorted.sort();
1406        assert_eq!(sorted, vec![0, 1, 2]);
1407    }
1408
1409    #[test]
1410    fn triangulate_square() {
1411        let coords = vec![
1412            GeoCoord::from_lat_lon(0.0, 0.0),
1413            GeoCoord::from_lat_lon(0.0, 1.0),
1414            GeoCoord::from_lat_lon(1.0, 1.0),
1415            GeoCoord::from_lat_lon(1.0, 0.0),
1416        ];
1417        let indices = triangulate_polygon(&coords);
1418        assert_eq!(indices.len(), 6); // 2 triangles
1419        assert!(indices.iter().all(|&i| i < 4));
1420    }
1421
1422    #[test]
1423    fn triangulate_closed_polygon() {
1424        // Closing vertex (duplicate of first) should be stripped.
1425        let coords = vec![
1426            GeoCoord::from_lat_lon(0.0, 0.0),
1427            GeoCoord::from_lat_lon(0.0, 1.0),
1428            GeoCoord::from_lat_lon(1.0, 1.0),
1429            GeoCoord::from_lat_lon(1.0, 0.0),
1430            GeoCoord::from_lat_lon(0.0, 0.0),
1431        ];
1432        let indices = triangulate_polygon(&coords);
1433        assert_eq!(indices.len(), 6); // 2 triangles, closing vertex stripped
1434                                      // All indices must reference the first 4 vertices (not the 5th).
1435        assert!(indices.iter().all(|&i| i < 4));
1436    }
1437
1438    #[test]
1439    fn triangulate_closing_vertex_reduces_below_three() {
1440        // 3 vertices where last == first -> effective_len = 2 -> degenerate.
1441        let coords = vec![
1442            GeoCoord::from_lat_lon(0.0, 0.0),
1443            GeoCoord::from_lat_lon(1.0, 1.0),
1444            GeoCoord::from_lat_lon(0.0, 0.0),
1445        ];
1446        // n=3, closing detected (but only when n > 3), so no stripping.
1447        // effective_len stays 3. This produces 1 degenerate triangle --
1448        // the function does not reject degenerate triangles, which is
1449        // fine (they render as nothing).
1450        let indices = triangulate_polygon(&coords);
1451        assert_eq!(indices.len(), 3);
1452    }
1453
1454    #[test]
1455    fn triangulate_pentagon() {
1456        let coords = vec![
1457            GeoCoord::from_lat_lon(0.0, 0.0),
1458            GeoCoord::from_lat_lon(0.0, 2.0),
1459            GeoCoord::from_lat_lon(1.0, 3.0),
1460            GeoCoord::from_lat_lon(2.0, 2.0),
1461            GeoCoord::from_lat_lon(2.0, 0.0),
1462        ];
1463        let indices = triangulate_polygon(&coords);
1464        // 5 vertices -> 3 triangles -> 9 indices.
1465        assert_eq!(indices.len(), 9);
1466        assert!(indices.iter().all(|&i| i < 5));
1467    }
1468
1469    #[test]
1470    fn triangulate_indices_are_valid() {
1471        // For any N-gon, every index must be in [0, effective_len).
1472        let coords: Vec<GeoCoord> = (0..20)
1473            .map(|i| {
1474                let angle = 2.0 * std::f64::consts::PI * i as f64 / 20.0;
1475                GeoCoord::from_lat_lon(angle.sin() * 10.0, angle.cos() * 10.0)
1476            })
1477            .collect();
1478        let indices = triangulate_polygon(&coords);
1479        assert_eq!(indices.len(), 18 * 3); // 20 - 2 = 18 triangles
1480        assert!(indices.iter().all(|&i| (i as usize) < coords.len()));
1481    }
1482
1483    // =====================================================================
1484    // stroke_line
1485    // =====================================================================
1486
1487    #[test]
1488    fn stroke_empty() {
1489        let (p, i) = stroke_line(&[], 1.0);
1490        assert!(p.is_empty());
1491        assert!(i.is_empty());
1492    }
1493
1494    #[test]
1495    fn stroke_single_point() {
1496        let (p, i) = stroke_line(&[GeoCoord::from_lat_lon(0.0, 0.0)], 1.0);
1497        assert!(p.is_empty());
1498        assert!(i.is_empty());
1499    }
1500
1501    #[test]
1502    fn stroke_line_two_points() {
1503        let coords = vec![
1504            GeoCoord::from_lat_lon(0.0, 0.0),
1505            GeoCoord::from_lat_lon(0.0, 1.0),
1506        ];
1507        let (positions, indices) = stroke_line(&coords, 0.01);
1508        assert_eq!(positions.len(), 4); // 2 vertices * 2 sides
1509        assert_eq!(indices.len(), 6); // 1 segment * 6 indices
1510    }
1511
1512    #[test]
1513    fn stroke_line_three_points() {
1514        let coords = vec![
1515            GeoCoord::from_lat_lon(0.0, 0.0),
1516            GeoCoord::from_lat_lon(0.0, 1.0),
1517            GeoCoord::from_lat_lon(0.0, 2.0),
1518        ];
1519        let (positions, indices) = stroke_line(&coords, 0.01);
1520        assert_eq!(positions.len(), 6); // 3 vertices * 2 sides
1521        assert_eq!(indices.len(), 12); // 2 segments * 6 indices
1522    }
1523
1524    #[test]
1525    fn stroke_ribbon_has_nonzero_width() {
1526        // A horizontal east-west line should be extruded north-south.
1527        let coords = vec![
1528            GeoCoord::from_lat_lon(0.0, 0.0),
1529            GeoCoord::from_lat_lon(0.0, 1.0),
1530        ];
1531        let hw = 0.5;
1532        let (positions, _) = stroke_line(&coords, hw);
1533
1534        // Vertex 0 (left) and vertex 1 (right) at the first point.
1535        let left = positions[0];
1536        let right = positions[1];
1537
1538        // The extrusion should be in the Y (lat) direction because the
1539        // tangent is east-west (X = lon direction), so the normal is
1540        // north-south.
1541        let dy = (left[1] - right[1]).abs();
1542        assert!(
1543            (dy - 2.0 * hw).abs() < 1e-12,
1544            "expected ribbon width {}, got {dy}",
1545            2.0 * hw
1546        );
1547    }
1548
1549    #[test]
1550    fn stroke_indices_are_valid() {
1551        let coords: Vec<GeoCoord> = (0..10)
1552            .map(|i| GeoCoord::from_lat_lon(0.0, i as f64))
1553            .collect();
1554        let (positions, indices) = stroke_line(&coords, 0.01);
1555        let max_idx = positions.len() as u32;
1556        assert!(
1557            indices.iter().all(|&i| i < max_idx),
1558            "all indices must be within the position buffer"
1559        );
1560    }
1561
1562    #[test]
1563    fn stroke_zero_width() {
1564        // Zero half-width should produce degenerate (zero-area) quads
1565        // without panicking.
1566        let coords = vec![
1567            GeoCoord::from_lat_lon(0.0, 0.0),
1568            GeoCoord::from_lat_lon(0.0, 1.0),
1569        ];
1570        let (positions, indices) = stroke_line(&coords, 0.0);
1571        assert_eq!(positions.len(), 4);
1572        assert_eq!(indices.len(), 6);
1573        // Left and right should coincide.
1574        assert_eq!(positions[0], positions[1]);
1575    }
1576
1577    #[test]
1578    fn stroke_coincident_points() {
1579        // Two identical points: tangent is zero-length, normal is zero.
1580        // Should not panic.
1581        let coords = vec![
1582            GeoCoord::from_lat_lon(5.0, 10.0),
1583            GeoCoord::from_lat_lon(5.0, 10.0),
1584        ];
1585        let (positions, indices) = stroke_line(&coords, 0.01);
1586        assert_eq!(positions.len(), 4);
1587        assert_eq!(indices.len(), 6);
1588    }
1589
1590    // =====================================================================
1591    // normalize
1592    // =====================================================================
1593
1594    #[test]
1595    fn normalize_unit_vector() {
1596        let v = normalize(Vec2 { x: 3.0, y: 4.0 });
1597        let len = (v.x * v.x + v.y * v.y).sqrt();
1598        assert!((len - 1.0).abs() < 1e-12);
1599    }
1600
1601    #[test]
1602    fn normalize_zero_vector() {
1603        let v = normalize(Vec2 { x: 0.0, y: 0.0 });
1604        assert_eq!(v.x, 0.0);
1605        assert_eq!(v.y, 0.0);
1606    }
1607
1608    #[test]
1609    fn normalize_tiny_vector() {
1610        // Below the 1e-15 threshold.
1611        let v = normalize(Vec2 { x: 1e-16, y: 0.0 });
1612        assert_eq!(v.x, 0.0);
1613        assert_eq!(v.y, 0.0);
1614    }
1615
1616    // =====================================================================
1617    // earcut -- concave polygons
1618    // =====================================================================
1619
1620    #[test]
1621    fn triangulate_concave_l_shape() {
1622        // An L-shaped polygon that is concave.
1623        //
1624        //  (0,2)---(1,2)
1625        //    |       |
1626        //  (0,1)---(1,1)---(2,1)
1627        //                    |
1628        //  (0,0)-----------(2,0)
1629        let coords = vec![
1630            GeoCoord::from_lat_lon(0.0, 0.0),
1631            GeoCoord::from_lat_lon(0.0, 2.0),
1632            GeoCoord::from_lat_lon(1.0, 2.0),
1633            GeoCoord::from_lat_lon(1.0, 1.0),
1634            GeoCoord::from_lat_lon(2.0, 1.0),
1635            GeoCoord::from_lat_lon(2.0, 0.0),
1636        ];
1637        let indices = triangulate_polygon(&coords);
1638        // 6 vertices -> 4 triangles -> 12 indices.
1639        assert_eq!(indices.len(), 12);
1640        assert!(indices.iter().all(|&i| i < 6));
1641
1642        // Verify total signed area matches the expected L-shape area:
1643        // Full 2x2 square minus 1x1 corner = 3.
1644        let area: f64 = indices
1645            .chunks(3)
1646            .map(|tri| {
1647                let a = &coords[tri[0] as usize];
1648                let b = &coords[tri[1] as usize];
1649                let c = &coords[tri[2] as usize];
1650                // Signed area of triangle (positive for CCW, negative for CW).
1651                ((b.lon - a.lon) * (c.lat - a.lat) - (c.lon - a.lon) * (b.lat - a.lat)) * 0.5
1652            })
1653            .sum::<f64>()
1654            .abs();
1655        assert!(
1656            (area - 3.0).abs() < 1e-6,
1657            "L-shape area should be 3.0, got {area}"
1658        );
1659    }
1660
1661    // =====================================================================
1662    // earcut -- polygon with holes
1663    // =====================================================================
1664
1665    #[test]
1666    fn triangulate_with_hole() {
1667        let exterior = vec![
1668            GeoCoord::from_lat_lon(0.0, 0.0),
1669            GeoCoord::from_lat_lon(0.0, 4.0),
1670            GeoCoord::from_lat_lon(4.0, 4.0),
1671            GeoCoord::from_lat_lon(4.0, 0.0),
1672        ];
1673        let hole = vec![
1674            GeoCoord::from_lat_lon(1.0, 1.0),
1675            GeoCoord::from_lat_lon(1.0, 3.0),
1676            GeoCoord::from_lat_lon(3.0, 3.0),
1677            GeoCoord::from_lat_lon(3.0, 1.0),
1678        ];
1679        let indices = triangulate_polygon_with_holes(&exterior, &[&hole]);
1680        // 4 exterior + 4 hole = 8 vertices -> 6 triangles -> 18 indices.
1681        assert_eq!(indices.len() % 3, 0);
1682        assert!(!indices.is_empty());
1683        // All indices must reference valid vertices.
1684        assert!(indices.iter().all(|&i| (i as usize) < 8));
1685    }
1686
1687    #[test]
1688    fn triangulate_with_hole_empty_exterior() {
1689        let exterior: Vec<GeoCoord> = Vec::new();
1690        let hole = vec![
1691            GeoCoord::from_lat_lon(1.0, 1.0),
1692            GeoCoord::from_lat_lon(1.0, 3.0),
1693            GeoCoord::from_lat_lon(3.0, 3.0),
1694        ];
1695        assert!(triangulate_polygon_with_holes(&exterior, &[&hole]).is_empty());
1696    }
1697
1698    // =====================================================================
1699    // stroke_line_styled
1700    // =====================================================================
1701
1702    #[test]
1703    fn styled_stroke_empty() {
1704        let r = stroke_line_styled(&[], 1.0, LineCap::Butt, LineJoin::Miter, 2.0);
1705        assert!(r.positions.is_empty());
1706        assert!(r.indices.is_empty());
1707        assert!(r.normals.is_empty());
1708        assert!(r.distances.is_empty());
1709    }
1710
1711    #[test]
1712    fn styled_stroke_single_point() {
1713        let r = stroke_line_styled(
1714            &[GeoCoord::from_lat_lon(0.0, 0.0)],
1715            1.0,
1716            LineCap::Butt,
1717            LineJoin::Miter,
1718            2.0,
1719        );
1720        assert!(r.positions.is_empty());
1721    }
1722
1723    #[test]
1724    fn styled_stroke_butt_cap_two_points() {
1725        let coords = vec![
1726            GeoCoord::from_lat_lon(0.0, 0.0),
1727            GeoCoord::from_lat_lon(0.0, 1.0),
1728        ];
1729        let r = stroke_line_styled(&coords, 0.01, LineCap::Butt, LineJoin::Miter, 2.0);
1730        // 2 start-cap verts + 2 end-segment verts = 4
1731        assert_eq!(r.positions.len(), 4);
1732        assert_eq!(r.normals.len(), 4);
1733        assert_eq!(r.distances.len(), 4);
1734        assert!(!r.indices.is_empty());
1735        // All indices valid.
1736        let max_idx = r.positions.len() as u32;
1737        assert!(r.indices.iter().all(|&i| i < max_idx));
1738    }
1739
1740    #[test]
1741    fn styled_stroke_square_cap_extends_beyond_endpoints() {
1742        let coords = vec![
1743            GeoCoord::from_lat_lon(0.0, 0.0),
1744            GeoCoord::from_lat_lon(0.0, 1.0),
1745        ];
1746        let hw = 0.1;
1747        let r = stroke_line_styled(&coords, hw, LineCap::Square, LineJoin::Miter, 2.0);
1748        // Square caps extend half_width backwards, so min lon should be < 0.
1749        let min_lon: f64 = r
1750            .positions
1751            .iter()
1752            .map(|p| p[0])
1753            .fold(f64::INFINITY, f64::min);
1754        assert!(
1755            min_lon < -0.05,
1756            "square cap should extend before the first vertex, got {min_lon}"
1757        );
1758    }
1759
1760    #[test]
1761    fn styled_stroke_round_cap_produces_more_vertices() {
1762        let coords = vec![
1763            GeoCoord::from_lat_lon(0.0, 0.0),
1764            GeoCoord::from_lat_lon(0.0, 1.0),
1765        ];
1766        let butt = stroke_line_styled(&coords, 0.01, LineCap::Butt, LineJoin::Miter, 2.0);
1767        let round = stroke_line_styled(&coords, 0.01, LineCap::Round, LineJoin::Miter, 2.0);
1768        assert!(
1769            round.positions.len() > butt.positions.len(),
1770            "round cap should produce more vertices than butt: {} vs {}",
1771            round.positions.len(),
1772            butt.positions.len(),
1773        );
1774    }
1775
1776    #[test]
1777    fn styled_stroke_distances_are_monotonic() {
1778        let coords = vec![
1779            GeoCoord::from_lat_lon(0.0, 0.0),
1780            GeoCoord::from_lat_lon(0.0, 1.0),
1781            GeoCoord::from_lat_lon(0.0, 2.0),
1782        ];
1783        let r = stroke_line_styled(&coords, 0.01, LineCap::Butt, LineJoin::Miter, 2.0);
1784        // Distances should be non-negative and the max should be > 0.
1785        assert!(r.distances.iter().all(|&d| d >= 0.0));
1786        let max_dist = r.distances.iter().cloned().fold(0.0_f64, f64::max);
1787        assert!(max_dist > 0.0, "max distance should be positive");
1788    }
1789
1790    #[test]
1791    fn styled_stroke_normals_are_unit_length_or_zero() {
1792        let coords = vec![
1793            GeoCoord::from_lat_lon(0.0, 0.0),
1794            GeoCoord::from_lat_lon(0.0, 1.0),
1795            GeoCoord::from_lat_lon(1.0, 1.0),
1796        ];
1797        let r = stroke_line_styled(&coords, 0.01, LineCap::Round, LineJoin::Round, 2.0);
1798        for (i, nrm) in r.normals.iter().enumerate() {
1799            let len = (nrm[0] * nrm[0] + nrm[1] * nrm[1]).sqrt();
1800            if r.cap_join[i] > 0.5 {
1801                // Circumscribed cap/join fan vertices: magnitude ≈ CIRCUMSCRIBE or 0 (center).
1802                assert!(
1803                    len < CIRCUMSCRIBE + 1e-10 || len < 1e-10,
1804                    "cap/join normal length should be ≤{CIRCUMSCRIBE} or ~0, got {len}"
1805                );
1806            } else {
1807                // Ribbon body vertices: unit length.
1808                assert!(
1809                    len < 1.0 + 1e-10 || len < 1e-10,
1810                    "body normal length should be ≤1 or ~0, got {len}"
1811                );
1812            }
1813        }
1814    }
1815
1816    #[test]
1817    fn styled_stroke_cap_join_flags_correct_for_round_caps() {
1818        let coords = vec![
1819            GeoCoord::from_lat_lon(0.0, 0.0),
1820            GeoCoord::from_lat_lon(0.0, 1.0),
1821        ];
1822        let r = stroke_line_styled(&coords, 0.01, LineCap::Round, LineJoin::Round, 2.0);
1823        assert_eq!(r.cap_join.len(), r.positions.len());
1824        // At least some vertices should be flagged as cap/join.
1825        let cap_join_count = r.cap_join.iter().filter(|&&f| f > 0.5).count();
1826        assert!(cap_join_count > 0, "round cap should flag some vertices");
1827        // Ribbon body vertices should exist and be flagged 0.0.
1828        let body_count = r.cap_join.iter().filter(|&&f| f < 0.5).count();
1829        assert!(body_count > 0, "ribbon body vertices should exist");
1830    }
1831
1832    #[test]
1833    fn styled_stroke_cap_join_flags_zero_for_butt_caps() {
1834        let coords = vec![
1835            GeoCoord::from_lat_lon(0.0, 0.0),
1836            GeoCoord::from_lat_lon(0.0, 1.0),
1837        ];
1838        let r = stroke_line_styled(&coords, 0.01, LineCap::Butt, LineJoin::Bevel, 2.0);
1839        assert_eq!(r.cap_join.len(), r.positions.len());
1840        // No cap/join vertices for butt caps + bevel joins.
1841        for &flag in &r.cap_join {
1842            assert!(
1843                flag < 0.5,
1844                "butt cap + bevel join should have no cap_join flags"
1845            );
1846        }
1847    }
1848
1849    #[test]
1850    fn styled_stroke_circumscribed_fan_extends_beyond_unit() {
1851        // Round cap fan perimeter normals should have magnitude > 1.0
1852        // (circumscribed) for SDF clipping.
1853        let coords = vec![
1854            GeoCoord::from_lat_lon(0.0, 0.0),
1855            GeoCoord::from_lat_lon(0.0, 1.0),
1856        ];
1857        let r = stroke_line_styled(&coords, 0.01, LineCap::Round, LineJoin::Round, 2.0);
1858        let mut found_circumscribed = false;
1859        for (i, nrm) in r.normals.iter().enumerate() {
1860            if r.cap_join[i] > 0.5 {
1861                let len = (nrm[0] * nrm[0] + nrm[1] * nrm[1]).sqrt();
1862                if len > 1.0 + 1e-10 {
1863                    found_circumscribed = true;
1864                    assert!(
1865                        (len - CIRCUMSCRIBE).abs() < 1e-6,
1866                        "circumscribed normal should be ~{CIRCUMSCRIBE}, got {len}"
1867                    );
1868                }
1869            }
1870        }
1871        assert!(
1872            found_circumscribed,
1873            "should find circumscribed fan normals with length > 1"
1874        );
1875    }
1876
1877    #[test]
1878    fn styled_stroke_bevel_join_at_sharp_angle() {
1879        // 90-degree turn: miter_limit 1.0 forces bevel.
1880        let coords = vec![
1881            GeoCoord::from_lat_lon(0.0, 0.0),
1882            GeoCoord::from_lat_lon(0.0, 1.0),
1883            GeoCoord::from_lat_lon(1.0, 1.0),
1884        ];
1885        let r = stroke_line_styled(&coords, 0.01, LineCap::Butt, LineJoin::Bevel, 2.0);
1886        // Bevel should produce more vertices than a straight line.
1887        let straight = stroke_line_styled(
1888            &[
1889                GeoCoord::from_lat_lon(0.0, 0.0),
1890                GeoCoord::from_lat_lon(0.0, 2.0),
1891            ],
1892            0.01,
1893            LineCap::Butt,
1894            LineJoin::Bevel,
1895            2.0,
1896        );
1897        assert!(
1898            r.positions.len() > straight.positions.len(),
1899            "bevel join should add extra vertices: {} vs {}",
1900            r.positions.len(),
1901            straight.positions.len(),
1902        );
1903    }
1904
1905    #[test]
1906    fn styled_stroke_round_join_produces_fan_vertices() {
1907        let coords = vec![
1908            GeoCoord::from_lat_lon(0.0, 0.0),
1909            GeoCoord::from_lat_lon(0.0, 1.0),
1910            GeoCoord::from_lat_lon(1.0, 1.0),
1911        ];
1912        let bevel = stroke_line_styled(&coords, 0.01, LineCap::Butt, LineJoin::Bevel, 2.0);
1913        let round = stroke_line_styled(&coords, 0.01, LineCap::Butt, LineJoin::Round, 2.0);
1914        assert!(
1915            round.positions.len() > bevel.positions.len(),
1916            "round join should produce more vertices than bevel: {} vs {}",
1917            round.positions.len(),
1918            bevel.positions.len(),
1919        );
1920    }
1921
1922    #[test]
1923    fn styled_stroke_miter_join_collinear() {
1924        // Collinear points: miter should be simple (no extra vertices).
1925        let coords = vec![
1926            GeoCoord::from_lat_lon(0.0, 0.0),
1927            GeoCoord::from_lat_lon(0.0, 1.0),
1928            GeoCoord::from_lat_lon(0.0, 2.0),
1929        ];
1930        let r = stroke_line_styled(&coords, 0.01, LineCap::Butt, LineJoin::Miter, 2.0);
1931        // All indices valid.
1932        let max_idx = r.positions.len() as u32;
1933        assert!(r.indices.iter().all(|&i| i < max_idx));
1934        assert!(!r.indices.is_empty());
1935    }
1936
1937    #[test]
1938    fn styled_stroke_indices_valid_for_complex_line() {
1939        // 10-point zigzag.
1940        let coords: Vec<GeoCoord> = (0..10)
1941            .map(|i| {
1942                let lat = if i % 2 == 0 { 0.0 } else { 1.0 };
1943                GeoCoord::from_lat_lon(lat, i as f64)
1944            })
1945            .collect();
1946        for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] {
1947            for join in [LineJoin::Miter, LineJoin::Bevel, LineJoin::Round] {
1948                let r = stroke_line_styled(&coords, 0.01, cap, join, 2.0);
1949                let max_idx = r.positions.len() as u32;
1950                assert!(
1951                    r.indices.iter().all(|&i| i < max_idx),
1952                    "invalid index for cap={cap:?} join={join:?}: max valid={max_idx}",
1953                );
1954                assert_eq!(r.positions.len(), r.normals.len());
1955                assert_eq!(r.positions.len(), r.distances.len());
1956            }
1957        }
1958    }
1959}