Skip to main content

scirs2_spatial/computational_geometry/
incremental_hull_3d.rs

1//! Incremental 3D convex hull algorithm
2//!
3//! This module implements an incremental convex hull algorithm for 3D point sets
4//! using a Doubly-Connected Edge List (DCEL) / half-edge data structure.
5//!
6//! # Algorithm
7//!
8//! The incremental algorithm builds the convex hull by adding points one at a time:
9//! 1. Start with an initial tetrahedron from 4 non-coplanar points
10//! 2. For each remaining point:
11//!    a. If the point is inside the hull, skip it
12//!    b. Find all faces visible from the point (horizon detection)
13//!    c. Remove visible faces and create new faces connecting the point to the horizon edges
14//!
15//! Time complexity: O(n^2) worst case, O(n log n) expected.
16//!
17//! # Examples
18//!
19//! ```
20//! use scirs2_spatial::computational_geometry::incremental_hull_3d::IncrementalHull3D;
21//!
22//! let points = vec![
23//!     [0.0, 0.0, 0.0],
24//!     [1.0, 0.0, 0.0],
25//!     [0.0, 1.0, 0.0],
26//!     [0.0, 0.0, 1.0],
27//!     [0.1, 0.1, 0.1], // interior point
28//! ];
29//!
30//! let hull = IncrementalHull3D::new(&points).expect("Operation failed");
31//! assert_eq!(hull.num_vertices(), 4);
32//! assert_eq!(hull.num_faces(), 4);
33//! ```
34
35use crate::error::{SpatialError, SpatialResult};
36
37/// Tolerance for floating-point comparisons
38const EPSILON: f64 = 1e-10;
39
40/// A triangular face of the convex hull
41#[derive(Debug, Clone)]
42pub struct HullFace {
43    /// Indices of the three vertices (in counter-clockwise order when viewed from outside)
44    pub vertices: [usize; 3],
45    /// Outward-pointing normal vector
46    pub normal: [f64; 3],
47    /// Distance from origin along the normal (plane equation: n . x = d)
48    pub distance: f64,
49    /// Whether this face is active (not removed)
50    active: bool,
51}
52
53impl HullFace {
54    /// Compute the signed distance from a point to the face's plane
55    ///
56    /// Positive means the point is on the outside (visible side) of the face.
57    fn signed_distance(&self, point: &[f64; 3]) -> f64 {
58        self.normal[0] * point[0] + self.normal[1] * point[1] + self.normal[2] * point[2]
59            - self.distance
60    }
61
62    /// Check if a point is visible from this face (on the outside)
63    fn is_visible_from(&self, point: &[f64; 3]) -> bool {
64        self.signed_distance(point) > EPSILON
65    }
66
67    /// Area of the triangular face
68    pub fn area(&self, vertices: &[[f64; 3]]) -> f64 {
69        let a = vertices[self.vertices[0]];
70        let b = vertices[self.vertices[1]];
71        let c = vertices[self.vertices[2]];
72
73        let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
74        let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
75
76        let cross = [
77            ab[1] * ac[2] - ab[2] * ac[1],
78            ab[2] * ac[0] - ab[0] * ac[2],
79            ab[0] * ac[1] - ab[1] * ac[0],
80        ];
81
82        0.5 * (cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]).sqrt()
83    }
84}
85
86/// An edge of the convex hull, represented by two vertex indices
87#[derive(Debug, Clone, Hash, PartialEq, Eq)]
88struct HullEdge {
89    v1: usize,
90    v2: usize,
91}
92
93impl HullEdge {
94    fn new(v1: usize, v2: usize) -> Self {
95        Self { v1, v2 }
96    }
97
98    /// Get the reversed edge (same edge, opposite direction)
99    fn reversed(&self) -> Self {
100        Self {
101            v1: self.v2,
102            v2: self.v1,
103        }
104    }
105}
106
107/// An incremental 3D convex hull
108#[derive(Debug, Clone)]
109pub struct IncrementalHull3D {
110    /// All points (including interior ones)
111    points: Vec<[f64; 3]>,
112    /// Indices of points that are hull vertices
113    vertex_indices: Vec<usize>,
114    /// The faces of the hull
115    faces: Vec<HullFace>,
116}
117
118impl IncrementalHull3D {
119    /// Create a new 3D convex hull from a set of points
120    ///
121    /// # Arguments
122    ///
123    /// * `points` - A slice of 3D points [x, y, z]
124    ///
125    /// # Returns
126    ///
127    /// * `SpatialResult<IncrementalHull3D>` - The convex hull
128    ///
129    /// # Examples
130    ///
131    /// ```
132    /// use scirs2_spatial::computational_geometry::incremental_hull_3d::IncrementalHull3D;
133    ///
134    /// let points = vec![
135    ///     [0.0, 0.0, 0.0],
136    ///     [1.0, 0.0, 0.0],
137    ///     [0.0, 1.0, 0.0],
138    ///     [0.0, 0.0, 1.0],
139    /// ];
140    ///
141    /// let hull = IncrementalHull3D::new(&points).expect("Operation failed");
142    /// assert_eq!(hull.num_faces(), 4);
143    /// ```
144    pub fn new(points: &[[f64; 3]]) -> SpatialResult<Self> {
145        if points.len() < 4 {
146            return Err(SpatialError::ValueError(
147                "Need at least 4 points for a 3D convex hull".to_string(),
148            ));
149        }
150
151        let mut hull = IncrementalHull3D {
152            points: points.to_vec(),
153            vertex_indices: Vec::new(),
154            faces: Vec::new(),
155        };
156
157        // Find initial tetrahedron
158        hull.initialize_tetrahedron()?;
159
160        // Add remaining points incrementally
161        for i in 0..points.len() {
162            if hull.vertex_indices.contains(&i) {
163                continue;
164            }
165            hull.add_point(i)?;
166        }
167
168        // Compact: remove inactive faces
169        hull.faces.retain(|f| f.active);
170
171        // Rebuild vertex_indices from active faces
172        let mut used_vertices = std::collections::HashSet::new();
173        for face in &hull.faces {
174            for &v in &face.vertices {
175                used_vertices.insert(v);
176            }
177        }
178        hull.vertex_indices = used_vertices.into_iter().collect();
179        hull.vertex_indices.sort();
180
181        Ok(hull)
182    }
183
184    /// Initialize the hull with a tetrahedron from 4 non-coplanar points
185    fn initialize_tetrahedron(&mut self) -> SpatialResult<()> {
186        let n = self.points.len();
187
188        // Find 4 non-coplanar points
189        // Step 1: Find two distinct points
190        let p0 = 0;
191        let mut p1 = None;
192
193        for i in 1..n {
194            let d = distance_3d(&self.points[p0], &self.points[i]);
195            if d > EPSILON {
196                p1 = Some(i);
197                break;
198            }
199        }
200
201        let p1 = p1.ok_or_else(|| {
202            SpatialError::ComputationError("All points are coincident".to_string())
203        })?;
204
205        // Step 2: Find a point not collinear with p0, p1
206        let mut p2 = None;
207        for i in 0..n {
208            if i == p0 || i == p1 {
209                continue;
210            }
211            let cross = cross_product_3d(
212                &sub_3d(&self.points[p1], &self.points[p0]),
213                &sub_3d(&self.points[i], &self.points[p0]),
214            );
215            let cross_len = norm_3d(&cross);
216            if cross_len > EPSILON {
217                p2 = Some(i);
218                break;
219            }
220        }
221
222        let p2 = p2.ok_or_else(|| {
223            SpatialError::ComputationError("All points are collinear".to_string())
224        })?;
225
226        // Step 3: Find a point not coplanar with p0, p1, p2
227        let normal = cross_product_3d(
228            &sub_3d(&self.points[p1], &self.points[p0]),
229            &sub_3d(&self.points[p2], &self.points[p0]),
230        );
231
232        let mut p3 = None;
233        for i in 0..n {
234            if i == p0 || i == p1 || i == p2 {
235                continue;
236            }
237            let diff = sub_3d(&self.points[i], &self.points[p0]);
238            let vol = dot_3d(&normal, &diff);
239            if vol.abs() > EPSILON {
240                p3 = Some(i);
241                break;
242            }
243        }
244
245        let p3 = p3
246            .ok_or_else(|| SpatialError::ComputationError("All points are coplanar".to_string()))?;
247
248        self.vertex_indices = vec![p0, p1, p2, p3];
249
250        // Create the 4 faces of the tetrahedron
251        // Ensure all faces have outward-pointing normals
252
253        // Compute centroid of tetrahedron
254        let centroid = [
255            (self.points[p0][0] + self.points[p1][0] + self.points[p2][0] + self.points[p3][0])
256                / 4.0,
257            (self.points[p0][1] + self.points[p1][1] + self.points[p2][1] + self.points[p3][1])
258                / 4.0,
259            (self.points[p0][2] + self.points[p1][2] + self.points[p2][2] + self.points[p3][2])
260                / 4.0,
261        ];
262
263        let face_verts = [[p0, p1, p2], [p0, p1, p3], [p0, p2, p3], [p1, p2, p3]];
264
265        for verts in &face_verts {
266            let face = self.create_face(verts[0], verts[1], verts[2], &centroid);
267            self.faces.push(face);
268        }
269
270        Ok(())
271    }
272
273    /// Create a face with outward-pointing normal
274    fn create_face(&self, v0: usize, v1: usize, v2: usize, interior_point: &[f64; 3]) -> HullFace {
275        let a = self.points[v0];
276        let b = self.points[v1];
277        let c = self.points[v2];
278
279        let ab = sub_3d(&b, &a);
280        let ac = sub_3d(&c, &a);
281        let mut normal = cross_product_3d(&ab, &ac);
282
283        let normal_len = norm_3d(&normal);
284        if normal_len > EPSILON {
285            normal[0] /= normal_len;
286            normal[1] /= normal_len;
287            normal[2] /= normal_len;
288        }
289
290        let distance = dot_3d(&normal, &a);
291
292        // Check if normal points away from interior point
293        let interior_dist = dot_3d(&normal, interior_point) - distance;
294
295        if interior_dist > 0.0 {
296            // Normal points toward the interior; flip it
297            normal[0] = -normal[0];
298            normal[1] = -normal[1];
299            normal[2] = -normal[2];
300            let new_distance = -distance;
301
302            HullFace {
303                vertices: [v0, v2, v1], // swap v1 and v2 to maintain CCW when viewed from outside
304                normal,
305                distance: new_distance,
306                active: true,
307            }
308        } else {
309            HullFace {
310                vertices: [v0, v1, v2],
311                normal,
312                distance,
313                active: true,
314            }
315        }
316    }
317
318    /// Add a point to the convex hull
319    fn add_point(&mut self, point_idx: usize) -> SpatialResult<()> {
320        let point = self.points[point_idx];
321
322        // Find all faces visible from this point
323        let mut visible_faces: Vec<usize> = Vec::new();
324        for (i, face) in self.faces.iter().enumerate() {
325            if face.active && face.is_visible_from(&point) {
326                visible_faces.push(i);
327            }
328        }
329
330        // If no faces are visible, the point is inside the hull
331        if visible_faces.is_empty() {
332            return Ok(());
333        }
334
335        // Find the horizon edges (edges shared between one visible and one non-visible face)
336        let horizon_edges = self.find_horizon_edges(&visible_faces);
337
338        if horizon_edges.is_empty() {
339            return Ok(());
340        }
341
342        // Compute the centroid of the current hull (for face orientation)
343        let centroid = self.compute_centroid();
344
345        // Remove visible faces
346        for &face_idx in &visible_faces {
347            self.faces[face_idx].active = false;
348        }
349
350        // Create new faces from the point to each horizon edge
351        for edge in &horizon_edges {
352            let face = self.create_face(edge.v1, edge.v2, point_idx, &centroid);
353            self.faces.push(face);
354        }
355
356        Ok(())
357    }
358
359    /// Find the horizon edges for a set of visible faces
360    fn find_horizon_edges(&self, visible_faces: &[usize]) -> Vec<HullEdge> {
361        let mut edge_count: std::collections::HashMap<(usize, usize), i32> =
362            std::collections::HashMap::new();
363
364        for &face_idx in visible_faces {
365            let face = &self.faces[face_idx];
366            let v = face.vertices;
367
368            // Each face has 3 edges
369            let edges = [(v[0], v[1]), (v[1], v[2]), (v[2], v[0])];
370
371            for (a, b) in edges {
372                let key = if a < b { (a, b) } else { (b, a) };
373                *edge_count.entry(key).or_insert(0) += 1;
374            }
375        }
376
377        // Horizon edges appear exactly once in the visible faces
378        // (edges shared by two visible faces appear twice and are internal)
379        let mut horizon = Vec::new();
380        for (&(a, b), &count) in &edge_count {
381            if count == 1 {
382                // Find the orientation: the edge should be oriented so that the
383                // new face will have the correct winding
384                // Find which visible face contains this edge
385                for &face_idx in visible_faces {
386                    let face = &self.faces[face_idx];
387                    let v = face.vertices;
388                    let edges = [(v[0], v[1]), (v[1], v[2]), (v[2], v[0])];
389
390                    for (ea, eb) in edges {
391                        let key = if ea < eb { (ea, eb) } else { (eb, ea) };
392                        if key == (a, b) {
393                            // Reverse the edge direction (since we're creating
394                            // new faces to replace the visible one, the new face
395                            // should have the opposite winding for the shared edge)
396                            horizon.push(HullEdge::new(eb, ea));
397                            break;
398                        }
399                    }
400                }
401            }
402        }
403
404        horizon
405    }
406
407    /// Compute the centroid of the hull vertices
408    fn compute_centroid(&self) -> [f64; 3] {
409        let active_verts: std::collections::HashSet<usize> = self
410            .faces
411            .iter()
412            .filter(|f| f.active)
413            .flat_map(|f| f.vertices.iter().copied())
414            .collect();
415
416        if active_verts.is_empty() {
417            return [0.0, 0.0, 0.0];
418        }
419
420        let n = active_verts.len() as f64;
421        let mut cx = 0.0;
422        let mut cy = 0.0;
423        let mut cz = 0.0;
424
425        for &v in &active_verts {
426            cx += self.points[v][0];
427            cy += self.points[v][1];
428            cz += self.points[v][2];
429        }
430
431        [cx / n, cy / n, cz / n]
432    }
433
434    /// Get the number of hull vertices
435    pub fn num_vertices(&self) -> usize {
436        self.vertex_indices.len()
437    }
438
439    /// Get the number of hull faces
440    pub fn num_faces(&self) -> usize {
441        self.faces.len()
442    }
443
444    /// Get the number of hull edges
445    ///
446    /// By Euler's formula: V - E + F = 2, so E = V + F - 2
447    pub fn num_edges(&self) -> usize {
448        let v = self.num_vertices();
449        let f = self.num_faces();
450        (v + f).saturating_sub(2)
451    }
452
453    /// Get the hull vertices
454    pub fn get_vertices(&self) -> Vec<[f64; 3]> {
455        self.vertex_indices
456            .iter()
457            .map(|&i| self.points[i])
458            .collect()
459    }
460
461    /// Get the hull vertex indices
462    pub fn vertex_indices(&self) -> &[usize] {
463        &self.vertex_indices
464    }
465
466    /// Get the hull faces
467    pub fn get_faces(&self) -> &[HullFace] {
468        &self.faces
469    }
470
471    /// Get the face vertex indices as triples
472    pub fn get_face_indices(&self) -> Vec<[usize; 3]> {
473        self.faces.iter().map(|f| f.vertices).collect()
474    }
475
476    /// Compute the volume of the convex hull
477    ///
478    /// Uses the signed tetrahedron volume method: sum the signed volumes of
479    /// tetrahedra formed by each face and the origin.
480    pub fn volume(&self) -> f64 {
481        let mut vol = 0.0;
482
483        for face in &self.faces {
484            let a = self.points[face.vertices[0]];
485            let b = self.points[face.vertices[1]];
486            let c = self.points[face.vertices[2]];
487
488            // Signed volume of tetrahedron with one vertex at origin
489            vol += a[0] * (b[1] * c[2] - b[2] * c[1])
490                + a[1] * (b[2] * c[0] - b[0] * c[2])
491                + a[2] * (b[0] * c[1] - b[1] * c[0]);
492        }
493
494        vol.abs() / 6.0
495    }
496
497    /// Compute the surface area of the convex hull
498    pub fn surface_area(&self) -> f64 {
499        let mut area = 0.0;
500
501        for face in &self.faces {
502            area += face.area(&self.points);
503        }
504
505        area
506    }
507
508    /// Check if a point is inside the convex hull
509    ///
510    /// A point is inside if it is on the interior side of all faces.
511    pub fn contains(&self, point: &[f64; 3]) -> bool {
512        for face in &self.faces {
513            if face.signed_distance(point) > EPSILON {
514                return false;
515            }
516        }
517        true
518    }
519
520    /// Get the all input points
521    pub fn points(&self) -> &[[f64; 3]] {
522        &self.points
523    }
524}
525
526// ---- Vector math helpers ----
527
528fn sub_3d(a: &[f64; 3], b: &[f64; 3]) -> [f64; 3] {
529    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
530}
531
532fn cross_product_3d(a: &[f64; 3], b: &[f64; 3]) -> [f64; 3] {
533    [
534        a[1] * b[2] - a[2] * b[1],
535        a[2] * b[0] - a[0] * b[2],
536        a[0] * b[1] - a[1] * b[0],
537    ]
538}
539
540fn dot_3d(a: &[f64; 3], b: &[f64; 3]) -> f64 {
541    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
542}
543
544fn norm_3d(a: &[f64; 3]) -> f64 {
545    (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
546}
547
548fn distance_3d(a: &[f64; 3], b: &[f64; 3]) -> f64 {
549    let d = sub_3d(a, b);
550    norm_3d(&d)
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn test_tetrahedron() {
559        let points = vec![
560            [0.0, 0.0, 0.0],
561            [1.0, 0.0, 0.0],
562            [0.0, 1.0, 0.0],
563            [0.0, 0.0, 1.0],
564        ];
565
566        let hull = IncrementalHull3D::new(&points).expect("Operation failed");
567        assert_eq!(hull.num_vertices(), 4);
568        assert_eq!(hull.num_faces(), 4);
569        // Euler: E = V + F - 2 = 4 + 4 - 2 = 6
570        assert_eq!(hull.num_edges(), 6);
571    }
572
573    #[test]
574    fn test_tetrahedron_with_interior_point() {
575        let points = vec![
576            [0.0, 0.0, 0.0],
577            [1.0, 0.0, 0.0],
578            [0.0, 1.0, 0.0],
579            [0.0, 0.0, 1.0],
580            [0.1, 0.1, 0.1], // interior point
581        ];
582
583        let hull = IncrementalHull3D::new(&points).expect("Operation failed");
584        assert_eq!(hull.num_vertices(), 4);
585        assert_eq!(hull.num_faces(), 4);
586    }
587
588    #[test]
589    fn test_cube() {
590        let points = vec![
591            [0.0, 0.0, 0.0],
592            [1.0, 0.0, 0.0],
593            [0.0, 1.0, 0.0],
594            [1.0, 1.0, 0.0],
595            [0.0, 0.0, 1.0],
596            [1.0, 0.0, 1.0],
597            [0.0, 1.0, 1.0],
598            [1.0, 1.0, 1.0],
599        ];
600
601        let hull = IncrementalHull3D::new(&points).expect("Operation failed");
602        assert_eq!(hull.num_vertices(), 8);
603        // A cube has 6 faces, but as a triangulated convex hull it has 12 triangular faces
604        assert_eq!(hull.num_faces(), 12);
605    }
606
607    #[test]
608    fn test_volume_tetrahedron() {
609        // Regular tetrahedron with one vertex at origin
610        let points = vec![
611            [0.0, 0.0, 0.0],
612            [1.0, 0.0, 0.0],
613            [0.0, 1.0, 0.0],
614            [0.0, 0.0, 1.0],
615        ];
616
617        let hull = IncrementalHull3D::new(&points).expect("Operation failed");
618        let vol = hull.volume();
619        // Volume of this tetrahedron = 1/6
620        assert!((vol - 1.0 / 6.0).abs() < 1e-10);
621    }
622
623    #[test]
624    fn test_volume_cube() {
625        let points = vec![
626            [0.0, 0.0, 0.0],
627            [1.0, 0.0, 0.0],
628            [0.0, 1.0, 0.0],
629            [1.0, 1.0, 0.0],
630            [0.0, 0.0, 1.0],
631            [1.0, 0.0, 1.0],
632            [0.0, 1.0, 1.0],
633            [1.0, 1.0, 1.0],
634        ];
635
636        let hull = IncrementalHull3D::new(&points).expect("Operation failed");
637        let vol = hull.volume();
638        assert!((vol - 1.0).abs() < 1e-10);
639    }
640
641    #[test]
642    fn test_surface_area_cube() {
643        let points = vec![
644            [0.0, 0.0, 0.0],
645            [1.0, 0.0, 0.0],
646            [0.0, 1.0, 0.0],
647            [1.0, 1.0, 0.0],
648            [0.0, 0.0, 1.0],
649            [1.0, 0.0, 1.0],
650            [0.0, 1.0, 1.0],
651            [1.0, 1.0, 1.0],
652        ];
653
654        let hull = IncrementalHull3D::new(&points).expect("Operation failed");
655        let sa = hull.surface_area();
656        // Surface area of unit cube = 6
657        assert!((sa - 6.0).abs() < 1e-10);
658    }
659
660    #[test]
661    fn test_contains() {
662        let points = vec![
663            [0.0, 0.0, 0.0],
664            [1.0, 0.0, 0.0],
665            [0.0, 1.0, 0.0],
666            [0.0, 0.0, 1.0],
667        ];
668
669        let hull = IncrementalHull3D::new(&points).expect("Operation failed");
670
671        // Interior point
672        assert!(hull.contains(&[0.1, 0.1, 0.1]));
673        // Centroid should be inside
674        assert!(hull.contains(&[0.1, 0.1, 0.1]));
675        // Far exterior point
676        assert!(!hull.contains(&[2.0, 2.0, 2.0]));
677    }
678
679    #[test]
680    fn test_too_few_points() {
681        let points = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
682        let result = IncrementalHull3D::new(&points);
683        assert!(result.is_err());
684    }
685
686    #[test]
687    fn test_coplanar_points() {
688        let points = vec![
689            [0.0, 0.0, 0.0],
690            [1.0, 0.0, 0.0],
691            [0.0, 1.0, 0.0],
692            [1.0, 1.0, 0.0],
693        ];
694        let result = IncrementalHull3D::new(&points);
695        assert!(result.is_err());
696    }
697
698    #[test]
699    fn test_get_vertices() {
700        let points = vec![
701            [0.0, 0.0, 0.0],
702            [1.0, 0.0, 0.0],
703            [0.0, 1.0, 0.0],
704            [0.0, 0.0, 1.0],
705        ];
706
707        let hull = IncrementalHull3D::new(&points).expect("Operation failed");
708        let verts = hull.get_vertices();
709        assert_eq!(verts.len(), 4);
710    }
711
712    #[test]
713    fn test_get_face_indices() {
714        let points = vec![
715            [0.0, 0.0, 0.0],
716            [1.0, 0.0, 0.0],
717            [0.0, 1.0, 0.0],
718            [0.0, 0.0, 1.0],
719        ];
720
721        let hull = IncrementalHull3D::new(&points).expect("Operation failed");
722        let face_indices = hull.get_face_indices();
723        assert_eq!(face_indices.len(), 4);
724
725        // Each face should have 3 distinct vertices
726        for face in &face_indices {
727            assert_ne!(face[0], face[1]);
728            assert_ne!(face[1], face[2]);
729            assert_ne!(face[0], face[2]);
730        }
731    }
732
733    #[test]
734    fn test_many_points() {
735        // Points on a sphere - hull should use all of them
736        // 6 points: axis-aligned extremes of unit sphere, plus interior point
737        let points = vec![
738            [1.0, 0.0, 0.0],
739            [-1.0, 0.0, 0.0],
740            [0.0, 1.0, 0.0],
741            [0.0, -1.0, 0.0],
742            [0.0, 0.0, 1.0],
743            [0.0, 0.0, -1.0],
744            [0.0, 0.0, 0.0],
745        ];
746
747        let hull = IncrementalHull3D::new(&points).expect("Operation failed");
748        assert_eq!(hull.num_vertices(), 6); // Only the 6 extremes
749        assert!(hull.contains(&[0.0, 0.0, 0.0])); // Origin is inside
750    }
751
752    #[test]
753    fn test_surface_area_positive() {
754        let points = vec![
755            [0.0, 0.0, 0.0],
756            [1.0, 0.0, 0.0],
757            [0.0, 1.0, 0.0],
758            [0.0, 0.0, 1.0],
759        ];
760
761        let hull = IncrementalHull3D::new(&points).expect("Operation failed");
762        assert!(hull.surface_area() > 0.0);
763    }
764
765    #[test]
766    fn test_volume_positive() {
767        let points = vec![
768            [0.0, 0.0, 0.0],
769            [1.0, 0.0, 0.0],
770            [0.0, 1.0, 0.0],
771            [0.0, 0.0, 1.0],
772        ];
773
774        let hull = IncrementalHull3D::new(&points).expect("Operation failed");
775        assert!(hull.volume() > 0.0);
776    }
777}