Skip to main content

threecrate_reconstruction/
delaunay.rs

1//! Delaunay triangulation for surface reconstruction
2//!
3//! This module provides both 2D and 3D Delaunay triangulation capabilities.
4//! For 3D point clouds, multiple projection strategies are available.
5
6use crate::parallel;
7use nalgebra::Matrix3;
8use spade::{DelaunayTriangulation, Point2, Triangulation};
9use threecrate_core::{Error, Point3f, PointCloud, Result, TriangleMesh};
10
11/// Configuration for Delaunay triangulation
12#[derive(Debug, Clone)]
13pub struct DelaunayConfig {
14    /// Projection method for 3D points
15    pub projection: ProjectionMethod,
16    /// Whether to validate the triangulation
17    pub validate: bool,
18    /// Minimum triangle area threshold
19    pub min_triangle_area: f32,
20    /// Maximum triangle edge length (for quality control)
21    pub max_edge_length: Option<f32>,
22}
23
24/// Projection methods for 3D to 2D mapping
25#[derive(Debug, Clone, PartialEq)]
26pub enum ProjectionMethod {
27    /// Project onto XY plane (ignore Z coordinate)
28    XY,
29    /// Project onto XZ plane (ignore Y coordinate)
30    XZ,
31    /// Project onto YZ plane (ignore X coordinate)
32    YZ,
33    /// Use principal component analysis to find best plane
34    PCA,
35    /// Project using plane defined by first 3 non-collinear points
36    BestFitPlane,
37}
38
39impl Default for DelaunayConfig {
40    fn default() -> Self {
41        Self {
42            projection: ProjectionMethod::PCA,
43            validate: true,
44            min_triangle_area: 1e-8,
45            max_edge_length: None,
46        }
47    }
48}
49
50/// 2D Delaunay triangulation using spade crate
51pub fn delaunay_triangulation_2d(points: &[Point2<f64>]) -> Result<Vec<[usize; 3]>> {
52    if points.len() < 3 {
53        return Err(Error::InvalidData(
54            "Need at least 3 points for triangulation".to_string(),
55        ));
56    }
57
58    let mut triangulation: DelaunayTriangulation<Point2<f64>> = DelaunayTriangulation::new();
59
60    // Insert points and track original indices
61    for point in points {
62        triangulation.insert(*point).map_err(|e| {
63            Error::Algorithm(format!(
64                "Failed to insert point in Delaunay triangulation: {:?}",
65                e
66            ))
67        })?;
68    }
69
70    // Extract triangles
71    let mut triangles = Vec::new();
72    for face in triangulation.inner_faces() {
73        let vertices = face.vertices();
74        let positions: Vec<Point2<f64>> = vertices.iter().map(|v| v.position()).collect();
75
76        // Find original indices by matching positions
77        let mut indices = Vec::new();
78        for pos in positions {
79            if let Some(idx) = points
80                .iter()
81                .position(|p| (p.x - pos.x).abs() < 1e-10 && (p.y - pos.y).abs() < 1e-10)
82            {
83                indices.push(idx);
84            } else {
85                return Err(Error::Algorithm(
86                    "Failed to match triangle vertex to original point".to_string(),
87                ));
88            }
89        }
90
91        if indices.len() == 3 {
92            triangles.push([indices[0], indices[1], indices[2]]);
93        }
94    }
95
96    Ok(triangles)
97}
98
99/// Project 3D points to 2D using specified method with parallel processing
100pub fn project_3d_to_2d(points: &[Point3f], method: &ProjectionMethod) -> Result<Vec<Point2<f64>>> {
101    if points.is_empty() {
102        return Ok(Vec::new());
103    }
104
105    match method {
106        ProjectionMethod::XY => Ok(parallel::parallel_map(points, |p| {
107            Point2::new(p.x as f64, p.y as f64)
108        })),
109        ProjectionMethod::XZ => Ok(parallel::parallel_map(points, |p| {
110            Point2::new(p.x as f64, p.z as f64)
111        })),
112        ProjectionMethod::YZ => Ok(parallel::parallel_map(points, |p| {
113            Point2::new(p.y as f64, p.z as f64)
114        })),
115        ProjectionMethod::PCA => project_using_pca(points),
116        ProjectionMethod::BestFitPlane => project_using_best_fit_plane(points),
117    }
118}
119
120/// Project points using Principal Component Analysis to find best 2D plane
121fn project_using_pca(points: &[Point3f]) -> Result<Vec<Point2<f64>>> {
122    if points.len() < 3 {
123        return Err(Error::InvalidData(
124            "Need at least 3 points for PCA projection".to_string(),
125        ));
126    }
127
128    // Compute centroid
129    let mut centroid = Point3f::origin();
130    for point in points {
131        centroid = Point3f::from(centroid.coords + point.coords);
132    }
133    centroid = Point3f::from(centroid.coords / points.len() as f32);
134
135    // Compute covariance matrix
136    let mut covariance = Matrix3::zeros();
137    for point in points {
138        let diff = point - centroid;
139        covariance += diff * diff.transpose();
140    }
141
142    // Find eigenvalues and eigenvectors
143    let eigen = covariance.symmetric_eigen();
144
145    // Use the two eigenvectors with largest eigenvalues as 2D basis
146    let mut eigen_pairs: Vec<(f32, nalgebra::Vector3<f32>)> = eigen
147        .eigenvalues
148        .iter()
149        .zip(eigen.eigenvectors.column_iter())
150        .map(|(val, vec)| (*val, vec.clone_owned()))
151        .collect();
152
153    // Sort by eigenvalue in descending order
154    eigen_pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
155
156    let u = eigen_pairs[0].1.normalize();
157    let v = eigen_pairs[1].1.normalize();
158
159    // Project all points onto the 2D plane using parallel processing
160    let projected: Vec<Point2<f64>> = parallel::parallel_map(points, |point| {
161        let diff = point - centroid;
162        let x = diff.dot(&u) as f64;
163        let y = diff.dot(&v) as f64;
164        Point2::new(x, y)
165    });
166
167    Ok(projected)
168}
169
170/// Project points using a best-fit plane through the points
171fn project_using_best_fit_plane(points: &[Point3f]) -> Result<Vec<Point2<f64>>> {
172    if points.len() < 3 {
173        return Err(Error::InvalidData(
174            "Need at least 3 points for best-fit plane projection".to_string(),
175        ));
176    }
177
178    // Use first 3 non-collinear points to define the plane
179    let p1 = points[0];
180    let mut p2 = None;
181    let mut p3 = None;
182
183    // Find second point that's not too close to first
184    for point in points.iter().skip(1) {
185        if (point - p1).magnitude() > 1e-6 {
186            p2 = Some(*point);
187            break;
188        }
189    }
190
191    let p2 =
192        p2.ok_or_else(|| Error::InvalidData("All points are too close together".to_string()))?;
193
194    // Find third point that's not collinear with first two
195    for point in points.iter().skip(2) {
196        let v1 = p2 - p1;
197        let v2 = *point - p1;
198        if v1.cross(&v2).magnitude() > 1e-6 {
199            p3 = Some(*point);
200            break;
201        }
202    }
203
204    let p3 = p3.ok_or_else(|| Error::InvalidData("All points are collinear".to_string()))?;
205
206    // Define 2D coordinate system on the plane
207    let u = (p2 - p1).normalize();
208    let w = (p3 - p1).cross(&u).normalize();
209    let v = w.cross(&u).normalize();
210
211    // Project all points onto the plane using parallel processing
212    let projected: Vec<Point2<f64>> = parallel::parallel_map(points, |point| {
213        let diff = *point - p1;
214        let x = diff.dot(&u) as f64;
215        let y = diff.dot(&v) as f64;
216        Point2::new(x, y)
217    });
218
219    Ok(projected)
220}
221
222/// Validate triangle quality
223fn is_triangle_valid(p1: &Point3f, p2: &Point3f, p3: &Point3f, config: &DelaunayConfig) -> bool {
224    // Check minimum area
225    let v1 = p2 - p1;
226    let v2 = p3 - p1;
227    let cross = v1.cross(&v2);
228    let area = cross.magnitude() * 0.5;
229
230    if area < config.min_triangle_area {
231        return false;
232    }
233
234    // Check maximum edge length if specified
235    if let Some(max_len) = config.max_edge_length {
236        let edge1 = (p2 - p1).magnitude();
237        let edge2 = (p3 - p2).magnitude();
238        let edge3 = (p1 - p3).magnitude();
239
240        if edge1 > max_len || edge2 > max_len || edge3 > max_len {
241            return false;
242        }
243    }
244
245    true
246}
247
248/// 3D Delaunay triangulation using projection to 2D
249pub fn delaunay_triangulation(cloud: &PointCloud<Point3f>) -> Result<TriangleMesh> {
250    delaunay_triangulation_with_config(cloud, &DelaunayConfig::default())
251}
252
253/// 3D Delaunay triangulation with configuration
254pub fn delaunay_triangulation_with_config(
255    cloud: &PointCloud<Point3f>,
256    config: &DelaunayConfig,
257) -> Result<TriangleMesh> {
258    if cloud.is_empty() {
259        return Err(Error::InvalidData("Point cloud is empty".to_string()));
260    }
261
262    if cloud.points.len() < 3 {
263        return Err(Error::InvalidData(
264            "Need at least 3 points for triangulation".to_string(),
265        ));
266    }
267
268    // Project 3D points to 2D
269    let projected_points = project_3d_to_2d(&cloud.points, &config.projection)?;
270
271    // Perform 2D Delaunay triangulation
272    let triangle_indices = delaunay_triangulation_2d(&projected_points)?;
273
274    // Validate triangles if requested
275    let mut valid_triangles = Vec::new();
276    for &[i, j, k] in &triangle_indices {
277        if i < cloud.points.len() && j < cloud.points.len() && k < cloud.points.len() {
278            let p1 = &cloud.points[i];
279            let p2 = &cloud.points[j];
280            let p3 = &cloud.points[k];
281
282            if !config.validate || is_triangle_valid(p1, p2, p3, config) {
283                valid_triangles.push([i, j, k]);
284            }
285        }
286    }
287
288    if valid_triangles.is_empty() {
289        return Err(Error::Algorithm("No valid triangles generated".to_string()));
290    }
291
292    // Create mesh
293    let mesh = TriangleMesh::from_vertices_and_faces(cloud.points.clone(), valid_triangles);
294
295    Ok(mesh)
296}
297
298/// Automatic projection method selection based on point cloud geometry
299pub fn auto_select_projection(points: &[Point3f]) -> ProjectionMethod {
300    if points.len() < 10 {
301        return ProjectionMethod::BestFitPlane;
302    }
303
304    // Compute bounding box
305    let mut min_vals = [f32::INFINITY; 3];
306    let mut max_vals = [f32::NEG_INFINITY; 3];
307
308    for point in points {
309        min_vals[0] = min_vals[0].min(point.x);
310        min_vals[1] = min_vals[1].min(point.y);
311        min_vals[2] = min_vals[2].min(point.z);
312        max_vals[0] = max_vals[0].max(point.x);
313        max_vals[1] = max_vals[1].max(point.y);
314        max_vals[2] = max_vals[2].max(point.z);
315    }
316
317    let extents = [
318        max_vals[0] - min_vals[0],
319        max_vals[1] - min_vals[1],
320        max_vals[2] - min_vals[2],
321    ];
322
323    // Find the dimension with minimum extent
324    let min_extent_idx = extents
325        .iter()
326        .enumerate()
327        .min_by(|a, b| a.1.partial_cmp(b.1).unwrap())
328        .map(|(idx, _)| idx)
329        .unwrap();
330
331    // If one dimension is significantly smaller, project onto the other two
332    let min_extent = extents[min_extent_idx];
333    let max_extent = extents.iter().fold(0.0f32, |acc, &x| acc.max(x));
334
335    if min_extent < max_extent * 0.1 {
336        match min_extent_idx {
337            0 => ProjectionMethod::YZ, // X is smallest, project onto YZ
338            1 => ProjectionMethod::XZ, // Y is smallest, project onto XZ
339            2 => ProjectionMethod::XY, // Z is smallest, project onto XY
340            _ => ProjectionMethod::PCA,
341        }
342    } else {
343        // Use PCA for general case
344        ProjectionMethod::PCA
345    }
346}
347
348/// Convenience function for automatic Delaunay triangulation
349pub fn delaunay_triangulation_auto(cloud: &PointCloud<Point3f>) -> Result<TriangleMesh> {
350    let projection = auto_select_projection(&cloud.points);
351    let config = DelaunayConfig {
352        projection,
353        ..Default::default()
354    };
355    delaunay_triangulation_with_config(cloud, &config)
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use nalgebra::Point3;
362
363    #[test]
364    fn test_delaunay_config_default() {
365        let config = DelaunayConfig::default();
366        assert_eq!(config.projection, ProjectionMethod::PCA);
367        assert!(config.validate);
368        assert_eq!(config.min_triangle_area, 1e-8);
369        assert_eq!(config.max_edge_length, None);
370    }
371
372    #[test]
373    fn test_project_3d_to_2d_xy() {
374        let points = vec![
375            Point3f::new(0.0, 0.0, 1.0),
376            Point3f::new(1.0, 1.0, 2.0),
377            Point3f::new(2.0, 0.0, 3.0),
378        ];
379
380        let projected = project_3d_to_2d(&points, &ProjectionMethod::XY).unwrap();
381
382        assert_eq!(projected.len(), 3);
383        assert_eq!(projected[0], Point2::new(0.0, 0.0));
384        assert_eq!(projected[1], Point2::new(1.0, 1.0));
385        assert_eq!(projected[2], Point2::new(2.0, 0.0));
386    }
387
388    #[test]
389    fn test_delaunay_triangulation_2d_simple() {
390        let points = vec![
391            Point2::new(0.0, 0.0),
392            Point2::new(1.0, 0.0),
393            Point2::new(0.5, 1.0),
394        ];
395
396        let triangles = delaunay_triangulation_2d(&points).unwrap();
397
398        assert_eq!(triangles.len(), 1);
399        // Should form one triangle
400        assert_eq!(triangles[0].len(), 3);
401    }
402
403    #[test]
404    fn test_delaunay_triangulation_empty() {
405        let cloud = PointCloud::new();
406        let result = delaunay_triangulation(&cloud);
407        assert!(result.is_err());
408    }
409
410    #[test]
411    fn test_delaunay_triangulation_too_few_points() {
412        let points = vec![Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 0.0, 0.0)];
413        let cloud = PointCloud::from_points(points);
414
415        let result = delaunay_triangulation(&cloud);
416        assert!(result.is_err());
417    }
418
419    #[test]
420    fn test_delaunay_triangulation_simple() {
421        let points = vec![
422            Point3::new(0.0, 0.0, 0.0),
423            Point3::new(1.0, 0.0, 0.0),
424            Point3::new(0.5, 1.0, 0.0),
425            Point3::new(0.0, 1.0, 0.0),
426        ];
427        let cloud = PointCloud::from_points(points);
428
429        let result = delaunay_triangulation(&cloud);
430
431        match result {
432            Ok(mesh) => {
433                assert!(!mesh.is_empty());
434                assert!(mesh.vertex_count() <= 4);
435                assert!(mesh.face_count() >= 1);
436            }
437            Err(_) => {
438                // May fail on simple coplanar data - acceptable for testing
439            }
440        }
441    }
442
443    #[test]
444    fn test_auto_select_projection() {
445        // Test points mostly in XY plane (small Z variation)
446        let points = vec![
447            Point3f::new(0.0, 0.0, 0.0),
448            Point3f::new(1.0, 0.0, 0.01),
449            Point3f::new(0.5, 1.0, 0.02),
450            Point3f::new(0.0, 1.0, 0.01),
451        ];
452
453        let projection = auto_select_projection(&points);
454        // Should select some reasonable projection for mostly planar data
455        // With only 4 points, it should use BestFitPlane
456        assert!(matches!(projection, ProjectionMethod::BestFitPlane));
457
458        // Test with many points that are clearly planar in XY
459        let many_points: Vec<Point3f> = (0..20)
460            .map(|i| Point3f::new(i as f32 * 0.1, (i % 5) as f32 * 0.1, 0.001))
461            .collect();
462
463        let projection = auto_select_projection(&many_points);
464        // Should select XY projection for clearly planar data
465        assert!(matches!(
466            projection,
467            ProjectionMethod::XY | ProjectionMethod::PCA
468        ));
469    }
470}