use crate::core::{IgraphError, IgraphResult};
#[derive(Debug, Clone, PartialEq)]
pub struct ConvexHullResult {
pub hull_vertices: Vec<usize>,
pub hull_coords: Vec<(f64, f64)>,
}
pub fn convex_hull_2d(points: &[(f64, f64)]) -> IgraphResult<ConvexHullResult> {
let n = points.len();
if n == 0 {
return Ok(ConvexHullResult {
hull_vertices: Vec::new(),
hull_coords: Vec::new(),
});
}
for (i, &(x, y)) in points.iter().enumerate() {
if x.is_nan() || y.is_nan() {
return Err(IgraphError::InvalidArgument(format!(
"convex_hull_2d: NaN coordinate at index {i}"
)));
}
}
if n <= 2 {
let hull_vertices: Vec<usize> = (0..n).collect();
let hull_coords: Vec<(f64, f64)> = points.to_vec();
return Ok(ConvexHullResult {
hull_vertices,
hull_coords,
});
}
let mut pivot_idx: usize = 0;
for i in 1..n {
let cmp_y = points[i].1.total_cmp(&points[pivot_idx].1);
let cmp_x = points[i].0.total_cmp(&points[pivot_idx].0);
if cmp_y == std::cmp::Ordering::Less
|| (cmp_y == std::cmp::Ordering::Equal && cmp_x == std::cmp::Ordering::Less)
{
pivot_idx = i;
}
}
let px = points[pivot_idx].0;
let py = points[pivot_idx].1;
let mut angles: Vec<f64> = Vec::with_capacity(n);
for (i, &(x, y)) in points.iter().enumerate() {
if i == pivot_idx {
angles.push(f64::NEG_INFINITY);
} else {
angles.push(f64::atan2(y - py, x - px));
}
}
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| {
angles[a].total_cmp(&angles[b]).then_with(|| {
let da = dist_sq(px, py, points[a].0, points[a].1);
let db = dist_sq(px, py, points[b].0, points[b].1);
da.total_cmp(&db)
})
});
let mut filtered: Vec<usize> = Vec::with_capacity(n);
let mut i = 0;
while i < order.len() {
let mut j = i;
while j + 1 < order.len()
&& angles[order[j + 1]].total_cmp(&angles[order[i]]) == std::cmp::Ordering::Equal
{
j += 1;
}
filtered.push(order[j]);
i = j + 1;
}
if filtered.len() <= 2 {
let hull_vertices = filtered;
let hull_coords: Vec<(f64, f64)> = hull_vertices.iter().map(|&i| points[i]).collect();
return Ok(ConvexHullResult {
hull_vertices,
hull_coords,
});
}
let mut stack: Vec<usize> = Vec::with_capacity(filtered.len());
stack.push(filtered[0]);
stack.push(filtered[1]);
for &idx in filtered.iter().skip(2) {
while stack.len() >= 2 {
let top = stack[stack.len() - 1];
let below = stack[stack.len() - 2];
let cp = cross_product(points[below], points[top], points[idx]);
if cp > 0.0 {
break;
}
stack.pop();
}
stack.push(idx);
}
let hull_coords: Vec<(f64, f64)> = stack.iter().map(|&i| points[i]).collect();
Ok(ConvexHullResult {
hull_vertices: stack,
hull_coords,
})
}
fn dist_sq(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
(x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)
}
fn cross_product(o: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
(a.0 - o.0) * (b.1 - o.1) - (b.0 - o.0) * (a.1 - o.1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty() {
let r = convex_hull_2d(&[]).unwrap();
assert!(r.hull_vertices.is_empty());
assert!(r.hull_coords.is_empty());
}
#[test]
fn single_point() {
let r = convex_hull_2d(&[(1.0, 2.0)]).unwrap();
assert_eq!(r.hull_vertices, vec![0]);
assert_eq!(r.hull_coords, vec![(1.0, 2.0)]);
}
#[test]
fn two_points() {
let r = convex_hull_2d(&[(0.0, 0.0), (1.0, 1.0)]).unwrap();
assert_eq!(r.hull_vertices.len(), 2);
}
#[test]
fn triangle() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)];
let r = convex_hull_2d(&pts).unwrap();
assert_eq!(r.hull_vertices.len(), 3);
let mut sorted = r.hull_vertices.clone();
sorted.sort_unstable();
assert_eq!(sorted, vec![0, 1, 2]);
}
#[test]
fn square_with_interior() {
let pts = vec![
(0.0, 0.0),
(1.0, 0.0),
(1.0, 1.0),
(0.0, 1.0),
(0.5, 0.5), ];
let r = convex_hull_2d(&pts).unwrap();
assert_eq!(r.hull_vertices.len(), 4);
assert!(!r.hull_vertices.contains(&4));
}
#[test]
fn collinear_points() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (3.0, 0.0)];
let r = convex_hull_2d(&pts).unwrap();
assert_eq!(r.hull_vertices.len(), 2);
let mut sorted = r.hull_vertices.clone();
sorted.sort_unstable();
assert_eq!(sorted, vec![0, 3]);
}
#[test]
fn regular_hexagon() {
let pts: Vec<(f64, f64)> = (0..6)
.map(|i| {
let angle = std::f64::consts::PI / 3.0 * f64::from(i);
(angle.cos(), angle.sin())
})
.collect();
let r = convex_hull_2d(&pts).unwrap();
assert_eq!(r.hull_vertices.len(), 6);
}
#[test]
fn nan_coordinate_error() {
let pts = vec![(0.0, 0.0), (f64::NAN, 1.0)];
assert!(convex_hull_2d(&pts).is_err());
}
#[test]
fn duplicate_points() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)];
let r = convex_hull_2d(&pts).unwrap();
assert_eq!(r.hull_vertices.len(), 3);
}
#[test]
fn large_random_circle() {
let n: u32 = 20;
let pts: Vec<(f64, f64)> = (0..n)
.map(|i| {
let angle = 2.0 * std::f64::consts::PI * f64::from(i) / f64::from(n);
(angle.cos(), angle.sin())
})
.collect();
let r = convex_hull_2d(&pts).unwrap();
assert_eq!(r.hull_vertices.len(), n as usize);
}
#[test]
fn hull_is_ccw() {
let pts = vec![(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (1.0, 1.0)];
let r = convex_hull_2d(&pts).unwrap();
let hull = &r.hull_coords;
let len = hull.len();
for i in 0..len {
let cp = cross_product(hull[i], hull[(i + 1) % len], hull[(i + 2) % len]);
assert!(
cp >= 0.0,
"hull is not CCW at index {i}: cross product = {cp}"
);
}
}
}