Skip to main content

oxigdal_algorithms/vector/
offset.rs

1//! Line offset (parallel curve) generation for cartographic styling
2//!
3//! This module implements **open-curve parallel offset** — shifting a polyline
4//! laterally to produce a parallel polyline — distinct from the filled-polygon
5//! buffer in `buffer.rs`.
6//!
7//! # Conventions
8//!
9//! A **positive** distance places the offset curve on the **left** of the
10//! direction of travel (right-hand coordinate system).  A **negative** distance
11//! places it on the **right**.
12//!
13//! # Join styles
14//!
15//! At interior vertices the two adjacent offset segment directions differ.
16//! Three strategies reconcile them:
17//!
18//! - **Miter** – extend both segments to their intersection point.  If the
19//!   resulting extension ratio exceeds `miter_limit`, fall back to `Bevel`.
20//! - **Bevel** – insert two points (one per segment), slicing off the sharp
21//!   corner.
22//! - **Round** – interpolate a circular arc between the two offset directions.
23//!   Arc resolution adapts to the turn angle at approximately 8 segments per π.
24//!
25//! # Example
26//!
27//! ```
28//! use oxigdal_algorithms::vector::{JoinStyle, OffsetOptions, offset_linestring};
29//!
30//! let coords = vec![(0.0_f64, 0.0_f64), (10.0, 0.0)];
31//! let opts = OffsetOptions::default();
32//! let result = offset_linestring(&coords, 1.0, &opts)?;
33//! assert_eq!(result.coords.len(), 2);
34//! assert!((result.coords[0].1 - 1.0).abs() < 1e-10);
35//! # Ok::<(), oxigdal_algorithms::error::AlgorithmError>(())
36//! ```
37
38use std::f64::consts::PI;
39
40use crate::error::{AlgorithmError, Result};
41use crate::vector::simplify_linestring_dp;
42use oxigdal_core::vector::{Coordinate, LineString};
43
44// ─────────────────────────────────────────────────────────────────────────────
45// Public types
46// ─────────────────────────────────────────────────────────────────────────────
47
48/// Join style used when constructing the offset curve at interior vertices.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum JoinStyle {
51    /// Sharp miter join, clipped to `miter_limit`.
52    #[default]
53    Miter,
54    /// Flat bevel — two points per corner, no extension.
55    Bevel,
56    /// Circular arc, proportional number of segments.
57    Round,
58}
59
60/// Options controlling how the offset curve is generated.
61#[derive(Debug, Clone)]
62pub struct OffsetOptions {
63    /// Maximum miter-extension ratio before falling back to bevel.
64    ///
65    /// The miter length at a corner is `|d / sin(θ/2)|`.  If this ratio
66    /// relative to `|d|` exceeds `miter_limit` the corner is bevelled instead.
67    /// Default is `10.0`.
68    pub miter_limit: f64,
69    /// Join style applied at each interior vertex.
70    pub join_style: JoinStyle,
71    /// Optional Douglas-Peucker tolerance applied to the output coordinates.
72    ///
73    /// When `Some(tol)` the output polyline is simplified with tolerance `tol`.
74    /// `None` means no simplification.
75    pub simplify_tolerance: Option<f64>,
76}
77
78impl Default for OffsetOptions {
79    fn default() -> Self {
80        Self {
81            miter_limit: 10.0,
82            join_style: JoinStyle::Miter,
83            simplify_tolerance: None,
84        }
85    }
86}
87
88/// Result returned by [`offset_linestring`] and its variants.
89#[derive(Debug, Clone)]
90pub struct OffsetResult {
91    /// The offset coordinates as `(x, y)` tuples.
92    pub coords: Vec<(f64, f64)>,
93    /// `true` if `simplify_tolerance` was set and Douglas-Peucker was applied.
94    pub was_simplified: bool,
95}
96
97// ─────────────────────────────────────────────────────────────────────────────
98// Public API
99// ─────────────────────────────────────────────────────────────────────────────
100
101/// Compute a parallel offset of an open polyline.
102///
103/// # Arguments
104///
105/// * `coords`   – Input vertices as `(x, y)` slice.  Consecutive duplicate
106///   vertices are treated as zero-length segments and skipped gracefully.
107/// * `distance` – Signed offset distance.  Positive = left of travel direction,
108///   negative = right.
109/// * `options`  – Join style, miter limit, and optional simplification.
110///
111/// # Errors
112///
113/// Returns [`AlgorithmError::InsufficientData`] when `coords.len() < 2`.
114///
115/// # Panics
116///
117/// Never panics in production code.
118pub fn offset_linestring(
119    coords: &[(f64, f64)],
120    distance: f64,
121    options: &OffsetOptions,
122) -> Result<OffsetResult> {
123    let n = coords.len();
124    if n < 2 {
125        return Err(AlgorithmError::InsufficientData {
126            operation: "offset_linestring",
127            message: format!("need at least 2 vertices, got {n}"),
128        });
129    }
130
131    // Degenerate: zero distance → return a copy of the input.
132    if distance == 0.0 {
133        return Ok(OffsetResult {
134            coords: coords.to_vec(),
135            was_simplified: false,
136        });
137    }
138
139    // Compute per-segment left-normal vectors (skipping degenerate segs).
140    let normals = compute_segment_normals(coords);
141
142    // Build the offset polyline one vertex at a time.
143    let mut out: Vec<(f64, f64)> = Vec::with_capacity(n + 8);
144
145    // ── Start vertex ─────────────────────────────────────────────────────────
146    let n0 = first_valid_normal(&normals);
147    let (px, py) = coords[0];
148    out.push((px + distance * n0.0, py + distance * n0.1));
149
150    // ── Interior vertices ─────────────────────────────────────────────────────
151    for i in 1..n - 1 {
152        let na = prev_valid_normal(&normals, i);
153        let nb = next_valid_normal(&normals, i);
154        let (vx, vy) = coords[i];
155        emit_join_points(&mut out, (vx, vy), na, nb, distance, options);
156    }
157
158    // ── End vertex ────────────────────────────────────────────────────────────
159    let n_last = last_valid_normal(&normals);
160    let (ex, ey) = coords[n - 1];
161    out.push((ex + distance * n_last.0, ey + distance * n_last.1));
162
163    // ── Optional Douglas-Peucker simplification ────────────────────────────
164    let was_simplified;
165    if let Some(tol) = options.simplify_tolerance {
166        let ls_coords: Vec<Coordinate> =
167            out.iter().map(|&(x, y)| Coordinate::new_2d(x, y)).collect();
168        match LineString::new(ls_coords) {
169            Ok(ls) => match simplify_linestring_dp(&ls, tol) {
170                Ok(simplified) => {
171                    out = simplified.coords.iter().map(|c| (c.x, c.y)).collect();
172                    was_simplified = true;
173                }
174                Err(_) => {
175                    // Simplification failed – keep raw output.
176                    was_simplified = false;
177                }
178            },
179            Err(_) => {
180                was_simplified = false;
181            }
182        }
183    } else {
184        was_simplified = false;
185    }
186
187    Ok(OffsetResult {
188        coords: out,
189        was_simplified,
190    })
191}
192
193/// Compute parallel offset for each ring of a polygon independently.
194///
195/// Each ring is treated as a **closed** curve — all vertices are interior and
196/// no endpoint special-casing is applied.  The bisector/miter join logic wraps
197/// cyclically around the ring.
198///
199/// A positive `distance` expands exterior rings outward; the caller is
200/// responsible for negating the distance for holes if needed.
201///
202/// # Errors
203///
204/// Returns [`AlgorithmError::InsufficientData`] if any input ring has < 3
205/// distinct positions (i.e. the raw slice length is < 3).
206pub fn offset_polygon_rings(
207    rings: &[Vec<(f64, f64)>],
208    distance: f64,
209    options: &OffsetOptions,
210) -> Result<Vec<Vec<(f64, f64)>>> {
211    rings
212        .iter()
213        .map(|ring| offset_closed_ring(ring, distance, options))
214        .collect()
215}
216
217// ─────────────────────────────────────────────────────────────────────────────
218// Closed-ring offset
219// ─────────────────────────────────────────────────────────────────────────────
220
221/// Offset a single closed ring (polygon ring).
222///
223/// The signed-distance convention for closed rings:
224/// - Positive distance = **outward** for a CCW exterior ring (left-hand rule
225///   means left-normals point inward for CCW, so we negate the distance
226///   internally when the ring is CCW).
227/// - Positive distance = inward for a CW interior ring (hole), which is the
228///   caller's responsibility to handle by negating the distance passed in.
229fn offset_closed_ring(
230    ring: &[(f64, f64)],
231    distance: f64,
232    options: &OffsetOptions,
233) -> Result<Vec<(f64, f64)>> {
234    // Normalise: strip repeated closing vertex if present so all positions
235    // are unique.
236    let effective_ring = strip_closing_vertex(ring);
237    let n = effective_ring.len();
238    if n < 3 {
239        return Err(AlgorithmError::InsufficientData {
240            operation: "offset_polygon_rings",
241            message: format!("ring must have at least 3 distinct vertices, got {n}"),
242        });
243    }
244
245    // Determine ring orientation using the shoelace formula.
246    // Positive signed area → CCW winding.
247    let signed_area = ring_signed_area(effective_ring);
248    // For CCW rings the left-normal points **inward**.  Negate the distance so
249    // that the caller's positive distance always means "outward expansion" for
250    // an exterior (CCW) ring.
251    let effective_distance = if signed_area > 0.0 {
252        -distance
253    } else {
254        distance
255    };
256
257    // Compute normals for the n cyclic segments.
258    let normals = compute_cyclic_normals(effective_ring);
259
260    let mut out: Vec<(f64, f64)> = Vec::with_capacity(n + 4);
261
262    for i in 0..n {
263        // In a closed ring every vertex is an interior join vertex.
264        let prev_seg = if i == 0 { n - 1 } else { i - 1 };
265        let curr_seg = i;
266
267        let na = normals[prev_seg];
268        let nb = normals[curr_seg];
269        let (vx, vy) = effective_ring[i];
270        emit_join_points(&mut out, (vx, vy), na, nb, effective_distance, options);
271    }
272
273    // Close the ring.
274    if let Some(&first) = out.first() {
275        out.push(first);
276    }
277
278    Ok(out)
279}
280
281// ─────────────────────────────────────────────────────────────────────────────
282// Normal computation helpers
283// ─────────────────────────────────────────────────────────────────────────────
284
285/// Per-segment left-normal vectors for an open polyline.
286///
287/// Returns a `Vec` of length `n - 1`.  Degenerate (zero-length) segments get
288/// `(0.0, 0.0)` so they can be skipped later.
289fn compute_segment_normals(coords: &[(f64, f64)]) -> Vec<(f64, f64)> {
290    coords.windows(2).map(|w| left_normal(w[0], w[1])).collect()
291}
292
293/// Cyclic per-segment normals for a closed ring of `n` vertices.
294/// Segment `i` runs from `ring[i]` to `ring[(i+1) % n]`.
295fn compute_cyclic_normals(ring: &[(f64, f64)]) -> Vec<(f64, f64)> {
296    let n = ring.len();
297    (0..n)
298        .map(|i| left_normal(ring[i], ring[(i + 1) % n]))
299        .collect()
300}
301
302/// Left-perpendicular unit normal of the directed segment `a → b`.
303///
304/// Returns `(0.0, 0.0)` for a degenerate (zero-length) segment.
305fn left_normal(a: (f64, f64), b: (f64, f64)) -> (f64, f64) {
306    let dx = b.0 - a.0;
307    let dy = b.1 - a.1;
308    let len = dx.hypot(dy);
309    if len < f64::EPSILON {
310        return (0.0, 0.0);
311    }
312    // Left normal: rotate direction 90° counter-clockwise → (-dy, dx) / len
313    (-dy / len, dx / len)
314}
315
316/// Returns the first non-degenerate normal in `normals`.
317fn first_valid_normal(normals: &[(f64, f64)]) -> (f64, f64) {
318    normals
319        .iter()
320        .find(|&&n| normal_is_valid(n))
321        .copied()
322        .unwrap_or((0.0, 0.0))
323}
324
325/// Returns the last non-degenerate normal in `normals`.
326fn last_valid_normal(normals: &[(f64, f64)]) -> (f64, f64) {
327    normals
328        .iter()
329        .rev()
330        .find(|&&n| normal_is_valid(n))
331        .copied()
332        .unwrap_or((0.0, 0.0))
333}
334
335/// For interior vertex `i`, returns the normal of the **previous** segment
336/// (the segment ending at `i`), skipping degenerate ones by scanning backward.
337fn prev_valid_normal(normals: &[(f64, f64)], vertex_idx: usize) -> (f64, f64) {
338    // segment i-1 ends at vertex i
339    let seg_idx = vertex_idx.saturating_sub(1);
340    (0..=seg_idx)
341        .rev()
342        .map(|j| normals[j])
343        .find(|&n| normal_is_valid(n))
344        .unwrap_or((0.0, 0.0))
345}
346
347/// For interior vertex `i`, returns the normal of the **next** segment
348/// (the segment starting at `i`), skipping degenerate ones by scanning forward.
349fn next_valid_normal(normals: &[(f64, f64)], vertex_idx: usize) -> (f64, f64) {
350    // segment i starts at vertex i (index = vertex_idx in the normals array)
351    (vertex_idx..normals.len())
352        .map(|j| normals[j])
353        .find(|&n| normal_is_valid(n))
354        .unwrap_or((0.0, 0.0))
355}
356
357/// True when the normal is a proper unit vector (not the degenerate `(0,0)`).
358#[inline]
359fn normal_is_valid(n: (f64, f64)) -> bool {
360    n.0 != 0.0 || n.1 != 0.0
361}
362
363// ─────────────────────────────────────────────────────────────────────────────
364// Join-point emission
365// ─────────────────────────────────────────────────────────────────────────────
366
367/// Emit offset point(s) for vertex `v` given the normals of the two adjacent
368/// segments.  Mutates `out` by appending 1 (Miter/degenerate), 2 (Bevel), or
369/// several (Round) points.
370fn emit_join_points(
371    out: &mut Vec<(f64, f64)>,
372    v: (f64, f64),
373    na: (f64, f64),
374    nb: (f64, f64),
375    distance: f64,
376    options: &OffsetOptions,
377) {
378    // Points on each offset segment, at vertex v.
379    let pa = (v.0 + distance * na.0, v.1 + distance * na.1);
380    let pb = (v.0 + distance * nb.0, v.1 + distance * nb.1);
381
382    // If both normals are degenerate (collinear duplicates) just push pa.
383    if !normal_is_valid(na) && !normal_is_valid(nb) {
384        out.push(pa);
385        return;
386    }
387    if !normal_is_valid(na) {
388        out.push(pb);
389        return;
390    }
391    if !normal_is_valid(nb) {
392        out.push(pa);
393        return;
394    }
395
396    match options.join_style {
397        JoinStyle::Bevel => {
398            emit_bevel(out, pa, pb);
399        }
400        JoinStyle::Round => {
401            emit_round(out, v, pa, pb, distance);
402        }
403        JoinStyle::Miter => {
404            emit_miter(out, v, na, nb, pa, pb, distance, options.miter_limit);
405        }
406    }
407}
408
409/// Bevel join: just the two points, one per offset segment.
410#[inline]
411fn emit_bevel(out: &mut Vec<(f64, f64)>, pa: (f64, f64), pb: (f64, f64)) {
412    out.push(pa);
413    out.push(pb);
414}
415
416/// Miter join.  Computes the bisector and the miter point.  Falls back to
417/// bevel when the extension ratio would exceed `miter_limit`.
418fn emit_miter(
419    out: &mut Vec<(f64, f64)>,
420    v: (f64, f64),
421    na: (f64, f64),
422    nb: (f64, f64),
423    pa: (f64, f64),
424    pb: (f64, f64),
425    distance: f64,
426    miter_limit: f64,
427) {
428    // Bisector direction = normalise(na + nb).
429    let bx = na.0 + nb.0;
430    let by = na.1 + nb.1;
431    let blen = bx.hypot(by);
432
433    // Collinear (anti-parallel) normals: straight continuation, single point.
434    if blen < f64::EPSILON {
435        out.push(pa);
436        return;
437    }
438
439    let (bux, buy) = (bx / blen, by / blen);
440
441    // dot(bisector_unit, na) = cos(θ/2) where θ is the exterior turn angle.
442    // miter_length = distance / dot(bisector_unit, na)  [signed].
443    let dot = bux * na.0 + buy * na.1;
444
445    // Guard: if dot ≈ 0 the lines are almost anti-parallel → very long miter.
446    if dot.abs() < f64::EPSILON {
447        emit_bevel(out, pa, pb);
448        return;
449    }
450
451    let miter_length = distance / dot;
452    let ratio = (miter_length / distance).abs();
453
454    if ratio > miter_limit.abs() || !miter_length.is_finite() {
455        // Clamp to bevel.
456        emit_bevel(out, pa, pb);
457    } else {
458        // Single miter point.
459        out.push((v.0 + miter_length * bux, v.1 + miter_length * buy));
460    }
461}
462
463/// Round join: arc from `pa` to `pb` around vertex `v`.
464///
465/// Uses approximately 8 segments per π radians (clamped to at least 1).
466fn emit_round(
467    out: &mut Vec<(f64, f64)>,
468    v: (f64, f64),
469    pa: (f64, f64),
470    pb: (f64, f64),
471    distance: f64,
472) {
473    out.push(pa);
474
475    let radius = distance.abs();
476    if radius < f64::EPSILON {
477        out.push(pb);
478        return;
479    }
480
481    let angle_a = (pa.1 - v.1).atan2(pa.0 - v.0);
482    let angle_b = (pb.1 - v.1).atan2(pb.0 - v.0);
483
484    // Angular span in the correct winding direction.
485    let mut delta = angle_b - angle_a;
486
487    // Normalise delta to (-π, π].
488    while delta > PI {
489        delta -= 2.0 * PI;
490    }
491    while delta < -PI {
492        delta += 2.0 * PI;
493    }
494
495    // ~8 segments per π.
496    let segments = (((delta.abs() / PI) * 8.0).ceil() as usize).max(1);
497
498    for k in 1..segments {
499        let t = (k as f64) / (segments as f64);
500        let angle = angle_a + t * delta;
501        out.push((v.0 + radius * angle.cos(), v.1 + radius * angle.sin()));
502    }
503
504    out.push(pb);
505}
506
507// ─────────────────────────────────────────────────────────────────────────────
508// Ring utility
509// ─────────────────────────────────────────────────────────────────────────────
510
511/// Shoelace signed area of a closed polygon ring (no closing duplicate vertex).
512///
513/// Positive = CCW (counter-clockwise) winding in a standard coordinate system
514/// (y-up / right-hand).
515fn ring_signed_area(ring: &[(f64, f64)]) -> f64 {
516    let n = ring.len();
517    let mut sum = 0.0_f64;
518    for i in 0..n {
519        let j = (i + 1) % n;
520        sum += ring[i].0 * ring[j].1;
521        sum -= ring[j].0 * ring[i].1;
522    }
523    sum / 2.0
524}
525
526/// Returns a sub-slice that strips the repeated closing vertex if the last
527/// point equals the first.  Otherwise returns the slice unchanged.
528fn strip_closing_vertex(ring: &[(f64, f64)]) -> &[(f64, f64)] {
529    if ring.len() >= 2 {
530        let first = ring[0];
531        let last = ring[ring.len() - 1];
532        if (first.0 - last.0).abs() < f64::EPSILON && (first.1 - last.1).abs() < f64::EPSILON {
533            return &ring[..ring.len() - 1];
534        }
535    }
536    ring
537}
538
539// ─────────────────────────────────────────────────────────────────────────────
540// Tests
541// ─────────────────────────────────────────────────────────────────────────────
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    fn opts_miter() -> OffsetOptions {
548        OffsetOptions {
549            join_style: JoinStyle::Miter,
550            ..Default::default()
551        }
552    }
553
554    fn opts_bevel() -> OffsetOptions {
555        OffsetOptions {
556            join_style: JoinStyle::Bevel,
557            ..Default::default()
558        }
559    }
560
561    fn opts_round() -> OffsetOptions {
562        OffsetOptions {
563            join_style: JoinStyle::Round,
564            ..Default::default()
565        }
566    }
567
568    // ── left_normal ──────────────────────────────────────────────────────────
569
570    #[test]
571    fn test_left_normal_east() {
572        // Eastward segment: left is north (+y).
573        let n = left_normal((0.0, 0.0), (1.0, 0.0));
574        assert!((n.0 - 0.0).abs() < 1e-12, "nx should be 0, got {}", n.0);
575        assert!((n.1 - 1.0).abs() < 1e-12, "ny should be 1, got {}", n.1);
576    }
577
578    #[test]
579    fn test_left_normal_north() {
580        // Northward segment: left is west (-x).
581        let n = left_normal((0.0, 0.0), (0.0, 1.0));
582        assert!((n.0 - (-1.0)).abs() < 1e-12);
583        assert!((n.1 - 0.0).abs() < 1e-12);
584    }
585
586    // ── offset_linestring ────────────────────────────────────────────────────
587
588    #[test]
589    fn test_offset_horizontal_line_left_positive() {
590        let coords = vec![(0.0, 0.0), (10.0, 0.0)];
591        let result = offset_linestring(&coords, 1.0, &opts_miter());
592        assert!(result.is_ok(), "expected Ok, got {result:?}");
593        let r = result.expect("checked above");
594        assert_eq!(r.coords.len(), 2);
595        for &(_, y) in &r.coords {
596            assert!((y - 1.0).abs() < 1e-10, "expected y=1, got {y}");
597        }
598    }
599
600    #[test]
601    fn test_offset_horizontal_line_right_negative() {
602        let coords = vec![(0.0, 0.0), (10.0, 0.0)];
603        let result = offset_linestring(&coords, -1.0, &opts_miter());
604        assert!(result.is_ok());
605        let r = result.expect("checked above");
606        assert_eq!(r.coords.len(), 2);
607        for &(_, y) in &r.coords {
608            assert!((y - (-1.0)).abs() < 1e-10, "expected y=-1, got {y}");
609        }
610    }
611
612    #[test]
613    fn test_offset_zero_distance_returns_input() {
614        let coords = vec![(0.0, 0.0), (5.0, 5.0), (10.0, 0.0)];
615        let result = offset_linestring(&coords, 0.0, &opts_miter());
616        assert!(result.is_ok());
617        let r = result.expect("checked above");
618        assert_eq!(r.coords, coords);
619    }
620
621    #[test]
622    fn test_offset_right_angle_miter_within_limit() {
623        // L-shape: (0,10)→(0,0)→(10,0).  Left offset +1.
624        let coords = vec![(0.0, 10.0), (0.0, 0.0), (10.0, 0.0)];
625        let opts = opts_miter();
626        let result = offset_linestring(&coords, 1.0, &opts);
627        assert!(result.is_ok());
628        let r = result.expect("checked above");
629        // No NaN.
630        for &(x, y) in &r.coords {
631            assert!(x.is_finite(), "x must be finite, got {x}");
632            assert!(y.is_finite(), "y must be finite, got {y}");
633        }
634        // Must have at least 3 points (start, corner, end).
635        assert!(r.coords.len() >= 3, "got {} points", r.coords.len());
636    }
637
638    #[test]
639    fn test_offset_right_angle_bevel_style() {
640        // Bevel produces two points at the corner → 4 total (start, 2×corner, end).
641        let coords = vec![(0.0, 10.0), (0.0, 0.0), (10.0, 0.0)];
642        let result = offset_linestring(&coords, 1.0, &opts_bevel());
643        assert!(result.is_ok());
644        let r = result.expect("checked above");
645        assert_eq!(
646            r.coords.len(),
647            4,
648            "bevel should produce 4 points, got {}",
649            r.coords.len()
650        );
651    }
652
653    #[test]
654    fn test_offset_miter_limit_clamps_sharp_angle() {
655        // Very acute inward turn (almost 180° reversal): normals are nearly
656        // anti-parallel → miter would be enormous → clamp to bevel.
657        // Spike: (0,0)→(5,0)→(5, 0.001)→(0,0) — use a near-reversal.
658        // A near-antiparallel turn: go east then almost-west.
659        let coords = vec![(0.0, 0.0), (10.0, 0.0), (10.05, 0.01)];
660        let opts = OffsetOptions {
661            join_style: JoinStyle::Miter,
662            miter_limit: 2.0,
663            ..Default::default()
664        };
665        let result = offset_linestring(&coords, 1.0, &opts);
666        assert!(result.is_ok());
667        let r = result.expect("checked above");
668        for &(x, y) in &r.coords {
669            assert!(x.is_finite() && y.is_finite(), "coords must be finite");
670        }
671    }
672
673    #[test]
674    fn test_offset_insufficient_vertices_errors() {
675        let coords = vec![(0.0, 0.0)]; // Only 1 point.
676        let result = offset_linestring(&coords, 1.0, &opts_miter());
677        assert!(result.is_err(), "expected Err for 1-point input");
678    }
679
680    #[test]
681    fn test_offset_round_style_no_nan() {
682        let coords = vec![(0.0, 10.0), (0.0, 0.0), (10.0, 0.0)];
683        let result = offset_linestring(&coords, 1.0, &opts_round());
684        assert!(result.is_ok());
685        let r = result.expect("checked above");
686        for &(x, y) in &r.coords {
687            assert!(
688                x.is_finite() && y.is_finite(),
689                "round join produced non-finite coord"
690            );
691        }
692        // Round produces more points than bevel (arc segments in between).
693        assert!(
694            r.coords.len() > 4,
695            "expected >4 coords for round join, got {}",
696            r.coords.len()
697        );
698    }
699
700    #[test]
701    fn test_offset_polygon_ring_square() {
702        // Square (0,0)→(10,0)→(10,10)→(0,10)→(0,0), offset +1 outward.
703        let ring = vec![
704            (0.0_f64, 0.0_f64),
705            (10.0, 0.0),
706            (10.0, 10.0),
707            (0.0, 10.0),
708            (0.0, 0.0), // closing vertex
709        ];
710        let opts = OffsetOptions {
711            join_style: JoinStyle::Miter,
712            ..Default::default()
713        };
714        let result = offset_polygon_rings(&[ring], 1.0, &opts);
715        assert!(result.is_ok());
716        let rings = result.expect("checked above");
717        assert_eq!(rings.len(), 1);
718        let out_ring = &rings[0];
719
720        // All coordinates finite.
721        for &(x, y) in out_ring {
722            assert!(x.is_finite() && y.is_finite());
723        }
724
725        // Rough area check: original 10×10=100, offset +1 on all sides → ~144.
726        // Use shoelace on the (closed) output ring.
727        let area = shoelace_area(out_ring);
728        assert!(
729            area > 100.0,
730            "expanded area should exceed original; got {area}"
731        );
732    }
733
734    /// Shoelace formula for a closed ring (last point == first point).
735    fn shoelace_area(ring: &[(f64, f64)]) -> f64 {
736        let n = ring.len();
737        let mut sum = 0.0;
738        for i in 0..n - 1 {
739            sum += ring[i].0 * ring[i + 1].1;
740            sum -= ring[i + 1].0 * ring[i].1;
741        }
742        sum.abs() / 2.0
743    }
744}