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