Skip to main content

oxiphysics_geometry/
offset_geometry.rs

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