Skip to main content

u_geometry/
offset.rs

1//! Polygon offset via raw edge offset with miter/bevel joins.
2//!
3//! Offsets a simple polygon by moving each edge along its outward normal,
4//! then computing miter (or bevel) joins at vertices. Self-intersections
5//! in the raw offset are detected and resolved by splitting into sub-loops
6//! and filtering by signed area.
7//!
8//! # Algorithm
9//!
10//! 1. Ensure CCW winding
11//! 2. Offset each edge by translating along its outward normal
12//! 3. Compute miter joins at consecutive offset edges
13//! 4. Apply miter limit -- bevel if ratio exceeds threshold
14//! 5. Detect and resolve self-intersections
15//!
16//! # Complexity
17//!
18//! O(n^2) worst case for self-intersection resolution on concave polygons,
19//! O(n) for convex polygons.
20//!
21//! # References
22//!
23//! - Held (1998), "On the Computational Geometry of Pocket Machining", Ch. 3
24//! - Chen & McMains (2005), "Polygon Offsetting by Computing Winding Numbers"
25
26use crate::polygon;
27
28/// Default miter limit ratio (miter length / offset distance).
29const DEFAULT_MITER_LIMIT: f64 = 2.0;
30
31/// Tolerance for detecting parallel edges and degenerate geometry.
32const EPS: f64 = 1e-10;
33
34/// Offsets a simple polygon by `distance` using the default miter limit (2.0).
35///
36/// Positive distance expands the polygon outward (for CCW winding).
37/// Negative distance shrinks inward. Zero distance returns the original polygon.
38///
39/// Returns a `Vec` of polygon rings. The result may be empty if the polygon
40/// collapses, or contain multiple rings if the offset causes topology splits.
41///
42/// # Arguments
43///
44/// * `polygon` - A simple polygon as a slice of `(f64, f64)` vertices
45/// * `distance` - Offset distance (positive = outward, negative = inward)
46///
47/// # Complexity
48///
49/// O(n) for convex polygons, O(n^2) worst case for concave polygons
50/// due to self-intersection resolution.
51///
52/// # Example
53///
54/// ```
55/// use u_geometry::offset::offset_polygon;
56///
57/// let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
58/// let result = offset_polygon(&square, 1.0);
59/// assert_eq!(result.len(), 1);
60/// ```
61pub fn offset_polygon(polygon: &[(f64, f64)], distance: f64) -> Vec<Vec<(f64, f64)>> {
62    offset_polygon_miter(polygon, distance, DEFAULT_MITER_LIMIT)
63}
64
65/// Offsets a simple polygon by `distance` with a configurable miter limit.
66///
67/// The miter limit controls how far a miter join can extend relative to the
68/// offset distance. When the miter ratio at a vertex exceeds `miter_limit`,
69/// a bevel join is inserted instead, preventing long spikes at acute angles.
70///
71/// # Arguments
72///
73/// * `polygon` - A simple polygon as a slice of `(f64, f64)` vertices
74/// * `distance` - Offset distance (positive = outward, negative = inward)
75/// * `miter_limit` - Maximum miter ratio before switching to bevel
76///
77/// # Returns
78///
79/// `Vec` of polygon rings. May be empty, single, or multiple rings.
80pub fn offset_polygon_miter(
81    polygon: &[(f64, f64)],
82    distance: f64,
83    miter_limit: f64,
84) -> Vec<Vec<(f64, f64)>> {
85    if polygon.len() < 3 {
86        return Vec::new();
87    }
88
89    if distance.abs() < EPS {
90        return vec![polygon.to_vec()];
91    }
92
93    let poly = polygon::ensure_ccw(polygon);
94    let n = poly.len();
95
96    // Step 1: Compute offset edges
97    let offset_edges: Vec<((f64, f64), (f64, f64))> = (0..n)
98        .map(|i| {
99            let j = (i + 1) % n;
100            offset_edge(poly[i], poly[j], distance)
101        })
102        .collect();
103
104    // Step 2: Compute miter/bevel joins at each vertex
105    let mut raw_vertices: Vec<(f64, f64)> = Vec::new();
106
107    for i in 0..n {
108        let prev = if i == 0 { n - 1 } else { i - 1 };
109
110        let (ep0, ep1) = offset_edges[prev];
111        let (ec0, ec1) = offset_edges[i];
112
113        let dir_prev = (ep1.0 - ep0.0, ep1.1 - ep0.1);
114        let dir_curr = (ec1.0 - ec0.0, ec1.1 - ec0.1);
115
116        if let Some(pt) = line_intersection(ep0, dir_prev, ec0, dir_curr) {
117            // Add EPS tolerance to miter limit to avoid false bevel triggers
118            // from floating-point rounding near the boundary.
119            let miter_dist_sq = (pt.0 - poly[i].0).powi(2) + (pt.1 - poly[i].1).powi(2);
120            let limit_sq = (miter_limit * distance.abs() * (1.0 + 1e-4)).powi(2);
121
122            if miter_dist_sq > limit_sq {
123                raw_vertices.push(ep1);
124                raw_vertices.push(ec0);
125            } else {
126                raw_vertices.push(pt);
127            }
128        } else {
129            raw_vertices.push(ep1);
130        }
131    }
132
133    let raw_vertices = remove_consecutive_duplicates(&raw_vertices);
134
135    if raw_vertices.len() < 3 {
136        return Vec::new();
137    }
138
139    let mut result = resolve_self_intersections(&raw_vertices);
140
141    // Filter out "ghost" polygons that appear when inward offset exceeds
142    // half the polygon width. These have valid CCW winding but are actually
143    // "inverted" results where edges crossed over each other.
144    // Detection: every vertex of a valid inward offset must be at least
145    // |distance| away from every original edge (within tolerance).
146    if distance < 0.0 {
147        let abs_d = distance.abs();
148        let tol = abs_d * 0.1 + EPS; // 10% tolerance for numerical stability
149
150        result.retain(|ring| {
151            // Check that at least one vertex has proper distance from all edges
152            ring.iter().all(|&v| {
153                let min_dist = min_distance_to_polygon_edges(&poly, v);
154                min_dist >= abs_d - tol
155            })
156        });
157    }
158
159    result
160}
161
162/// Computes the minimum distance from a point to any edge of a polygon.
163fn min_distance_to_polygon_edges(polygon: &[(f64, f64)], point: (f64, f64)) -> f64 {
164    let n = polygon.len();
165    let mut min_d = f64::MAX;
166
167    for i in 0..n {
168        let j = (i + 1) % n;
169        let d = point_to_segment_distance(point, polygon[i], polygon[j]);
170        if d < min_d {
171            min_d = d;
172        }
173    }
174
175    min_d
176}
177
178/// Computes the distance from a point to a line segment.
179fn point_to_segment_distance(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
180    let dx = b.0 - a.0;
181    let dy = b.1 - a.1;
182    let len_sq = dx * dx + dy * dy;
183
184    if len_sq < EPS {
185        return ((p.0 - a.0).powi(2) + (p.1 - a.1).powi(2)).sqrt();
186    }
187
188    // Project point onto line, clamped to [0, 1]
189    let t = ((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len_sq;
190    let t = t.clamp(0.0, 1.0);
191
192    let proj = (a.0 + t * dx, a.1 + t * dy);
193    ((p.0 - proj.0).powi(2) + (p.1 - proj.1).powi(2)).sqrt()
194}
195
196/// Offsets a single directed edge segment by translating along its outward normal.
197///
198/// For a CCW-wound polygon, the outward normal of edge `(p0 -> p1)` points to
199/// the right: `(dy, -dx)` normalized, then scaled by `distance`.
200pub fn offset_edge(p0: (f64, f64), p1: (f64, f64), distance: f64) -> ((f64, f64), (f64, f64)) {
201    let dx = p1.0 - p0.0;
202    let dy = p1.1 - p0.1;
203    let len = (dx * dx + dy * dy).sqrt();
204
205    if len < EPS {
206        return (p0, p1);
207    }
208
209    let nx = dy / len * distance;
210    let ny = -dx / len * distance;
211
212    ((p0.0 + nx, p0.1 + ny), (p1.0 + nx, p1.1 + ny))
213}
214
215/// Computes the intersection of two lines defined by point + direction.
216///
217/// Each line is defined as `p + t * d` for scalar `t`.
218/// Returns `None` if the lines are parallel (cross product near zero).
219///
220/// # Complexity
221///
222/// O(1)
223pub fn line_intersection(
224    p1: (f64, f64),
225    d1: (f64, f64),
226    p2: (f64, f64),
227    d2: (f64, f64),
228) -> Option<(f64, f64)> {
229    let cross = d1.0 * d2.1 - d1.1 * d2.0;
230
231    if cross.abs() < EPS {
232        return None;
233    }
234
235    let dp = (p2.0 - p1.0, p2.1 - p1.1);
236    let t = (dp.0 * d2.1 - dp.1 * d2.0) / cross;
237
238    Some((p1.0 + t * d1.0, p1.1 + t * d1.1))
239}
240
241fn segment_intersection(
242    a0: (f64, f64),
243    a1: (f64, f64),
244    b0: (f64, f64),
245    b1: (f64, f64),
246) -> Option<(f64, f64, (f64, f64))> {
247    let da = (a1.0 - a0.0, a1.1 - a0.1);
248    let db = (b1.0 - b0.0, b1.1 - b0.1);
249    let cross = da.0 * db.1 - da.1 * db.0;
250
251    if cross.abs() < EPS {
252        return None;
253    }
254
255    let dp = (b0.0 - a0.0, b0.1 - a0.1);
256    let t = (dp.0 * db.1 - dp.1 * db.0) / cross;
257    let u = (dp.0 * da.1 - dp.1 * da.0) / cross;
258
259    if t > EPS && t < 1.0 - EPS && u > EPS && u < 1.0 - EPS {
260        let pt = (a0.0 + t * da.0, a0.1 + t * da.1);
261        Some((t, u, pt))
262    } else {
263        None
264    }
265}
266
267fn remove_consecutive_duplicates(vertices: &[(f64, f64)]) -> Vec<(f64, f64)> {
268    if vertices.is_empty() {
269        return Vec::new();
270    }
271
272    let mut result = vec![vertices[0]];
273
274    for &v in &vertices[1..] {
275        let last = result
276            .last()
277            .expect("result is non-empty after initial push");
278        if (v.0 - last.0).abs() > EPS || (v.1 - last.1).abs() > EPS {
279            result.push(v);
280        }
281    }
282
283    if result.len() > 1 {
284        let first = result[0];
285        let last = result.last().expect("result has at least 2 elements");
286        if (first.0 - last.0).abs() < EPS && (first.1 - last.1).abs() < EPS {
287            result.pop();
288        }
289    }
290
291    result
292}
293
294fn resolve_self_intersections(vertices: &[(f64, f64)]) -> Vec<Vec<(f64, f64)>> {
295    let n = vertices.len();
296
297    let mut event_edge_i: Vec<usize> = Vec::new();
298    let mut event_edge_j: Vec<usize> = Vec::new();
299    let mut event_t_i: Vec<f64> = Vec::new();
300    let mut event_t_j: Vec<f64> = Vec::new();
301    let mut event_pts: Vec<(f64, f64)> = Vec::new();
302
303    for i in 0..n {
304        let i_next = (i + 1) % n;
305        for j in (i + 2)..n {
306            if i == 0 && j == n - 1 {
307                continue;
308            }
309            let j_next = (j + 1) % n;
310
311            if let Some((t, u, pt)) =
312                segment_intersection(vertices[i], vertices[i_next], vertices[j], vertices[j_next])
313            {
314                event_edge_i.push(i);
315                event_edge_j.push(j);
316                event_t_i.push(t);
317                event_t_j.push(u);
318                event_pts.push(pt);
319            }
320        }
321    }
322
323    if event_edge_i.is_empty() {
324        let sa = polygon::signed_area(vertices);
325        if sa > EPS {
326            return vec![vertices.to_vec()];
327        }
328        return Vec::new();
329    }
330
331    let num_events = event_edge_i.len();
332    let mut edge_splits: Vec<Vec<(f64, usize)>> = vec![Vec::new(); n];
333
334    for ei in 0..num_events {
335        edge_splits[event_edge_i[ei]].push((event_t_i[ei], ei));
336        edge_splits[event_edge_j[ei]].push((event_t_j[ei], ei));
337    }
338
339    for splits in &mut edge_splits {
340        splits.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
341    }
342
343    let mut node_points: Vec<(f64, f64)> = Vec::new();
344    let mut node_is_isect: Vec<bool> = Vec::new();
345    let mut isect_node_on_i: Vec<usize> = vec![0; num_events];
346    let mut isect_node_on_j: Vec<usize> = vec![0; num_events];
347
348    for i in 0..n {
349        node_points.push(vertices[i]);
350        node_is_isect.push(false);
351
352        for &(_, ei) in &edge_splits[i] {
353            let node_idx = node_points.len();
354            node_points.push(event_pts[ei]);
355            node_is_isect.push(true);
356
357            if event_edge_i[ei] == i {
358                isect_node_on_i[ei] = node_idx;
359            } else {
360                isect_node_on_j[ei] = node_idx;
361            }
362        }
363    }
364
365    let total = node_points.len();
366    let forward: Vec<usize> = (0..total).map(|i| (i + 1) % total).collect();
367
368    let mut jump: Vec<Option<usize>> = vec![None; total];
369    for ei in 0..num_events {
370        let ni = isect_node_on_i[ei];
371        let nj = isect_node_on_j[ei];
372        jump[ni] = Some(forward[nj]);
373        jump[nj] = Some(forward[ni]);
374    }
375
376    let mut visited = vec![false; total];
377    let mut result_loops: Vec<Vec<(f64, f64)>> = Vec::new();
378
379    for start in 0..total {
380        if visited[start] || !node_is_isect[start] {
381            continue;
382        }
383
384        for use_jump_first in [true, false] {
385            let first_step = if use_jump_first {
386                match jump[start] {
387                    Some(j) => j,
388                    None => continue,
389                }
390            } else {
391                forward[start]
392            };
393
394            let mut loop_pts = vec![node_points[start]];
395            let mut curr = first_step;
396            let mut steps = 0;
397            let max_steps = total + 1;
398
399            while curr != start && steps < max_steps {
400                loop_pts.push(node_points[curr]);
401                curr = match jump[curr].take() {
402                    Some(j) => j,
403                    None => forward[curr],
404                };
405                steps += 1;
406            }
407
408            if curr == start && loop_pts.len() >= 3 {
409                let sa = polygon::signed_area(&loop_pts);
410
411                if sa > EPS {
412                    for &lp in &loop_pts {
413                        for (idx, &np) in node_points.iter().enumerate() {
414                            if (lp.0 - np.0).abs() < EPS && (lp.1 - np.1).abs() < EPS {
415                                visited[idx] = true;
416                            }
417                        }
418                    }
419                    result_loops.push(loop_pts);
420                }
421            }
422        }
423    }
424
425    if result_loops.is_empty() {
426        let sa = polygon::signed_area(vertices);
427        if sa > EPS {
428            return vec![vertices.to_vec()];
429        }
430        return Vec::new();
431    }
432
433    result_loops
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    fn approx_area(polygon: &[(f64, f64)]) -> f64 {
441        polygon::area(polygon)
442    }
443
444    #[test]
445    fn test_offset_edge_horizontal() {
446        let ((q0x, q0y), (q1x, q1y)) = offset_edge((0.0, 0.0), (10.0, 0.0), 1.0);
447        assert!((q0y - (-1.0)).abs() < EPS);
448        assert!((q1y - (-1.0)).abs() < EPS);
449        assert!((q0x - 0.0).abs() < EPS);
450        assert!((q1x - 10.0).abs() < EPS);
451    }
452
453    #[test]
454    fn test_offset_edge_vertical() {
455        let ((q0x, q0y), (q1x, q1y)) = offset_edge((0.0, 0.0), (0.0, 10.0), 1.0);
456        assert!((q0x - 1.0).abs() < EPS);
457        assert!((q1x - 1.0).abs() < EPS);
458        assert!((q0y - 0.0).abs() < EPS);
459        assert!((q1y - 10.0).abs() < EPS);
460    }
461
462    #[test]
463    fn test_offset_edge_zero_length() {
464        let (q0, q1) = offset_edge((5.0, 5.0), (5.0, 5.0), 1.0);
465        assert!((q0.0 - 5.0).abs() < EPS);
466        assert!((q0.1 - 5.0).abs() < EPS);
467        assert!((q1.0 - 5.0).abs() < EPS);
468        assert!((q1.1 - 5.0).abs() < EPS);
469    }
470
471    #[test]
472    fn test_line_intersection_perpendicular() {
473        let pt = line_intersection((0.0, 0.0), (1.0, 0.0), (5.0, -5.0), (0.0, 1.0));
474        assert!(pt.is_some());
475        let (x, y) = pt.expect("perpendicular lines must intersect");
476        assert!((x - 5.0).abs() < EPS);
477        assert!((y - 0.0).abs() < EPS);
478    }
479
480    #[test]
481    fn test_line_intersection_parallel() {
482        let pt = line_intersection((0.0, 0.0), (1.0, 0.0), (0.0, 5.0), (1.0, 0.0));
483        assert!(pt.is_none());
484    }
485
486    #[test]
487    fn test_line_intersection_diagonal() {
488        let pt = line_intersection((0.0, 0.0), (1.0, 1.0), (10.0, 0.0), (-1.0, 1.0));
489        let (x, y) = pt.expect("diagonal lines must intersect");
490        assert!((x - 5.0).abs() < EPS);
491        assert!((y - 5.0).abs() < EPS);
492    }
493
494    #[test]
495    fn test_square_offset_outward() {
496        let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
497        let result = offset_polygon(&square, 1.0);
498        assert!(!result.is_empty());
499        let a = approx_area(&result[0]);
500        assert!((a - 144.0).abs() < 1.0, "got {a}");
501    }
502
503    #[test]
504    fn test_square_offset_inward() {
505        let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
506        let result = offset_polygon(&square, -1.0);
507        assert!(!result.is_empty());
508        let a = approx_area(&result[0]);
509        assert!((a - 64.0).abs() < 1.0, "got {a}");
510    }
511
512    #[test]
513    fn test_square_offset_area_formula() {
514        let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
515        let d = 2.0;
516        let result = offset_polygon(&square, d);
517        assert!(!result.is_empty());
518        let a = approx_area(&result[0]);
519        let expected = 100.0 + 40.0 * d + 4.0 * d * d;
520        assert!((a - expected).abs() < 1.0, "got {a}, expected {expected}");
521    }
522
523    #[test]
524    fn test_triangle_offset_outward() {
525        let tri = [(0.0, 0.0), (10.0, 0.0), (5.0, 10.0)];
526        let result = offset_polygon(&tri, 0.5);
527        assert!(!result.is_empty());
528        let original_area = approx_area(&tri);
529        let offset_area = approx_area(&result[0]);
530        assert!(
531            offset_area > original_area,
532            "{offset_area} <= {original_area}"
533        );
534    }
535
536    #[test]
537    fn test_triangle_offset_inward() {
538        let tri = [(0.0, 0.0), (20.0, 0.0), (10.0, 17.32)];
539        let result = offset_polygon(&tri, -1.0);
540        assert!(!result.is_empty());
541        let original_area = approx_area(&tri);
542        let offset_area = approx_area(&result[0]);
543        assert!(
544            offset_area < original_area,
545            "{offset_area} >= {original_area}"
546        );
547    }
548
549    #[test]
550    fn test_convex_polygon_offset_preserves_convexity() {
551        let hexagon: Vec<(f64, f64)> = (0..6)
552            .map(|i| {
553                let angle = std::f64::consts::PI / 3.0 * i as f64;
554                (50.0 + 20.0 * angle.cos(), 50.0 + 20.0 * angle.sin())
555            })
556            .collect();
557        let result = offset_polygon(&hexagon, 2.0);
558        assert!(!result.is_empty());
559        assert!(polygon::is_convex(&result[0]));
560    }
561
562    #[test]
563    fn test_zero_distance_returns_original() {
564        let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
565        let result = offset_polygon(&square, 0.0);
566        assert_eq!(result.len(), 1);
567        assert_eq!(result[0].len(), square.len());
568        for (orig, rp) in square.iter().zip(result[0].iter()) {
569            assert!((orig.0 - rp.0).abs() < EPS);
570            assert!((orig.1 - rp.1).abs() < EPS);
571        }
572    }
573
574    #[test]
575    fn test_large_negative_offset_collapses() {
576        let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
577        let result = offset_polygon(&square, -6.0);
578        assert!(result.is_empty(), "got {} rings", result.len());
579    }
580
581    #[test]
582    fn test_l_shape_offset_outward() {
583        let l_shape = [
584            (0.0, 0.0),
585            (20.0, 0.0),
586            (20.0, 10.0),
587            (10.0, 10.0),
588            (10.0, 20.0),
589            (0.0, 20.0),
590        ];
591        let result = offset_polygon(&l_shape, 1.0);
592        assert!(!result.is_empty());
593        let original_area = approx_area(&l_shape);
594        let total: f64 = result.iter().map(|r| approx_area(r)).sum();
595        assert!(total > original_area, "{total} <= {original_area}");
596    }
597
598    #[test]
599    fn test_l_shape_offset_inward() {
600        let l_shape = [
601            (0.0, 0.0),
602            (20.0, 0.0),
603            (20.0, 10.0),
604            (10.0, 10.0),
605            (10.0, 20.0),
606            (0.0, 20.0),
607        ];
608        let result = offset_polygon(&l_shape, -1.0);
609        assert!(!result.is_empty());
610        let original_area = approx_area(&l_shape);
611        let total: f64 = result.iter().map(|r| approx_area(r)).sum();
612        assert!(total < original_area, "{total} >= {original_area}");
613    }
614
615    #[test]
616    fn test_degenerate_polygon_too_few_vertices() {
617        assert!(offset_polygon(&[(0.0, 0.0), (10.0, 0.0)], 1.0).is_empty());
618    }
619
620    #[test]
621    fn test_empty_polygon() {
622        assert!(offset_polygon(&[], 1.0).is_empty());
623    }
624
625    #[test]
626    fn test_cw_polygon_normalized_to_ccw() {
627        let cw = [(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0)];
628        let result = offset_polygon(&cw, 1.0);
629        assert!(!result.is_empty());
630        let a = approx_area(&result[0]);
631        assert!((a - 144.0).abs() < 1.0, "got {a}");
632    }
633
634    #[test]
635    fn test_small_polygon_small_offset() {
636        let tiny = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
637        let result = offset_polygon(&tiny, 0.1);
638        assert!(!result.is_empty());
639        let a = approx_area(&result[0]);
640        assert!((a - 1.44).abs() < 0.1, "got {a}");
641    }
642
643    #[test]
644    fn test_miter_limit_with_acute_triangle() {
645        let acute = [(0.0, 0.0), (100.0, 0.0), (50.0, 1.0)];
646        let result = offset_polygon_miter(&acute, 2.0, 1.5);
647        for ring in &result {
648            for &(x, y) in ring {
649                assert!(x.is_finite());
650                assert!(y.is_finite());
651            }
652        }
653    }
654
655    #[test]
656    fn test_regular_pentagon_offset() {
657        let pentagon: Vec<(f64, f64)> = (0..5)
658            .map(|i| {
659                let angle = std::f64::consts::TAU / 5.0 * i as f64 - std::f64::consts::FRAC_PI_2;
660                (50.0 + 30.0 * angle.cos(), 50.0 + 30.0 * angle.sin())
661            })
662            .collect();
663        let result = offset_polygon(&pentagon, 3.0);
664        assert!(!result.is_empty());
665        assert!(approx_area(&result[0]) > approx_area(&pentagon));
666    }
667
668    #[cfg(test)]
669    mod proptest_tests {
670        use super::*;
671        use proptest::prelude::*;
672
673        fn convex_polygon_strategy() -> impl Strategy<Value = Vec<(f64, f64)>> {
674            (5..=12u32, 10.0..100.0f64).prop_map(|(n, radius)| {
675                (0..n)
676                    .map(|i| {
677                        let angle = std::f64::consts::TAU / n as f64 * i as f64;
678                        (50.0 + radius * angle.cos(), 50.0 + radius * angle.sin())
679                    })
680                    .collect()
681            })
682        }
683
684        proptest! {
685            #[test]
686            fn prop_outward_offset_increases_area(
687                poly in convex_polygon_strategy(),
688                d in 0.1..5.0f64,
689            ) {
690                let result = offset_polygon(&poly, d);
691                if !result.is_empty() {
692                    let original = polygon::area(&poly);
693                    let offset_a = polygon::area(&result[0]);
694                    prop_assert!(offset_a >= original - 1e-6,
695                        "original={}, offset={}", original, offset_a);
696                }
697            }
698
699            #[test]
700            fn prop_inward_offset_decreases_area(
701                poly in convex_polygon_strategy(),
702                d in 0.1..3.0f64,
703            ) {
704                let result = offset_polygon(&poly, -d);
705                if !result.is_empty() {
706                    let original = polygon::area(&poly);
707                    let offset_a = polygon::area(&result[0]);
708                    prop_assert!(offset_a <= original + 1e-6,
709                        "original={}, offset={}", original, offset_a);
710                }
711            }
712
713            #[test]
714            fn prop_offset_vertices_are_finite(
715                poly in convex_polygon_strategy(),
716                d in -10.0..10.0f64,
717            ) {
718                let result = offset_polygon(&poly, d);
719                for ring in &result {
720                    for &(x, y) in ring {
721                        prop_assert!(x.is_finite());
722                        prop_assert!(y.is_finite());
723                    }
724                }
725            }
726        }
727    }
728}