Skip to main content

oxiphysics_geometry/
offset_geometry.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Offset / buffer geometry operations.
5//!
6//! Provides algorithms for:
7//!
8//! - **Polygon offset** via Minkowski sum approach (inward/outward)
9//! - **Curve offset** (equidistant curves for polylines)
10//! - **Fillet** (rounding interior corners)
11//! - **Chamfer** (beveling corners)
12//! - **Rounded rectangles** generation
13//! - **Minkowski sum / difference** for convex 2-D shapes
14//! - **Morphological operations** (erosion, dilation, opening, closing)
15//! - **Tubular neighborhoods** (thickening of curves/surfaces)
16
17use std::f64::consts::PI;
18
19// ---------------------------------------------------------------------------
20// 2-D point type
21// ---------------------------------------------------------------------------
22
23/// A 2-D point / vector.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct Point2 {
26    /// X coordinate.
27    pub x: f64,
28    /// Y coordinate.
29    pub y: f64,
30}
31
32impl Point2 {
33    /// Create a new point.
34    pub fn new(x: f64, y: f64) -> Self {
35        Self { x, y }
36    }
37
38    /// Zero vector.
39    pub fn zero() -> Self {
40        Self { x: 0.0, y: 0.0 }
41    }
42
43    /// Euclidean distance to another point.
44    pub fn distance(&self, other: &Self) -> f64 {
45        let dx = self.x - other.x;
46        let dy = self.y - other.y;
47        (dx * dx + dy * dy).sqrt()
48    }
49
50    /// Length (norm) of the vector.
51    pub fn length(&self) -> f64 {
52        (self.x * self.x + self.y * self.y).sqrt()
53    }
54
55    /// Normalise to unit length. Returns zero if degenerate.
56    pub fn normalized(&self) -> Self {
57        let l = self.length();
58        if l < 1.0e-14 {
59            return Self::zero();
60        }
61        Self {
62            x: self.x / l,
63            y: self.y / l,
64        }
65    }
66
67    /// Dot product.
68    pub fn dot(&self, other: &Self) -> f64 {
69        self.x * other.x + self.y * other.y
70    }
71
72    /// 2-D cross product (scalar).
73    pub fn cross(&self, other: &Self) -> f64 {
74        self.x * other.y - self.y * other.x
75    }
76
77    /// Perpendicular (rotate 90 degrees CCW).
78    pub fn perp(&self) -> Self {
79        Self {
80            x: -self.y,
81            y: self.x,
82        }
83    }
84
85    /// Add two points.
86    pub fn add(&self, other: &Self) -> Self {
87        Self {
88            x: self.x + other.x,
89            y: self.y + other.y,
90        }
91    }
92
93    /// Subtract.
94    pub fn sub(&self, other: &Self) -> Self {
95        Self {
96            x: self.x - other.x,
97            y: self.y - other.y,
98        }
99    }
100
101    /// Scale.
102    pub fn scale(&self, s: f64) -> Self {
103        Self {
104            x: self.x * s,
105            y: self.y * s,
106        }
107    }
108
109    /// Lerp between self and other.
110    pub fn lerp(&self, other: &Self, t: f64) -> Self {
111        Self {
112            x: self.x * (1.0 - t) + other.x * t,
113            y: self.y * (1.0 - t) + other.y * t,
114        }
115    }
116
117    /// Angle in radians (atan2).
118    pub fn angle(&self) -> f64 {
119        self.y.atan2(self.x)
120    }
121
122    /// Create from angle and length.
123    pub fn from_polar(angle: f64, length: f64) -> Self {
124        Self {
125            x: length * angle.cos(),
126            y: length * angle.sin(),
127        }
128    }
129}
130
131// ---------------------------------------------------------------------------
132// Polygon utilities
133// ---------------------------------------------------------------------------
134
135/// Signed area of a simple polygon (positive = CCW).
136pub fn signed_area(poly: &[Point2]) -> f64 {
137    let n = poly.len();
138    if n < 3 {
139        return 0.0;
140    }
141    let mut area = 0.0;
142    for i in 0..n {
143        let j = (i + 1) % n;
144        area += poly[i].x * poly[j].y - poly[j].x * poly[i].y;
145    }
146    area * 0.5
147}
148
149/// Absolute area.
150pub fn polygon_area(poly: &[Point2]) -> f64 {
151    signed_area(poly).abs()
152}
153
154/// Perimeter of a polygon.
155pub fn polygon_perimeter(poly: &[Point2]) -> f64 {
156    let n = poly.len();
157    if n < 2 {
158        return 0.0;
159    }
160    let mut perim = 0.0;
161    for i in 0..n {
162        let j = (i + 1) % n;
163        perim += poly[i].distance(&poly[j]);
164    }
165    perim
166}
167
168/// Centroid of a simple polygon.
169pub fn polygon_centroid(poly: &[Point2]) -> Point2 {
170    let n = poly.len();
171    if n == 0 {
172        return Point2::zero();
173    }
174    let a = signed_area(poly);
175    if a.abs() < 1.0e-14 {
176        // Degenerate: return average
177        let mut cx = 0.0;
178        let mut cy = 0.0;
179        for p in poly {
180            cx += p.x;
181            cy += p.y;
182        }
183        return Point2::new(cx / n as f64, cy / n as f64);
184    }
185    let mut cx = 0.0;
186    let mut cy = 0.0;
187    for i in 0..n {
188        let j = (i + 1) % n;
189        let cross = poly[i].x * poly[j].y - poly[j].x * poly[i].y;
190        cx += (poly[i].x + poly[j].x) * cross;
191        cy += (poly[i].y + poly[j].y) * cross;
192    }
193    let factor = 1.0 / (6.0 * a);
194    Point2::new(cx * factor, cy * factor)
195}
196
197/// Ensure polygon is in CCW order.
198pub fn ensure_ccw(poly: &mut [Point2]) {
199    if signed_area(poly) < 0.0 {
200        poly.reverse();
201    }
202}
203
204/// Check if a polygon is convex.
205pub fn is_convex(poly: &[Point2]) -> bool {
206    let n = poly.len();
207    if n < 3 {
208        return true;
209    }
210    let mut sign = 0i32;
211    for i in 0..n {
212        let a = poly[i];
213        let b = poly[(i + 1) % n];
214        let c = poly[(i + 2) % n];
215        let cross = b.sub(&a).cross(&c.sub(&b));
216        if cross.abs() < 1.0e-12 {
217            continue;
218        }
219        let s = if cross > 0.0 { 1 } else { -1 };
220        if sign == 0 {
221            sign = s;
222        } else if sign != s {
223            return false;
224        }
225    }
226    true
227}
228
229/// Point-in-polygon test (ray casting).
230pub fn point_in_polygon(pt: &Point2, poly: &[Point2]) -> bool {
231    let n = poly.len();
232    let mut inside = false;
233    let mut j = n - 1;
234    for i in 0..n {
235        let pi = &poly[i];
236        let pj = &poly[j];
237        if ((pi.y > pt.y) != (pj.y > pt.y))
238            && (pt.x < (pj.x - pi.x) * (pt.y - pi.y) / (pj.y - pi.y) + pi.x)
239        {
240            inside = !inside;
241        }
242        j = i;
243    }
244    inside
245}
246
247// ---------------------------------------------------------------------------
248// Polygon offset (Minkowski sum with a disk approach)
249// ---------------------------------------------------------------------------
250
251/// Offset a simple polygon by `distance`.
252///
253/// Positive distance = outward (dilation), negative = inward (erosion).
254/// Uses edge normal offsetting with miter joins.
255pub fn polygon_offset(poly: &[Point2], distance: f64) -> Vec<Point2> {
256    let n = poly.len();
257    if n < 3 {
258        return poly.to_vec();
259    }
260
261    // Get edge normals (outward-pointing for CCW polygon)
262    let area = signed_area(poly);
263    let flip = if area < 0.0 { -1.0 } else { 1.0 };
264
265    let mut result = Vec::with_capacity(n);
266
267    for i in 0..n {
268        let prev = if i == 0 { n - 1 } else { i - 1 };
269        let next = (i + 1) % n;
270
271        // Edge vectors
272        let e_prev = poly[i].sub(&poly[prev]).normalized();
273        let e_next = poly[next].sub(&poly[i]).normalized();
274
275        // Outward normals (for CCW: right-hand normal = (dy, -dx))
276        let n_prev = Point2::new(e_prev.y * flip, -e_prev.x * flip);
277        let n_next = Point2::new(e_next.y * flip, -e_next.x * flip);
278
279        // Bisector
280        let bisector = n_prev.add(&n_next);
281        let bl = bisector.length();
282        if bl < 1.0e-14 {
283            // Degenerate: use one normal
284            result.push(poly[i].add(&n_prev.scale(distance)));
285            continue;
286        }
287        let bisector = bisector.scale(1.0 / bl);
288
289        // Miter factor: 1 / cos(half-angle)
290        let cos_half = bisector.dot(&n_prev).max(0.1); // clamp to avoid extreme miter
291        let offset_len = distance / cos_half;
292
293        result.push(poly[i].add(&bisector.scale(offset_len)));
294    }
295
296    result
297}
298
299/// Polygon offset with arc joins at convex corners.
300///
301/// `segments_per_corner` controls arc resolution.
302pub fn polygon_offset_round(
303    poly: &[Point2],
304    distance: f64,
305    segments_per_corner: usize,
306) -> Vec<Point2> {
307    let n = poly.len();
308    if n < 3 {
309        return poly.to_vec();
310    }
311
312    let area = signed_area(poly);
313    let flip = if area < 0.0 { -1.0 } else { 1.0 };
314    let segs = segments_per_corner.max(1);
315
316    let mut result = Vec::new();
317
318    for i in 0..n {
319        let prev = if i == 0 { n - 1 } else { i - 1 };
320        let next = (i + 1) % n;
321
322        let e_prev = poly[i].sub(&poly[prev]).normalized();
323        let e_next = poly[next].sub(&poly[i]).normalized();
324
325        let n_prev = Point2::new(e_prev.y * flip, -e_prev.x * flip);
326        let n_next = Point2::new(e_next.y * flip, -e_next.x * flip);
327
328        let cross = e_prev.cross(&e_next) * flip;
329
330        if (distance > 0.0 && cross < -1.0e-10) || (distance < 0.0 && cross > 1.0e-10) {
331            // Convex corner: insert arc
332            let angle_start = n_prev.angle();
333            let mut angle_end = n_next.angle();
334            if distance > 0.0 {
335                while angle_end < angle_start {
336                    angle_end += 2.0 * PI;
337                }
338            } else {
339                while angle_end > angle_start {
340                    angle_end -= 2.0 * PI;
341                }
342            }
343            for k in 0..=segs {
344                let t = k as f64 / segs as f64;
345                let angle = angle_start + t * (angle_end - angle_start);
346                let offset = Point2::from_polar(angle, distance.abs());
347                let sign = if distance > 0.0 { 1.0 } else { -1.0 };
348                result.push(poly[i].add(&offset.scale(sign)));
349            }
350        } else {
351            // Concave or near-straight: miter
352            let bisector = n_prev.add(&n_next);
353            let bl = bisector.length();
354            if bl < 1.0e-14 {
355                result.push(poly[i].add(&n_prev.scale(distance)));
356            } else {
357                let bisector = bisector.scale(1.0 / bl);
358                let cos_half = bisector.dot(&n_prev).max(0.1);
359                let offset_len = distance / cos_half;
360                result.push(poly[i].add(&bisector.scale(offset_len)));
361            }
362        }
363    }
364
365    result
366}
367
368// ---------------------------------------------------------------------------
369// Curve offset (polyline offset)
370// ---------------------------------------------------------------------------
371
372/// Offset an open polyline by `distance`.
373///
374/// Returns the offset polyline. Positive distance = left side (for CCW).
375pub fn polyline_offset(polyline: &[Point2], distance: f64) -> Vec<Point2> {
376    let n = polyline.len();
377    if n < 2 {
378        return polyline.to_vec();
379    }
380    if n == 2 {
381        let e = polyline[1].sub(&polyline[0]).normalized();
382        let normal = Point2::new(-e.y, e.x);
383        let offset = normal.scale(distance);
384        return vec![polyline[0].add(&offset), polyline[1].add(&offset)];
385    }
386
387    let mut result = Vec::with_capacity(n);
388
389    // First point: use first edge normal
390    let e0 = polyline[1].sub(&polyline[0]).normalized();
391    let n0 = Point2::new(-e0.y, e0.x);
392    result.push(polyline[0].add(&n0.scale(distance)));
393
394    // Interior points: bisector
395    for i in 1..n - 1 {
396        let e_prev = polyline[i].sub(&polyline[i - 1]).normalized();
397        let e_next = polyline[i + 1].sub(&polyline[i]).normalized();
398        let n_prev = Point2::new(-e_prev.y, e_prev.x);
399        let n_next = Point2::new(-e_next.y, e_next.x);
400        let bisector = n_prev.add(&n_next);
401        let bl = bisector.length();
402        if bl < 1.0e-14 {
403            result.push(polyline[i].add(&n_prev.scale(distance)));
404        } else {
405            let bisector = bisector.scale(1.0 / bl);
406            let cos_half = bisector.dot(&n_prev).max(0.1);
407            let offset_len = distance / cos_half;
408            result.push(polyline[i].add(&bisector.scale(offset_len)));
409        }
410    }
411
412    // Last point: use last edge normal
413    let e_last = polyline[n - 1].sub(&polyline[n - 2]).normalized();
414    let n_last = Point2::new(-e_last.y, e_last.x);
415    result.push(polyline[n - 1].add(&n_last.scale(distance)));
416
417    result
418}
419
420// ---------------------------------------------------------------------------
421// Fillet and chamfer
422// ---------------------------------------------------------------------------
423
424/// Apply a fillet (arc rounding) at each vertex of a polygon.
425///
426/// `radius` is the fillet radius; `segments` controls arc resolution.
427pub fn fillet_polygon(poly: &[Point2], radius: f64, segments: usize) -> Vec<Point2> {
428    let n = poly.len();
429    if n < 3 || radius <= 0.0 {
430        return poly.to_vec();
431    }
432    let segs = segments.max(2);
433    let mut result = Vec::new();
434
435    for i in 0..n {
436        let prev = if i == 0 { n - 1 } else { i - 1 };
437        let next = (i + 1) % n;
438
439        let to_prev = poly[prev].sub(&poly[i]).normalized();
440        let to_next = poly[next].sub(&poly[i]).normalized();
441
442        let dot = to_prev.dot(&to_next).clamp(-1.0, 1.0);
443        let half_angle = ((1.0 + dot) / 2.0).sqrt().acos().max(1.0e-6);
444        let tan_half = half_angle.tan().max(1.0e-6);
445        let trim = (radius / tan_half).min(
446            poly[i]
447                .distance(&poly[prev])
448                .min(poly[i].distance(&poly[next]))
449                * 0.49,
450        );
451        let actual_radius = trim * tan_half;
452
453        // Trim points
454        let p_start = poly[i].add(&to_prev.scale(trim));
455        let p_end = poly[i].add(&to_next.scale(trim));
456
457        // Arc center: offset from vertex along bisector
458        let bisector = to_prev.add(&to_next).normalized();
459        let center_dist = actual_radius / half_angle.sin().max(1.0e-6);
460        let center = poly[i].add(&bisector.scale(center_dist));
461
462        // Arc from p_start to p_end
463        let angle_start = p_start.sub(&center).angle();
464        let angle_end = p_end.sub(&center).angle();
465        let mut sweep = angle_end - angle_start;
466        // Choose the short arc
467        while sweep > PI {
468            sweep -= 2.0 * PI;
469        }
470        while sweep < -PI {
471            sweep += 2.0 * PI;
472        }
473
474        for k in 0..=segs {
475            let t = k as f64 / segs as f64;
476            let a = angle_start + t * sweep;
477            result.push(Point2::new(
478                center.x + actual_radius * a.cos(),
479                center.y + actual_radius * a.sin(),
480            ));
481        }
482    }
483
484    result
485}
486
487/// Apply a chamfer (bevel) at each vertex of a polygon.
488///
489/// `distance` is how far from the vertex to cut on each adjacent edge.
490pub fn chamfer_polygon(poly: &[Point2], distance: f64) -> Vec<Point2> {
491    let n = poly.len();
492    if n < 3 || distance <= 0.0 {
493        return poly.to_vec();
494    }
495    let mut result = Vec::new();
496
497    for i in 0..n {
498        let prev = if i == 0 { n - 1 } else { i - 1 };
499        let next = (i + 1) % n;
500
501        let to_prev = poly[prev].sub(&poly[i]).normalized();
502        let to_next = poly[next].sub(&poly[i]).normalized();
503
504        let d_prev = poly[i].distance(&poly[prev]);
505        let d_next = poly[i].distance(&poly[next]);
506        let trim = distance.min(d_prev * 0.49).min(d_next * 0.49);
507
508        let p1 = poly[i].add(&to_prev.scale(trim));
509        let p2 = poly[i].add(&to_next.scale(trim));
510
511        result.push(p1);
512        result.push(p2);
513    }
514
515    result
516}
517
518// ---------------------------------------------------------------------------
519// Rounded rectangle
520// ---------------------------------------------------------------------------
521
522/// Generate a rounded rectangle polygon.
523///
524/// `width`, `height` are the full dimensions, `radius` is corner radius.
525/// The rectangle is centred at the origin.
526pub fn rounded_rectangle(
527    width: f64,
528    height: f64,
529    radius: f64,
530    segments_per_corner: usize,
531) -> Vec<Point2> {
532    let r = radius.min(width / 2.0).min(height / 2.0).max(0.0);
533    let hw = width / 2.0;
534    let hh = height / 2.0;
535    let segs = segments_per_corner.max(1);
536
537    let mut result = Vec::new();
538
539    // Four corners: top-right, top-left, bottom-left, bottom-right
540    let corners = [
541        (hw - r, hh - r, 0.0),
542        (-(hw - r), hh - r, PI / 2.0),
543        (-(hw - r), -(hh - r), PI),
544        (hw - r, -(hh - r), 3.0 * PI / 2.0),
545    ];
546
547    for &(cx, cy, start_angle) in &corners {
548        for k in 0..=segs {
549            let t = k as f64 / segs as f64;
550            let a = start_angle + t * (PI / 2.0);
551            result.push(Point2::new(cx + r * a.cos(), cy + r * a.sin()));
552        }
553    }
554
555    result
556}
557
558// ---------------------------------------------------------------------------
559// Minkowski sum / difference (convex shapes)
560// ---------------------------------------------------------------------------
561
562/// Minkowski sum of two convex polygons (both assumed CCW).
563///
564/// Result is a convex polygon.
565pub fn minkowski_sum_convex(a: &[Point2], b: &[Point2]) -> Vec<Point2> {
566    if a.is_empty() || b.is_empty() {
567        return Vec::new();
568    }
569    let na = a.len();
570    let nb = b.len();
571
572    // Find bottom-most points
573    let mut ia = 0;
574    for i in 1..na {
575        if a[i].y < a[ia].y || (a[i].y == a[ia].y && a[i].x < a[ia].x) {
576            ia = i;
577        }
578    }
579    let mut ib = 0;
580    for i in 1..nb {
581        if b[i].y < b[ib].y || (b[i].y == b[ib].y && b[i].x < b[ib].x) {
582            ib = i;
583        }
584    }
585
586    let mut result = Vec::with_capacity(na + nb);
587    let mut i = 0;
588    let mut j = 0;
589
590    while i < na || j < nb {
591        let ai = (ia + i) % na;
592        let bi = (ib + j) % nb;
593        result.push(a[ai].add(&b[bi]));
594
595        let a_next = (ia + i + 1) % na;
596        let b_next = (ib + j + 1) % nb;
597
598        let ea = a[a_next].sub(&a[ai]);
599        let eb = b[b_next].sub(&b[bi]);
600
601        let cross = ea.cross(&eb);
602
603        if i >= na {
604            j += 1;
605        } else if j >= nb || cross > 0.0 {
606            i += 1;
607        } else if cross < 0.0 {
608            j += 1;
609        } else {
610            // Parallel edges: advance both
611            i += 1;
612            j += 1;
613        }
614    }
615
616    result
617}
618
619/// Minkowski difference of two convex polygons.
620///
621/// This is the Minkowski sum of A and the reflection of B about the origin.
622pub fn minkowski_difference_convex(a: &[Point2], b: &[Point2]) -> Vec<Point2> {
623    let b_reflected: Vec<Point2> = b.iter().map(|p| Point2::new(-p.x, -p.y)).collect();
624    // Re-order reflected polygon to CCW
625    let mut b_ref = b_reflected;
626    ensure_ccw(&mut b_ref);
627    minkowski_sum_convex(a, &b_ref)
628}
629
630// ---------------------------------------------------------------------------
631// Morphological operations
632// ---------------------------------------------------------------------------
633
634/// Dilate a polygon by `radius` (outward offset).
635pub fn morphological_dilation(poly: &[Point2], radius: f64) -> Vec<Point2> {
636    polygon_offset(poly, radius.abs())
637}
638
639/// Erode a polygon by `radius` (inward offset).
640pub fn morphological_erosion(poly: &[Point2], radius: f64) -> Vec<Point2> {
641    polygon_offset(poly, -radius.abs())
642}
643
644/// Morphological opening: erosion followed by dilation.
645///
646/// Removes small protrusions.
647pub fn morphological_opening(poly: &[Point2], radius: f64) -> Vec<Point2> {
648    let eroded = morphological_erosion(poly, radius);
649    morphological_dilation(&eroded, radius)
650}
651
652/// Morphological closing: dilation followed by erosion.
653///
654/// Fills small concavities.
655pub fn morphological_closing(poly: &[Point2], radius: f64) -> Vec<Point2> {
656    let dilated = morphological_dilation(poly, radius);
657    morphological_erosion(&dilated, radius)
658}
659
660// ---------------------------------------------------------------------------
661// Tubular neighborhoods
662// ---------------------------------------------------------------------------
663
664/// Generate a tubular neighborhood (thickened polyline).
665///
666/// Returns a closed polygon that is the union of disks of given `radius`
667/// centred along the polyline (approximation via offset + closure).
668pub fn tubular_neighborhood(polyline: &[Point2], radius: f64) -> Vec<Point2> {
669    if polyline.len() < 2 {
670        if polyline.len() == 1 {
671            // Single point: return a circle
672            let mut circle = Vec::new();
673            let segs = 16;
674            for k in 0..segs {
675                let a = 2.0 * PI * k as f64 / segs as f64;
676                circle.push(Point2::new(
677                    polyline[0].x + radius * a.cos(),
678                    polyline[0].y + radius * a.sin(),
679                ));
680            }
681            return circle;
682        }
683        return Vec::new();
684    }
685
686    let left = polyline_offset(polyline, radius);
687    let right = polyline_offset(polyline, -radius);
688
689    // Close into a loop: left forward, right reversed, semicircular caps
690    let mut result = Vec::new();
691
692    // Start cap (semicircle around first point)
693    let e0 = polyline[1].sub(&polyline[0]).normalized();
694    let n0 = Point2::new(-e0.y, e0.x);
695    let angle_start = n0.scale(radius).add(&polyline[0]).sub(&polyline[0]).angle();
696    let cap_segs = 8;
697    // Left side forward
698    result.extend_from_slice(&left);
699
700    // End cap
701    let n = polyline.len();
702    let e_end = polyline[n - 1].sub(&polyline[n - 2]).normalized();
703    let n_end = Point2::new(-e_end.y, e_end.x);
704    let cap_start = n_end.angle();
705    for k in 0..=cap_segs {
706        let t = k as f64 / cap_segs as f64;
707        let a = cap_start - t * PI;
708        result.push(Point2::new(
709            polyline[n - 1].x + radius * a.cos(),
710            polyline[n - 1].y + radius * a.sin(),
711        ));
712    }
713
714    // Right side reversed
715    for p in right.iter().rev() {
716        result.push(*p);
717    }
718
719    // Start cap
720    let _n0_angle = Point2::new(e0.y, -e0.x).angle();
721    let cap_start2 = Point2::new(e0.y, -e0.x).angle();
722    for k in 0..=cap_segs {
723        let t = k as f64 / cap_segs as f64;
724        let a = cap_start2 - t * PI;
725        result.push(Point2::new(
726            polyline[0].x + radius * a.cos(),
727            polyline[0].y + radius * a.sin(),
728        ));
729    }
730
731    let _ = angle_start; // suppress unused
732    result
733}
734
735/// Compute the distance from a point to a line segment.
736pub fn point_to_segment_distance(pt: &Point2, a: &Point2, b: &Point2) -> f64 {
737    let ab = b.sub(a);
738    let ap = pt.sub(a);
739    let t = (ap.dot(&ab) / ab.dot(&ab)).clamp(0.0, 1.0);
740    let proj = a.add(&ab.scale(t));
741    pt.distance(&proj)
742}
743
744/// Check if a point is within distance `d` of a polyline.
745pub fn point_in_tubular(pt: &Point2, polyline: &[Point2], radius: f64) -> bool {
746    for i in 0..polyline.len().saturating_sub(1) {
747        if point_to_segment_distance(pt, &polyline[i], &polyline[i + 1]) <= radius {
748            return true;
749        }
750    }
751    false
752}
753
754// ---------------------------------------------------------------------------
755// 3-D offset helpers
756// ---------------------------------------------------------------------------
757
758/// Offset a 3-D polygon (planar) by `distance` along edge normals.
759///
760/// The polygon lives in `[f64; 3]` space and is assumed planar.
761/// This projects to 2-D, offsets, and lifts back.
762pub fn polygon_offset_3d(
763    vertices: &[[f64; 3]],
764    distance: f64,
765    plane_normal: [f64; 3],
766) -> Vec<[f64; 3]> {
767    if vertices.len() < 3 {
768        return vertices.to_vec();
769    }
770
771    // Build a local 2-D frame
772    let n = normalize_3d(plane_normal);
773    let u = find_perpendicular(n);
774    let v = cross_3d(n, u);
775
776    // Project to 2-D
777    let origin = vertices[0];
778    let poly2d: Vec<Point2> = vertices
779        .iter()
780        .map(|p| {
781            let rel = [p[0] - origin[0], p[1] - origin[1], p[2] - origin[2]];
782            Point2::new(dot_3d(rel, u), dot_3d(rel, v))
783        })
784        .collect();
785
786    // Offset in 2-D
787    let offset2d = polygon_offset(&poly2d, distance);
788
789    // Lift back to 3-D
790    offset2d
791        .iter()
792        .map(|p| {
793            [
794                origin[0] + p.x * u[0] + p.y * v[0],
795                origin[1] + p.x * u[1] + p.y * v[1],
796                origin[2] + p.x * u[2] + p.y * v[2],
797            ]
798        })
799        .collect()
800}
801
802/// Normalise a 3-D vector.
803fn normalize_3d(v: [f64; 3]) -> [f64; 3] {
804    let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
805    if l < 1.0e-14 {
806        return [0.0, 0.0, 1.0];
807    }
808    [v[0] / l, v[1] / l, v[2] / l]
809}
810
811/// Dot product of 3-D vectors.
812fn dot_3d(a: [f64; 3], b: [f64; 3]) -> f64 {
813    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
814}
815
816/// Cross product of 3-D vectors.
817fn cross_3d(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
818    [
819        a[1] * b[2] - a[2] * b[1],
820        a[2] * b[0] - a[0] * b[2],
821        a[0] * b[1] - a[1] * b[0],
822    ]
823}
824
825/// Find a perpendicular vector to `n`.
826fn find_perpendicular(n: [f64; 3]) -> [f64; 3] {
827    let candidate = if n[0].abs() < 0.9 {
828        [1.0, 0.0, 0.0]
829    } else {
830        [0.0, 1.0, 0.0]
831    };
832    let c = cross_3d(n, candidate);
833    normalize_3d(c)
834}
835
836// ---------------------------------------------------------------------------
837// Tests
838// ---------------------------------------------------------------------------
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    const EPS: f64 = 1e-10;
845
846    fn square() -> Vec<Point2> {
847        vec![
848            Point2::new(0.0, 0.0),
849            Point2::new(1.0, 0.0),
850            Point2::new(1.0, 1.0),
851            Point2::new(0.0, 1.0),
852        ]
853    }
854
855    fn triangle() -> Vec<Point2> {
856        vec![
857            Point2::new(0.0, 0.0),
858            Point2::new(2.0, 0.0),
859            Point2::new(1.0, 1.732),
860        ]
861    }
862
863    // 1. Signed area of CCW square is positive
864    #[test]
865    fn test_signed_area_ccw() {
866        let sq = square();
867        let a = signed_area(&sq);
868        assert!((a - 1.0).abs() < 1.0e-6, "Area should be 1.0, got {:.6}", a);
869    }
870
871    // 2. Signed area of CW square is negative
872    #[test]
873    fn test_signed_area_cw() {
874        let mut sq = square();
875        sq.reverse();
876        let a = signed_area(&sq);
877        assert!(
878            (a - (-1.0)).abs() < 1.0e-6,
879            "CW area should be -1.0, got {:.6}",
880            a
881        );
882    }
883
884    // 3. Polygon area is absolute
885    #[test]
886    fn test_polygon_area() {
887        let mut sq = square();
888        sq.reverse();
889        assert!((polygon_area(&sq) - 1.0).abs() < 1.0e-6);
890    }
891
892    // 4. Perimeter of unit square
893    #[test]
894    fn test_perimeter() {
895        let sq = square();
896        let p = polygon_perimeter(&sq);
897        assert!(
898            (p - 4.0).abs() < 1.0e-6,
899            "Perimeter should be 4.0, got {:.6}",
900            p
901        );
902    }
903
904    // 5. Centroid of unit square at (0.5, 0.5)
905    #[test]
906    fn test_centroid() {
907        let sq = square();
908        let c = polygon_centroid(&sq);
909        assert!(
910            (c.x - 0.5).abs() < 1.0e-6,
911            "cx should be 0.5, got {:.6}",
912            c.x
913        );
914        assert!(
915            (c.y - 0.5).abs() < 1.0e-6,
916            "cy should be 0.5, got {:.6}",
917            c.y
918        );
919    }
920
921    // 6. Square is convex
922    #[test]
923    fn test_square_convex() {
924        assert!(is_convex(&square()));
925    }
926
927    // 7. L-shape is not convex
928    #[test]
929    fn test_l_shape_not_convex() {
930        let l = vec![
931            Point2::new(0.0, 0.0),
932            Point2::new(2.0, 0.0),
933            Point2::new(2.0, 1.0),
934            Point2::new(1.0, 1.0),
935            Point2::new(1.0, 2.0),
936            Point2::new(0.0, 2.0),
937        ];
938        assert!(!is_convex(&l));
939    }
940
941    // 8. Point inside polygon
942    #[test]
943    fn test_point_inside() {
944        let sq = square();
945        assert!(point_in_polygon(&Point2::new(0.5, 0.5), &sq));
946    }
947
948    // 9. Point outside polygon
949    #[test]
950    fn test_point_outside() {
951        let sq = square();
952        assert!(!point_in_polygon(&Point2::new(2.0, 2.0), &sq));
953    }
954
955    // 10. Outward offset increases area
956    #[test]
957    fn test_offset_increases_area() {
958        let sq = square();
959        let offset = polygon_offset(&sq, 0.1);
960        let area_orig = polygon_area(&sq);
961        let area_offset = polygon_area(&offset);
962        assert!(
963            area_offset > area_orig,
964            "Offset area {:.6} should be > original {:.6}",
965            area_offset,
966            area_orig
967        );
968    }
969
970    // 11. Inward offset decreases area
971    #[test]
972    fn test_offset_decreases_area() {
973        let sq = square();
974        let offset = polygon_offset(&sq, -0.1);
975        let area_orig = polygon_area(&sq);
976        let area_offset = polygon_area(&offset);
977        assert!(
978            area_offset < area_orig,
979            "Inward offset area {:.6} should be < original {:.6}",
980            area_offset,
981            area_orig
982        );
983    }
984
985    // 12. Zero offset preserves polygon
986    #[test]
987    fn test_zero_offset() {
988        let sq = square();
989        let offset = polygon_offset(&sq, 0.0);
990        assert_eq!(offset.len(), sq.len());
991        for (a, b) in offset.iter().zip(sq.iter()) {
992            assert!(a.distance(b) < 1.0e-6);
993        }
994    }
995
996    // 13. Polyline offset preserves point count
997    #[test]
998    fn test_polyline_offset_count() {
999        let line = vec![
1000            Point2::new(0.0, 0.0),
1001            Point2::new(1.0, 0.0),
1002            Point2::new(2.0, 1.0),
1003        ];
1004        let offset = polyline_offset(&line, 0.1);
1005        assert_eq!(offset.len(), line.len());
1006    }
1007
1008    // 14. Fillet produces more points
1009    #[test]
1010    fn test_fillet_more_points() {
1011        let sq = square();
1012        let filleted = fillet_polygon(&sq, 0.1, 4);
1013        assert!(
1014            filleted.len() > sq.len(),
1015            "Fillet should add points: {} vs {}",
1016            filleted.len(),
1017            sq.len()
1018        );
1019    }
1020
1021    // 15. Chamfer doubles vertex count
1022    #[test]
1023    fn test_chamfer_doubles() {
1024        let sq = square();
1025        let chamfered = chamfer_polygon(&sq, 0.1);
1026        assert_eq!(
1027            chamfered.len(),
1028            2 * sq.len(),
1029            "Chamfer should produce 2 points per vertex"
1030        );
1031    }
1032
1033    // 16. Rounded rectangle has correct number of points
1034    #[test]
1035    fn test_rounded_rect_points() {
1036        let rect = rounded_rectangle(4.0, 2.0, 0.5, 4);
1037        // 4 corners * (4+1) = 20 points
1038        assert_eq!(rect.len(), 20, "Should have 20 points, got {}", rect.len());
1039    }
1040
1041    // 17. Rounded rectangle area is between rectangle and full roundedness
1042    #[test]
1043    fn test_rounded_rect_area() {
1044        let rect = rounded_rectangle(4.0, 2.0, 0.5, 16);
1045        let area = polygon_area(&rect);
1046        let rect_area = 4.0 * 2.0;
1047        // Area should be slightly less than full rectangle (corners are rounded)
1048        assert!(
1049            area < rect_area + 0.1 && area > rect_area - 2.0,
1050            "Rounded rect area {:.6} should be near {:.6}",
1051            area,
1052            rect_area
1053        );
1054    }
1055
1056    // 18. Minkowski sum of two squares
1057    #[test]
1058    fn test_minkowski_sum_squares() {
1059        let a = square();
1060        let b = vec![
1061            Point2::new(0.0, 0.0),
1062            Point2::new(0.5, 0.0),
1063            Point2::new(0.5, 0.5),
1064            Point2::new(0.0, 0.5),
1065        ];
1066        let sum = minkowski_sum_convex(&a, &b);
1067        assert!(!sum.is_empty(), "Minkowski sum should not be empty");
1068        let area = polygon_area(&sum);
1069        // Should be ~(1+0.5)*(1+0.5) = 2.25
1070        assert!(
1071            (area - 2.25).abs() < 0.5,
1072            "Minkowski sum area should be ~2.25, got {:.6}",
1073            area
1074        );
1075    }
1076
1077    // 19. Minkowski difference
1078    #[test]
1079    fn test_minkowski_difference() {
1080        let a = vec![
1081            Point2::new(0.0, 0.0),
1082            Point2::new(2.0, 0.0),
1083            Point2::new(2.0, 2.0),
1084            Point2::new(0.0, 2.0),
1085        ];
1086        let b = vec![
1087            Point2::new(0.0, 0.0),
1088            Point2::new(0.5, 0.0),
1089            Point2::new(0.5, 0.5),
1090            Point2::new(0.0, 0.5),
1091        ];
1092        let diff = minkowski_difference_convex(&a, &b);
1093        assert!(!diff.is_empty(), "Minkowski difference should not be empty");
1094    }
1095
1096    // 20. Dilation increases area
1097    #[test]
1098    fn test_dilation_area() {
1099        let sq = square();
1100        let dilated = morphological_dilation(&sq, 0.2);
1101        assert!(polygon_area(&dilated) > polygon_area(&sq));
1102    }
1103
1104    // 21. Erosion decreases area
1105    #[test]
1106    fn test_erosion_area() {
1107        let sq = square();
1108        let eroded = morphological_erosion(&sq, 0.1);
1109        assert!(
1110            polygon_area(&eroded) < polygon_area(&sq),
1111            "Erosion should reduce area"
1112        );
1113    }
1114
1115    // 22. Opening area <= original area
1116    #[test]
1117    fn test_opening_area() {
1118        let sq = square();
1119        let opened = morphological_opening(&sq, 0.05);
1120        assert!(polygon_area(&opened) <= polygon_area(&sq) + 1.0e-3);
1121    }
1122
1123    // 23. Closing area >= original area
1124    #[test]
1125    fn test_closing_area() {
1126        let sq = square();
1127        let closed = morphological_closing(&sq, 0.05);
1128        // For a convex shape, closing ~ original
1129        assert!(polygon_area(&closed) >= polygon_area(&sq) - 1.0e-3);
1130    }
1131
1132    // 24. Tubular neighborhood produces closed polygon
1133    #[test]
1134    fn test_tubular_closed() {
1135        let line = vec![Point2::new(0.0, 0.0), Point2::new(3.0, 0.0)];
1136        let tube = tubular_neighborhood(&line, 0.5);
1137        assert!(tube.len() >= 4, "Tube should have at least 4 points");
1138    }
1139
1140    // 25. Point-to-segment distance
1141    #[test]
1142    fn test_point_segment_dist() {
1143        let a = Point2::new(0.0, 0.0);
1144        let b = Point2::new(2.0, 0.0);
1145        let pt = Point2::new(1.0, 1.0);
1146        let d = point_to_segment_distance(&pt, &a, &b);
1147        assert!(
1148            (d - 1.0).abs() < 1.0e-6,
1149            "Distance should be 1.0, got {:.6}",
1150            d
1151        );
1152    }
1153
1154    // 26. Point in tubular
1155    #[test]
1156    fn test_point_in_tubular() {
1157        let line = vec![Point2::new(0.0, 0.0), Point2::new(2.0, 0.0)];
1158        assert!(point_in_tubular(&Point2::new(1.0, 0.3), &line, 0.5));
1159        assert!(!point_in_tubular(&Point2::new(1.0, 1.0), &line, 0.5));
1160    }
1161
1162    // 27. Round offset produces more points than miter
1163    #[test]
1164    fn test_round_offset_more_points() {
1165        let sq = square();
1166        let miter = polygon_offset(&sq, 0.2);
1167        let round = polygon_offset_round(&sq, 0.2, 4);
1168        assert!(
1169            round.len() >= miter.len(),
1170            "Round offset should have >= miter points: {} vs {}",
1171            round.len(),
1172            miter.len()
1173        );
1174    }
1175
1176    // 28. ensure_ccw makes CW polygon CCW
1177    #[test]
1178    fn test_ensure_ccw() {
1179        let mut poly = square();
1180        poly.reverse();
1181        assert!(signed_area(&poly) < 0.0);
1182        ensure_ccw(&mut poly);
1183        assert!(signed_area(&poly) > 0.0);
1184    }
1185
1186    // 29. 3-D polygon offset preserves point count
1187    #[test]
1188    fn test_3d_offset_count() {
1189        let verts = vec![
1190            [0.0, 0.0, 0.0],
1191            [1.0, 0.0, 0.0],
1192            [1.0, 1.0, 0.0],
1193            [0.0, 1.0, 0.0],
1194        ];
1195        let offset = polygon_offset_3d(&verts, 0.1, [0.0, 0.0, 1.0]);
1196        assert_eq!(offset.len(), verts.len());
1197    }
1198
1199    // 30. Point2 distance
1200    #[test]
1201    fn test_point2_distance() {
1202        let a = Point2::new(0.0, 0.0);
1203        let b = Point2::new(3.0, 4.0);
1204        assert!((a.distance(&b) - 5.0).abs() < EPS);
1205    }
1206
1207    // 31. Point2 normalized
1208    #[test]
1209    fn test_point2_normalized() {
1210        let p = Point2::new(3.0, 4.0);
1211        let n = p.normalized();
1212        assert!((n.length() - 1.0).abs() < EPS);
1213    }
1214
1215    // 32. Triangle area
1216    #[test]
1217    fn test_triangle_area() {
1218        let tri = triangle();
1219        let a = polygon_area(&tri);
1220        // equilateral-ish triangle with base 2, height ~1.732 => area ~ 1.732
1221        assert!(
1222            a > 1.5 && a < 2.0,
1223            "Triangle area should be ~1.732, got {:.6}",
1224            a
1225        );
1226    }
1227}