Skip to main content

rust_igraph/algorithms/spatial/
convex_hull.rs

1//! 2D convex hull via Graham scan (ALGO-GEO-001).
2//!
3//! Counterpart of `igraph_convex_hull_2d()` from
4//! `references/igraph/src/spatial/convex_hull.c`.
5//!
6//! Computes the convex hull of a set of 2D points using the Graham
7//! scan algorithm (Cormen et al., Introduction to Algorithms, §33.3).
8//! Returns the vertex indices of the hull in counter-clockwise order.
9
10use crate::core::{IgraphError, IgraphResult};
11
12/// Result of [`convex_hull_2d`].
13#[derive(Debug, Clone, PartialEq)]
14pub struct ConvexHullResult {
15    /// Indices of the input points that form the convex hull,
16    /// in counter-clockwise order starting from the bottom-most
17    /// (then left-most) point.
18    pub hull_vertices: Vec<usize>,
19    /// Coordinates of the hull vertices in the same order.
20    pub hull_coords: Vec<(f64, f64)>,
21}
22
23/// Compute the convex hull of a set of 2D points.
24///
25/// Uses the Graham scan algorithm. The input is a slice of `(x, y)`
26/// coordinate pairs. Returns the indices and coordinates of the hull
27/// vertices in counter-clockwise order.
28///
29/// # Errors
30///
31/// - `InvalidArgument` if any coordinate is NaN.
32///
33/// # Examples
34///
35/// ```
36/// use rust_igraph::convex_hull_2d;
37///
38/// let points = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.5, 0.5)];
39/// let hull = convex_hull_2d(&points).unwrap();
40/// // The interior point (0.5, 0.5) is not on the hull.
41/// assert_eq!(hull.hull_vertices.len(), 3);
42/// ```
43pub fn convex_hull_2d(points: &[(f64, f64)]) -> IgraphResult<ConvexHullResult> {
44    let n = points.len();
45
46    if n == 0 {
47        return Ok(ConvexHullResult {
48            hull_vertices: Vec::new(),
49            hull_coords: Vec::new(),
50        });
51    }
52
53    for (i, &(x, y)) in points.iter().enumerate() {
54        if x.is_nan() || y.is_nan() {
55            return Err(IgraphError::InvalidArgument(format!(
56                "convex_hull_2d: NaN coordinate at index {i}"
57            )));
58        }
59    }
60
61    if n <= 2 {
62        let hull_vertices: Vec<usize> = (0..n).collect();
63        let hull_coords: Vec<(f64, f64)> = points.to_vec();
64        return Ok(ConvexHullResult {
65            hull_vertices,
66            hull_coords,
67        });
68    }
69
70    // Find pivot: lowest y, then leftmost x as tiebreaker.
71    let mut pivot_idx: usize = 0;
72    for i in 1..n {
73        let cmp_y = points[i].1.total_cmp(&points[pivot_idx].1);
74        let cmp_x = points[i].0.total_cmp(&points[pivot_idx].0);
75        if cmp_y == std::cmp::Ordering::Less
76            || (cmp_y == std::cmp::Ordering::Equal && cmp_x == std::cmp::Ordering::Less)
77        {
78            pivot_idx = i;
79        }
80    }
81
82    let px = points[pivot_idx].0;
83    let py = points[pivot_idx].1;
84
85    // Compute polar angles relative to pivot.
86    let mut angles: Vec<f64> = Vec::with_capacity(n);
87    for (i, &(x, y)) in points.iter().enumerate() {
88        if i == pivot_idx {
89            angles.push(f64::NEG_INFINITY);
90        } else {
91            angles.push(f64::atan2(y - py, x - px));
92        }
93    }
94
95    // Sort indices by angle; for same angle, keep farthest from pivot.
96    let mut order: Vec<usize> = (0..n).collect();
97    order.sort_by(|&a, &b| {
98        angles[a].total_cmp(&angles[b]).then_with(|| {
99            let da = dist_sq(px, py, points[a].0, points[a].1);
100            let db = dist_sq(px, py, points[b].0, points[b].1);
101            da.total_cmp(&db)
102        })
103    });
104
105    // Remove collinear points with same angle — keep only the farthest.
106    let mut filtered: Vec<usize> = Vec::with_capacity(n);
107    let mut i = 0;
108    while i < order.len() {
109        let mut j = i;
110        while j + 1 < order.len()
111            && angles[order[j + 1]].total_cmp(&angles[order[i]]) == std::cmp::Ordering::Equal
112        {
113            j += 1;
114        }
115        // Among order[i..=j] with same angle, keep the last (farthest due to sort).
116        filtered.push(order[j]);
117        i = j + 1;
118    }
119
120    if filtered.len() <= 2 {
121        let hull_vertices = filtered;
122        let hull_coords: Vec<(f64, f64)> = hull_vertices.iter().map(|&i| points[i]).collect();
123        return Ok(ConvexHullResult {
124            hull_vertices,
125            hull_coords,
126        });
127    }
128
129    // Graham scan.
130    let mut stack: Vec<usize> = Vec::with_capacity(filtered.len());
131    stack.push(filtered[0]);
132    stack.push(filtered[1]);
133
134    for &idx in filtered.iter().skip(2) {
135        while stack.len() >= 2 {
136            let top = stack[stack.len() - 1];
137            let below = stack[stack.len() - 2];
138            let cp = cross_product(points[below], points[top], points[idx]);
139            if cp > 0.0 {
140                break;
141            }
142            stack.pop();
143        }
144        stack.push(idx);
145    }
146
147    let hull_coords: Vec<(f64, f64)> = stack.iter().map(|&i| points[i]).collect();
148    Ok(ConvexHullResult {
149        hull_vertices: stack,
150        hull_coords,
151    })
152}
153
154fn dist_sq(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
155    (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)
156}
157
158fn cross_product(o: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
159    (a.0 - o.0) * (b.1 - o.1) - (b.0 - o.0) * (a.1 - o.1)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn empty() {
168        let r = convex_hull_2d(&[]).unwrap();
169        assert!(r.hull_vertices.is_empty());
170        assert!(r.hull_coords.is_empty());
171    }
172
173    #[test]
174    fn single_point() {
175        let r = convex_hull_2d(&[(1.0, 2.0)]).unwrap();
176        assert_eq!(r.hull_vertices, vec![0]);
177        assert_eq!(r.hull_coords, vec![(1.0, 2.0)]);
178    }
179
180    #[test]
181    fn two_points() {
182        let r = convex_hull_2d(&[(0.0, 0.0), (1.0, 1.0)]).unwrap();
183        assert_eq!(r.hull_vertices.len(), 2);
184    }
185
186    #[test]
187    fn triangle() {
188        let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)];
189        let r = convex_hull_2d(&pts).unwrap();
190        assert_eq!(r.hull_vertices.len(), 3);
191        let mut sorted = r.hull_vertices.clone();
192        sorted.sort_unstable();
193        assert_eq!(sorted, vec![0, 1, 2]);
194    }
195
196    #[test]
197    fn square_with_interior() {
198        let pts = vec![
199            (0.0, 0.0),
200            (1.0, 0.0),
201            (1.0, 1.0),
202            (0.0, 1.0),
203            (0.5, 0.5), // interior
204        ];
205        let r = convex_hull_2d(&pts).unwrap();
206        assert_eq!(r.hull_vertices.len(), 4);
207        assert!(!r.hull_vertices.contains(&4));
208    }
209
210    #[test]
211    fn collinear_points() {
212        let pts = vec![(0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (3.0, 0.0)];
213        let r = convex_hull_2d(&pts).unwrap();
214        // Collinear points: hull is just the two endpoints.
215        assert_eq!(r.hull_vertices.len(), 2);
216        let mut sorted = r.hull_vertices.clone();
217        sorted.sort_unstable();
218        assert_eq!(sorted, vec![0, 3]);
219    }
220
221    #[test]
222    fn regular_hexagon() {
223        let pts: Vec<(f64, f64)> = (0..6)
224            .map(|i| {
225                let angle = std::f64::consts::PI / 3.0 * f64::from(i);
226                (angle.cos(), angle.sin())
227            })
228            .collect();
229        let r = convex_hull_2d(&pts).unwrap();
230        assert_eq!(r.hull_vertices.len(), 6);
231    }
232
233    #[test]
234    fn nan_coordinate_error() {
235        let pts = vec![(0.0, 0.0), (f64::NAN, 1.0)];
236        assert!(convex_hull_2d(&pts).is_err());
237    }
238
239    #[test]
240    fn duplicate_points() {
241        let pts = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)];
242        let r = convex_hull_2d(&pts).unwrap();
243        assert_eq!(r.hull_vertices.len(), 3);
244    }
245
246    #[test]
247    fn large_random_circle() {
248        // Points on a unit circle should all be on the hull.
249        let n: u32 = 20;
250        let pts: Vec<(f64, f64)> = (0..n)
251            .map(|i| {
252                let angle = 2.0 * std::f64::consts::PI * f64::from(i) / f64::from(n);
253                (angle.cos(), angle.sin())
254            })
255            .collect();
256        let r = convex_hull_2d(&pts).unwrap();
257        assert_eq!(r.hull_vertices.len(), n as usize);
258    }
259
260    #[test]
261    fn hull_is_ccw() {
262        // Verify the hull is in counter-clockwise order.
263        let pts = vec![(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (1.0, 1.0)];
264        let r = convex_hull_2d(&pts).unwrap();
265        // Check that all consecutive triples make left turns (positive cross product).
266        let hull = &r.hull_coords;
267        let len = hull.len();
268        for i in 0..len {
269            let cp = cross_product(hull[i], hull[(i + 1) % len], hull[(i + 2) % len]);
270            assert!(
271                cp >= 0.0,
272                "hull is not CCW at index {i}: cross product = {cp}"
273            );
274        }
275    }
276}