Skip to main content

geometry_algorithm/
concave_hull.rs

1//! Concave hulls derived from a planar point set.
2//!
3//! Boost.Geometry has no concave-hull algorithm. The edge-refinement shape is
4//! based on Park & Oh (2012), while the `k`-nearest entry exposes the candidate
5//! breadth described by Moreira & Santos (2007). Both variants begin with the
6//! existing convex hull and preserve a simple clockwise boundary.
7
8use alloc::vec::Vec;
9
10#[cfg(not(feature = "std"))]
11use geometry_coords::math::Float;
12use geometry_coords::precise_math;
13use geometry_model::{Polygon, Ring};
14use geometry_strategy::{CollectPoints, ConvexHullStrategy, MonotoneChain};
15use geometry_trait::{Point, PointMut};
16
17use crate::convex_hull::convex_hull;
18
19/// Parameters controlling edge-refinement concave hulls.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct ConcaveHullParams {
22    /// Maximum ratio `(a→candidate + candidate→b) / a→b` accepted for an edge.
23    /// Values below `1` are clamped to `1` at the algorithm boundary.
24    pub concavity: f64,
25    /// Edges no longer than this threshold are left unchanged.
26    pub length_threshold: f64,
27}
28
29impl Default for ConcaveHullParams {
30    fn default() -> Self {
31        Self {
32            concavity: 2.0,
33            length_threshold: 0.0,
34        }
35    }
36}
37
38/// Construct a concave hull with [`ConcaveHullParams::default`].
39#[inline]
40#[must_use]
41pub fn concave_hull<G, P>(geometry: &G) -> Polygon<P>
42where
43    G: CollectPoints<Point = P>,
44    P: Point<Scalar = f64> + PointMut + Default + Copy,
45    MonotoneChain: ConvexHullStrategy<G, Output = Ring<P, true, true>>,
46{
47    concave_hull_with(geometry, ConcaveHullParams::default())
48}
49
50/// Construct a concave hull using explicit edge-refinement parameters.
51#[inline]
52#[must_use]
53pub fn concave_hull_with<G, P>(geometry: &G, parameters: ConcaveHullParams) -> Polygon<P>
54where
55    G: CollectPoints<Point = P>,
56    P: Point<Scalar = f64> + PointMut + Default + Copy,
57    MonotoneChain: ConvexHullStrategy<G, Output = Ring<P, true, true>>,
58{
59    refine_hull(geometry, parameters, None)
60}
61
62/// Construct a concave hull while considering at most `k` nearest candidates
63/// for each boundary edge.
64///
65/// Candidate distance is measured from the edge midpoint. `k == 0` leaves the
66/// convex hull unchanged; larger values admit progressively more refinements.
67#[inline]
68#[must_use]
69pub fn k_nearest_concave_hull<G, P>(geometry: &G, k: usize) -> Polygon<P>
70where
71    G: CollectPoints<Point = P>,
72    P: Point<Scalar = f64> + PointMut + Default + Copy,
73    MonotoneChain: ConvexHullStrategy<G, Output = Ring<P, true, true>>,
74{
75    if k == 0 {
76        return Polygon::new(convex_hull(geometry));
77    }
78    refine_hull(
79        geometry,
80        ConcaveHullParams {
81            concavity: f64::INFINITY,
82            length_threshold: 0.0,
83        },
84        Some(k),
85    )
86}
87
88fn refine_hull<G, P>(
89    geometry: &G,
90    parameters: ConcaveHullParams,
91    nearest_limit: Option<usize>,
92) -> Polygon<P>
93where
94    G: CollectPoints<Point = P>,
95    P: Point<Scalar = f64> + PointMut + Default + Copy,
96    MonotoneChain: ConvexHullStrategy<G, Output = Ring<P, true, true>>,
97{
98    let mut all_points = Vec::new();
99    geometry.collect_points(&mut all_points);
100    deduplicate(&mut all_points);
101
102    let mut boundary = convex_hull(geometry).0;
103    while boundary.len() > 1 && same_xy(boundary.first(), boundary.last()) {
104        boundary.pop();
105    }
106    if boundary.len() < 3 {
107        close(&mut boundary);
108        return Polygon::new(Ring::from_vec(boundary));
109    }
110
111    let mut candidates: Vec<P> = all_points
112        .into_iter()
113        .filter(|point| !boundary.iter().any(|hull| same_point(point, hull)))
114        .collect();
115    let concavity = parameters.concavity.max(1.0);
116    let length_threshold = parameters.length_threshold.max(0.0);
117
118    while !candidates.is_empty() {
119        let mut best: Option<Insertion> = None;
120        for edge in 0..boundary.len() {
121            let first = boundary[edge];
122            let second = boundary[(edge + 1) % boundary.len()];
123            let edge_length = distance(first, second);
124            if edge_length <= length_threshold.max(f64::EPSILON) {
125                continue;
126            }
127
128            let mut candidate_indices: Vec<usize> = (0..candidates.len()).collect();
129            candidate_indices.sort_by(|&left, &right| {
130                midpoint_distance(first, second, candidates[left]).total_cmp(&midpoint_distance(
131                    first,
132                    second,
133                    candidates[right],
134                ))
135            });
136            if let Some(limit) = nearest_limit {
137                candidate_indices.truncate(limit.min(candidate_indices.len()));
138            }
139
140            for candidate_index in candidate_indices {
141                let candidate = candidates[candidate_index];
142                let detour =
143                    (distance(first, candidate) + distance(candidate, second)) / edge_length;
144                if detour > concavity
145                    || point_segment_distance(candidate, first, second) <= f64::EPSILON
146                    || !insertion_is_simple(&boundary, edge, candidate)
147                {
148                    continue;
149                }
150                let score = point_segment_distance(candidate, first, second);
151                let insertion = Insertion {
152                    edge,
153                    candidate: candidate_index,
154                    score,
155                };
156                if best.is_none_or(|current| insertion.score < current.score) {
157                    best = Some(insertion);
158                }
159            }
160        }
161
162        let Some(insertion) = best else {
163            break;
164        };
165        let point = candidates.swap_remove(insertion.candidate);
166        boundary.insert(insertion.edge + 1, point);
167    }
168
169    close(&mut boundary);
170    Polygon::new(Ring::from_vec(boundary))
171}
172
173#[derive(Clone, Copy)]
174struct Insertion {
175    edge: usize,
176    candidate: usize,
177    score: f64,
178}
179
180fn deduplicate<P: Point<Scalar = f64> + Copy>(points: &mut Vec<P>) {
181    let mut unique = Vec::with_capacity(points.len());
182    for point in points.iter().copied() {
183        if !unique.iter().any(|other| same_point(&point, other)) {
184            unique.push(point);
185        }
186    }
187    *points = unique;
188}
189
190fn close<P: Copy>(points: &mut Vec<P>) {
191    if let Some(first) = points.first().copied() {
192        points.push(first);
193    }
194}
195
196fn insertion_is_simple<P>(boundary: &[P], edge: usize, candidate: P) -> bool
197where
198    P: Point<Scalar = f64> + Copy,
199{
200    let first = boundary[edge];
201    let second_index = (edge + 1) % boundary.len();
202    let second = boundary[second_index];
203    for other_edge in 0..boundary.len() {
204        if other_edge == edge {
205            continue;
206        }
207        let other_first_index = other_edge;
208        let other_second_index = (other_edge + 1) % boundary.len();
209        let other_first = boundary[other_first_index];
210        let other_second = boundary[other_second_index];
211
212        let first_segment_shares_endpoint = other_first_index == edge || other_second_index == edge;
213        if !first_segment_shares_endpoint
214            && segments_intersect(first, candidate, other_first, other_second)
215        {
216            return false;
217        }
218        let second_segment_shares_endpoint =
219            other_first_index == second_index || other_second_index == second_index;
220        if !second_segment_shares_endpoint
221            && segments_intersect(candidate, second, other_first, other_second)
222        {
223            return false;
224        }
225    }
226    true
227}
228
229fn segments_intersect<P>(a: P, b: P, c: P, d: P) -> bool
230where
231    P: Point<Scalar = f64> + Copy,
232{
233    let ab_c = orientation(a, b, c);
234    let ab_d = orientation(a, b, d);
235    let cd_a = orientation(c, d, a);
236    let cd_b = orientation(c, d, b);
237    if ab_c == 0.0 && on_segment(a, b, c) {
238        return true;
239    }
240    if ab_d == 0.0 && on_segment(a, b, d) {
241        return true;
242    }
243    if cd_a == 0.0 && on_segment(c, d, a) {
244        return true;
245    }
246    if cd_b == 0.0 && on_segment(c, d, b) {
247        return true;
248    }
249    (ab_c > 0.0) != (ab_d > 0.0) && (cd_a > 0.0) != (cd_b > 0.0)
250}
251
252#[allow(
253    clippy::needless_pass_by_value,
254    reason = "the hull operates on Copy point handles throughout"
255)]
256fn orientation<P: Point<Scalar = f64>>(first: P, second: P, third: P) -> f64 {
257    precise_math::orient2d(
258        [first.get::<0>(), first.get::<1>()],
259        [second.get::<0>(), second.get::<1>()],
260        [third.get::<0>(), third.get::<1>()],
261    )
262}
263
264#[allow(
265    clippy::needless_pass_by_value,
266    reason = "the hull operates on Copy point handles throughout"
267)]
268fn on_segment<P: Point<Scalar = f64>>(first: P, second: P, point: P) -> bool {
269    point.get::<0>() >= first.get::<0>().min(second.get::<0>())
270        && point.get::<0>() <= first.get::<0>().max(second.get::<0>())
271        && point.get::<1>() >= first.get::<1>().min(second.get::<1>())
272        && point.get::<1>() <= first.get::<1>().max(second.get::<1>())
273}
274
275#[allow(
276    clippy::needless_pass_by_value,
277    reason = "the hull operates on Copy point handles throughout"
278)]
279fn midpoint_distance<P: Point<Scalar = f64>>(first: P, second: P, point: P) -> f64 {
280    let x = first.get::<0>() / 2.0 + second.get::<0>() / 2.0 - point.get::<0>();
281    let y = first.get::<1>() / 2.0 + second.get::<1>() / 2.0 - point.get::<1>();
282    x.hypot(y)
283}
284
285#[allow(
286    clippy::needless_pass_by_value,
287    reason = "the hull operates on Copy point handles throughout"
288)]
289fn point_segment_distance<P: Point<Scalar = f64>>(point: P, first: P, second: P) -> f64 {
290    let dx = second.get::<0>() - first.get::<0>();
291    let dy = second.get::<1>() - first.get::<1>();
292    let length_squared = dx * dx + dy * dy;
293    if length_squared <= f64::EPSILON {
294        return distance(point, first);
295    }
296    let projection = ((point.get::<0>() - first.get::<0>()) * dx
297        + (point.get::<1>() - first.get::<1>()) * dy)
298        / length_squared;
299    let projection = projection.clamp(0.0, 1.0);
300    let x = first.get::<0>() + projection * dx;
301    let y = first.get::<1>() + projection * dy;
302    (point.get::<0>() - x).hypot(point.get::<1>() - y)
303}
304
305#[allow(
306    clippy::needless_pass_by_value,
307    reason = "the hull operates on Copy point handles throughout"
308)]
309fn distance<P: Point<Scalar = f64>>(first: P, second: P) -> f64 {
310    (second.get::<0>() - first.get::<0>()).hypot(second.get::<1>() - first.get::<1>())
311}
312
313fn same_xy<P: Point<Scalar = f64>>(first: Option<&P>, second: Option<&P>) -> bool {
314    first
315        .zip(second)
316        .is_some_and(|(first, second)| same_point(first, second))
317}
318
319#[allow(
320    clippy::float_cmp,
321    reason = "coordinate identity, not approximate geometric equality, is required"
322)]
323fn same_point<P: Point<Scalar = f64>>(first: &P, second: &P) -> bool {
324    first.get::<0>() == second.get::<0>() && first.get::<1>() == second.get::<1>()
325}
326
327#[cfg(test)]
328mod tests {
329    use geometry_cs::Cartesian;
330    use geometry_model::{MultiPoint, Point2D};
331
332    use super::*;
333    use crate::area::area;
334
335    #[test]
336    fn square_digs_toward_an_interior_point() {
337        type P = Point2D<f64, Cartesian>;
338        let points = MultiPoint::from_vec(alloc::vec![
339            P::new(0.0, 0.0),
340            P::new(0.0, 4.0),
341            P::new(4.0, 4.0),
342            P::new(4.0, 0.0),
343            P::new(2.0, 1.0),
344        ]);
345        let hull = concave_hull_with(
346            &points,
347            ConcaveHullParams {
348                concavity: 1.2,
349                length_threshold: 0.0,
350            },
351        );
352        assert!(hull.outer.0.contains(&P::new(2.0, 1.0)));
353        assert!(area(&hull).abs() < 16.0);
354    }
355
356    #[test]
357    fn private_intersection_guards_cover_invalid_insertions() {
358        type P = Point2D<f64, Cartesian>;
359        let boundary = [
360            P::new(0.0, 0.0),
361            P::new(0.0, 4.0),
362            P::new(4.0, 4.0),
363            P::new(4.0, 0.0),
364        ];
365        assert!(!insertion_is_simple(&boundary, 0, P::new(5.0, 2.0)));
366        assert!(!insertion_is_simple(&boundary, 0, P::new(5.0, -1.0)));
367
368        assert!(segments_intersect(
369            P::new(0.0, 0.0),
370            P::new(2.0, 0.0),
371            P::new(1.0, 0.0),
372            P::new(1.0, 1.0),
373        ));
374        assert!(segments_intersect(
375            P::new(0.0, 0.0),
376            P::new(2.0, 0.0),
377            P::new(1.0, 1.0),
378            P::new(1.0, 0.0),
379        ));
380        assert!(segments_intersect(
381            P::new(1.0, 0.0),
382            P::new(1.0, 1.0),
383            P::new(0.0, 0.0),
384            P::new(2.0, 0.0),
385        ));
386        assert!(segments_intersect(
387            P::new(1.0, 1.0),
388            P::new(1.0, 0.0),
389            P::new(0.0, 0.0),
390            P::new(2.0, 0.0),
391        ));
392        let distance = point_segment_distance(P::new(3.0, 4.0), P::new(0.0, 0.0), P::new(0.0, 0.0));
393        assert!((distance - 5.0).abs() < f64::EPSILON);
394    }
395}