scirs2_spatial/convex_hull/algorithms/
graham_scan.rs

1//! Graham scan algorithm implementation for 2D convex hull computation
2//!
3//! The Graham scan algorithm computes the convex hull of a set of 2D points
4//! by sorting points by polar angle and using a stack-based approach to
5//! eliminate concave points.
6
7use crate::convex_hull::core::ConvexHull;
8use crate::convex_hull::geometry::calculations_2d::{compute_2d_hull_equations, cross_product_2d};
9use crate::error::{SpatialError, SpatialResult};
10use qhull::Qh;
11use scirs2_core::ndarray::ArrayView2;
12
13/// Compute convex hull using Graham scan algorithm (2D only)
14///
15/// The Graham scan algorithm works by:
16/// 1. Finding the bottommost point (lowest y-coordinate, then leftmost x)
17/// 2. Sorting all other points by polar angle with respect to the start point
18/// 3. Using a stack to eliminate points that create clockwise turns
19///
20/// # Arguments
21///
22/// * `points` - Input points (shape: npoints x 2)
23///
24/// # Returns
25///
26/// * Result containing a ConvexHull instance or an error
27///
28/// # Errors
29///
30/// * Returns error if fewer than 3 points are provided
31/// * Only works for 2D points
32///
33/// # Examples
34///
35/// ```rust
36/// use scirs2_spatial::convex_hull::algorithms::graham_scan::compute_graham_scan;
37/// use scirs2_core::ndarray::array;
38///
39/// let points = array![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.5, 0.5]];
40/// let hull = compute_graham_scan(&points.view()).unwrap();
41/// assert_eq!(hull.ndim(), 2);
42/// assert_eq!(hull.vertex_indices().len(), 3); // Excludes interior point
43/// ```
44pub fn compute_graham_scan(points: &ArrayView2<'_, f64>) -> SpatialResult<ConvexHull> {
45    let npoints = points.nrows();
46
47    if points.ncols() != 2 {
48        return Err(SpatialError::ValueError(
49            "Graham scan algorithm only supports 2D points".to_string(),
50        ));
51    }
52
53    if npoints < 3 {
54        return Err(SpatialError::ValueError(
55            "Need at least 3 points for 2D convex hull".to_string(),
56        ));
57    }
58
59    // Convert points to indexed points for sorting
60    let mut indexed_points: Vec<(usize, [f64; 2])> = (0..npoints)
61        .map(|i| (i, [points[[i, 0]], points[[i, 1]]]))
62        .collect();
63
64    // Find the bottom-most point (lowest y-coordinate, then leftmost x)
65    let start_idx = indexed_points
66        .iter()
67        .min_by(|a, b| {
68            let cmp = a.1[1].partial_cmp(&b.1[1]).unwrap();
69            if cmp == std::cmp::Ordering::Equal {
70                a.1[0].partial_cmp(&b.1[0]).unwrap()
71            } else {
72                cmp
73            }
74        })
75        .unwrap()
76        .0;
77
78    let start_point = indexed_points
79        .iter()
80        .find(|(idx, _)| *idx == start_idx)
81        .unwrap()
82        .1;
83
84    // Sort points by polar angle with respect to start point
85    indexed_points.sort_by(|a, b| {
86        if a.0 == start_idx {
87            return std::cmp::Ordering::Less;
88        }
89        if b.0 == start_idx {
90            return std::cmp::Ordering::Greater;
91        }
92
93        let angle_a = (a.1[1] - start_point[1]).atan2(a.1[0] - start_point[0]);
94        let angle_b = (b.1[1] - start_point[1]).atan2(b.1[0] - start_point[0]);
95
96        let angle_cmp = angle_a.partial_cmp(&angle_b).unwrap();
97        if angle_cmp == std::cmp::Ordering::Equal {
98            // If angles are equal, sort by distance
99            let dist_a = (a.1[0] - start_point[0]).powi(2) + (a.1[1] - start_point[1]).powi(2);
100            let dist_b = (b.1[0] - start_point[0]).powi(2) + (b.1[1] - start_point[1]).powi(2);
101            dist_a.partial_cmp(&dist_b).unwrap()
102        } else {
103            angle_cmp
104        }
105    });
106
107    // Graham scan algorithm
108    let mut stack: Vec<usize> = Vec::new();
109
110    for (point_idx, point) in indexed_points {
111        // Remove points from stack while they make a clockwise turn
112        while stack.len() >= 2 {
113            let top = stack[stack.len() - 1];
114            let second = stack[stack.len() - 2];
115
116            let p1 = [points[[second, 0]], points[[second, 1]]];
117            let p2 = [points[[top, 0]], points[[top, 1]]];
118            let p3 = point;
119
120            if cross_product_2d(p1, p2, p3) <= 0.0 {
121                stack.pop();
122            } else {
123                break;
124            }
125        }
126        stack.push(point_idx);
127    }
128
129    let vertex_indices = stack;
130
131    // Create simplices (edges for 2D hull)
132    let n = vertex_indices.len();
133    let mut simplices = Vec::new();
134    for i in 0..n {
135        let j = (i + 1) % n;
136        simplices.push(vec![vertex_indices[i], vertex_indices[j]]);
137    }
138
139    // Create a dummy QHull instance for compatibility
140    let points_vec: Vec<Vec<f64>> = (0..npoints).map(|i| points.row(i).to_vec()).collect();
141    let qh = Qh::builder()
142        .compute(false)
143        .build_from_iter(points_vec)
144        .map_err(|e| SpatialError::ComputationError(format!("Qhull error: {e}")))?;
145
146    // Compute facet equations for 2D hull
147    let equations = compute_2d_hull_equations(points, &vertex_indices);
148
149    Ok(ConvexHull {
150        points: points.to_owned(),
151        qh,
152        vertex_indices,
153        simplices,
154        equations: Some(equations),
155    })
156}
157
158/// Find the starting point for Graham scan
159///
160/// Finds the bottommost point (lowest y-coordinate), and in case of ties,
161/// the leftmost point among those with the lowest y-coordinate.
162///
163/// # Arguments
164///
165/// * `points` - Input points array
166///
167/// # Returns
168///
169/// * Index of the starting point
170///
171/// # Examples
172///
173/// ```rust
174/// use scirs2_spatial::convex_hull::algorithms::graham_scan::find_start_point;
175/// use scirs2_core::ndarray::array;
176///
177/// let points = array![[1.0, 1.0], [0.0, 0.0], [2.0, 0.0], [0.0, 1.0]];
178/// let start_idx = find_start_point(&points.view());
179/// assert_eq!(start_idx, 1); // Point [0.0, 0.0]
180/// ```
181pub fn find_start_point(points: &ArrayView2<'_, f64>) -> usize {
182    let npoints = points.nrows();
183    let mut start_idx = 0;
184
185    for i in 1..npoints {
186        let current_y = points[[i, 1]];
187        let start_y = points[[start_idx, 1]];
188
189        if current_y < start_y || (current_y == start_y && points[[i, 0]] < points[[start_idx, 0]])
190        {
191            start_idx = i;
192        }
193    }
194
195    start_idx
196}
197
198/// Sort points by polar angle from a reference point
199///
200/// # Arguments
201///
202/// * `points` - Input points array
203/// * `reference_point` - Reference point for angle calculation
204///
205/// # Returns
206///
207/// * Vector of point indices sorted by polar angle
208///
209/// # Examples
210///
211/// ```rust
212/// use scirs2_spatial::convex_hull::algorithms::graham_scan::sort_by_polar_angle;
213/// use scirs2_core::ndarray::array;
214///
215/// let points = array![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
216/// let reference = [0.0, 0.0];
217/// let sorted_indices = sort_by_polar_angle(&points.view(), reference);
218/// assert_eq!(sorted_indices.len(), 4);
219/// ```
220pub fn sort_by_polar_angle(points: &ArrayView2<'_, f64>, reference_point: [f64; 2]) -> Vec<usize> {
221    let npoints = points.nrows();
222    let mut indexed_points: Vec<(usize, f64, f64)> = Vec::new();
223
224    for i in 0..npoints {
225        let point = [points[[i, 0]], points[[i, 1]]];
226        let angle = (point[1] - reference_point[1]).atan2(point[0] - reference_point[0]);
227        let distance_sq =
228            (point[0] - reference_point[0]).powi(2) + (point[1] - reference_point[1]).powi(2);
229        indexed_points.push((i, angle, distance_sq));
230    }
231
232    // Sort by angle, then by distance for points with the same angle
233    indexed_points.sort_by(|a, b| {
234        let angle_cmp = a.1.partial_cmp(&b.1).unwrap();
235        if angle_cmp == std::cmp::Ordering::Equal {
236            a.2.partial_cmp(&b.2).unwrap()
237        } else {
238            angle_cmp
239        }
240    });
241
242    indexed_points.into_iter().map(|(idx, _, _)| idx).collect()
243}
244
245/// Check if three points make a counterclockwise turn
246///
247/// # Arguments
248///
249/// * `p1` - First point [x, y]
250/// * `p2` - Second point [x, y]
251/// * `p3` - Third point [x, y]
252///
253/// # Returns
254///
255/// * true if the turn is counterclockwise, false otherwise
256///
257/// # Examples
258///
259/// ```rust
260/// use scirs2_spatial::convex_hull::algorithms::graham_scan::is_ccw_turn;
261///
262/// let p1 = [0.0, 0.0];
263/// let p2 = [1.0, 0.0];
264/// let p3 = [0.0, 1.0];
265///
266/// assert!(is_ccw_turn(p1, p2, p3));
267/// ```
268pub fn is_ccw_turn(p1: [f64; 2], p2: [f64; 2], p3: [f64; 2]) -> bool {
269    cross_product_2d(p1, p2, p3) > 0.0
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use scirs2_core::ndarray::arr2;
276
277    #[test]
278    fn test_graham_scan_basic() {
279        let points = arr2(&[
280            [0.0, 0.0],
281            [1.0, 0.0],
282            [0.0, 1.0],
283            [0.5, 0.5], // Interior point
284        ]);
285
286        let hull = compute_graham_scan(&points.view()).unwrap();
287
288        // Check dimensions
289        assert_eq!(hull.ndim(), 2);
290
291        // The interior point should not be part of the convex hull
292        assert_eq!(hull.vertex_indices().len(), 3);
293
294        // Verify that the interior point is not in the hull
295        assert!(!hull.vertex_indices().contains(&3));
296    }
297
298    #[test]
299    fn test_graham_scan_square() {
300        let points = arr2(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]);
301
302        let hull = compute_graham_scan(&points.view()).unwrap();
303
304        assert_eq!(hull.ndim(), 2);
305        assert_eq!(hull.vertex_indices().len(), 4); // All points should be vertices
306    }
307
308    #[test]
309    fn test_find_start_point() {
310        let points = arr2(&[[1.0, 1.0], [0.0, 0.0], [2.0, 0.0], [0.0, 1.0]]);
311        let start_idx = find_start_point(&points.view());
312        assert_eq!(start_idx, 1); // Point [0.0, 0.0]
313    }
314
315    #[test]
316    fn test_sort_by_polar_angle() {
317        let points = arr2(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]);
318        let reference = [0.0, 0.0];
319        let sorted_indices = sort_by_polar_angle(&points.view(), reference);
320
321        assert_eq!(sorted_indices.len(), 4);
322        assert_eq!(sorted_indices[0], 0); // Reference point itself
323    }
324
325    #[test]
326    fn test_is_ccw_turn() {
327        let p1 = [0.0, 0.0];
328        let p2 = [1.0, 0.0];
329        let p3 = [0.0, 1.0];
330
331        assert!(is_ccw_turn(p1, p2, p3)); // Counterclockwise
332        assert!(!is_ccw_turn(p1, p3, p2)); // Clockwise
333    }
334
335    #[test]
336    fn test_error_cases() {
337        // Test with too few points
338        let too_few = arr2(&[[0.0, 0.0], [1.0, 0.0]]);
339        let result = compute_graham_scan(&too_few.view());
340        assert!(result.is_err());
341
342        // Test with 3D points (should fail)
343        let points_3d = arr2(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
344        let result = compute_graham_scan(&points_3d.view());
345        assert!(result.is_err());
346    }
347
348    #[test]
349    fn test_collinear_points() {
350        // Test with collinear points
351        let points = arr2(&[
352            [0.0, 0.0],
353            [1.0, 0.0],
354            [2.0, 0.0],
355            [0.5, 1.0], // Point above the line
356        ]);
357
358        let hull = compute_graham_scan(&points.view()).unwrap();
359
360        // Should form a triangle with the three non-collinear points
361        assert_eq!(hull.vertex_indices().len(), 3);
362    }
363}