mesh_repair/
pointcloud.rs

1//! Point cloud data structures and surface reconstruction.
2//!
3//! This module provides tools for working with point cloud data, commonly
4//! produced by 3D scanners, LiDAR, and photogrammetry. It includes:
5//!
6//! - [`PointCloud`] struct for storing 3D point data with optional attributes
7//! - I/O support for PLY, XYZ, and PCD formats
8//! - Normal estimation from local neighborhoods
9//! - Surface reconstruction algorithms:
10//!   - Ball-pivoting algorithm (BPA)
11//!   - SDF-based reconstruction (similar to Poisson)
12//!
13//! # Example
14//!
15//! ```ignore
16//! use mesh_repair::pointcloud::{PointCloud, ReconstructionParams};
17//!
18//! // Load a point cloud from file
19//! let cloud = PointCloud::load("scan.ply")?;
20//!
21//! // Estimate normals if not present
22//! let cloud = cloud.with_estimated_normals(16)?;
23//!
24//! // Reconstruct surface mesh
25//! let result = cloud.to_mesh(&ReconstructionParams::default())?;
26//! println!("Reconstructed {} faces", result.mesh.faces.len());
27//! ```
28//!
29//! # Units
30//!
31//! Like the rest of the library, point clouds are assumed to be in millimeters (mm).
32
33use std::fs::File;
34use std::io::{BufRead, BufReader, BufWriter, Write};
35use std::path::Path;
36
37use nalgebra::{Point3, Vector3};
38use ply_rs::ply::Property;
39use tracing::{debug, info, warn};
40
41use crate::error::{MeshError, MeshResult};
42use crate::{Mesh, Vertex, VertexColor};
43
44/// A point in the cloud with optional attributes.
45#[derive(Debug, Clone)]
46pub struct CloudPoint {
47    /// 3D position.
48    pub position: Point3<f64>,
49
50    /// Unit normal vector (estimated or from scanner).
51    pub normal: Option<Vector3<f64>>,
52
53    /// Point color (RGB).
54    pub color: Option<VertexColor>,
55
56    /// Intensity/reflectance value (common in LiDAR data).
57    pub intensity: Option<f32>,
58
59    /// Application-specific tag (e.g., classification, scan ID).
60    pub tag: Option<u32>,
61}
62
63impl CloudPoint {
64    /// Create a point with only position.
65    #[inline]
66    pub fn new(position: Point3<f64>) -> Self {
67        Self {
68            position,
69            normal: None,
70            color: None,
71            intensity: None,
72            tag: None,
73        }
74    }
75
76    /// Create a point from raw coordinates.
77    #[inline]
78    pub fn from_coords(x: f64, y: f64, z: f64) -> Self {
79        Self::new(Point3::new(x, y, z))
80    }
81
82    /// Create a point with position and normal.
83    #[inline]
84    pub fn with_normal(position: Point3<f64>, normal: Vector3<f64>) -> Self {
85        Self {
86            position,
87            normal: Some(normal),
88            color: None,
89            intensity: None,
90            tag: None,
91        }
92    }
93
94    /// Convert to a mesh vertex.
95    pub fn to_vertex(&self) -> Vertex {
96        let mut v = Vertex::new(self.position);
97        v.normal = self.normal;
98        v.color = self.color;
99        v.tag = self.tag;
100        v
101    }
102}
103
104/// A collection of 3D points with optional attributes.
105#[derive(Debug, Clone)]
106pub struct PointCloud {
107    /// The points in the cloud.
108    pub points: Vec<CloudPoint>,
109}
110
111impl PointCloud {
112    /// Create a new empty point cloud.
113    pub fn new() -> Self {
114        Self { points: Vec::new() }
115    }
116
117    /// Create a point cloud with pre-allocated capacity.
118    pub fn with_capacity(capacity: usize) -> Self {
119        Self {
120            points: Vec::with_capacity(capacity),
121        }
122    }
123
124    /// Create a point cloud from a list of positions.
125    pub fn from_positions(positions: &[Point3<f64>]) -> Self {
126        Self {
127            points: positions.iter().map(|&p| CloudPoint::new(p)).collect(),
128        }
129    }
130
131    /// Create a point cloud from a mesh (extracts vertices).
132    pub fn from_mesh(mesh: &Mesh) -> Self {
133        let points = mesh
134            .vertices
135            .iter()
136            .map(|v| {
137                let mut p = CloudPoint::new(v.position);
138                p.normal = v.normal;
139                p.color = v.color;
140                p.tag = v.tag;
141                p
142            })
143            .collect();
144        Self { points }
145    }
146
147    /// Number of points in the cloud.
148    #[inline]
149    pub fn len(&self) -> usize {
150        self.points.len()
151    }
152
153    /// Check if the cloud is empty.
154    #[inline]
155    pub fn is_empty(&self) -> bool {
156        self.points.is_empty()
157    }
158
159    /// Check if all points have normals.
160    pub fn has_normals(&self) -> bool {
161        !self.points.is_empty() && self.points.iter().all(|p| p.normal.is_some())
162    }
163
164    /// Check if any points have colors.
165    pub fn has_colors(&self) -> bool {
166        self.points.iter().any(|p| p.color.is_some())
167    }
168
169    /// Compute the axis-aligned bounding box.
170    pub fn bounds(&self) -> Option<(Point3<f64>, Point3<f64>)> {
171        if self.points.is_empty() {
172            return None;
173        }
174
175        let mut min = self.points[0].position;
176        let mut max = self.points[0].position;
177
178        for p in &self.points[1..] {
179            min.x = min.x.min(p.position.x);
180            min.y = min.y.min(p.position.y);
181            min.z = min.z.min(p.position.z);
182            max.x = max.x.max(p.position.x);
183            max.y = max.y.max(p.position.y);
184            max.z = max.z.max(p.position.z);
185        }
186
187        Some((min, max))
188    }
189
190    /// Compute the centroid (center of mass) of the point cloud.
191    pub fn centroid(&self) -> Option<Point3<f64>> {
192        if self.points.is_empty() {
193            return None;
194        }
195
196        let sum: Vector3<f64> = self
197            .points
198            .iter()
199            .map(|p| p.position.coords)
200            .fold(Vector3::zeros(), |acc, v| acc + v);
201
202        Some(Point3::from(sum / self.points.len() as f64))
203    }
204
205    /// Add a point to the cloud.
206    #[inline]
207    pub fn push(&mut self, point: CloudPoint) {
208        self.points.push(point);
209    }
210
211    /// Add a point from coordinates.
212    #[inline]
213    pub fn push_coords(&mut self, x: f64, y: f64, z: f64) {
214        self.points.push(CloudPoint::from_coords(x, y, z));
215    }
216
217    /// Load a point cloud from file, auto-detecting format.
218    pub fn load(path: impl AsRef<Path>) -> MeshResult<Self> {
219        let path = path.as_ref();
220        let format =
221            PointCloudFormat::from_path(path).ok_or_else(|| MeshError::UnsupportedFormat {
222                extension: path.extension().and_then(|e| e.to_str()).map(String::from),
223            })?;
224
225        info!("Loading point cloud from {:?} (format: {:?})", path, format);
226
227        let cloud = match format {
228            PointCloudFormat::Ply => load_ply_pointcloud(path)?,
229            PointCloudFormat::Xyz => load_xyz(path)?,
230            PointCloudFormat::Pcd => load_pcd(path)?,
231        };
232
233        info!(
234            "Loaded {} points (has_normals: {}, has_colors: {})",
235            cloud.len(),
236            cloud.has_normals(),
237            cloud.has_colors()
238        );
239
240        Ok(cloud)
241    }
242
243    /// Save the point cloud to file, auto-detecting format.
244    pub fn save(&self, path: impl AsRef<Path>) -> MeshResult<()> {
245        let path = path.as_ref();
246        let format =
247            PointCloudFormat::from_path(path).ok_or_else(|| MeshError::UnsupportedFormat {
248                extension: path.extension().and_then(|e| e.to_str()).map(String::from),
249            })?;
250
251        info!("Saving point cloud to {:?} (format: {:?})", path, format);
252
253        match format {
254            PointCloudFormat::Ply => save_ply_pointcloud(self, path),
255            PointCloudFormat::Xyz => save_xyz(self, path),
256            PointCloudFormat::Pcd => save_pcd(self, path),
257        }
258    }
259
260    /// Estimate normals for all points using PCA on local neighborhoods.
261    ///
262    /// # Arguments
263    /// * `k` - Number of nearest neighbors to use for normal estimation
264    ///
265    /// # Returns
266    /// A new point cloud with estimated normals.
267    pub fn with_estimated_normals(&self, k: usize) -> MeshResult<Self> {
268        if self.is_empty() {
269            return Ok(self.clone());
270        }
271
272        info!(
273            "Estimating normals using k={} neighbors for {} points",
274            k,
275            self.len()
276        );
277
278        let normals = estimate_normals(self, k)?;
279
280        let mut result = self.clone();
281        for (point, normal) in result.points.iter_mut().zip(normals.iter()) {
282            point.normal = Some(*normal);
283        }
284
285        Ok(result)
286    }
287
288    /// Orient normals consistently (points outward from centroid).
289    ///
290    /// This method flips normals that point toward the centroid so they
291    /// all point outward. This assumes the point cloud represents the
292    /// exterior of an object.
293    pub fn orient_normals_outward(&mut self) {
294        let centroid = match self.centroid() {
295            Some(c) => c,
296            None => return,
297        };
298
299        for point in &mut self.points {
300            if let Some(ref mut normal) = point.normal {
301                let to_point = point.position - centroid;
302                if normal.dot(&to_point) < 0.0 {
303                    *normal = -*normal;
304                }
305            }
306        }
307    }
308
309    /// Reconstruct a triangle mesh from the point cloud.
310    ///
311    /// # Arguments
312    /// * `params` - Reconstruction parameters
313    ///
314    /// # Returns
315    /// The reconstructed mesh with statistics.
316    pub fn to_mesh(&self, params: &ReconstructionParams) -> MeshResult<ReconstructionResult> {
317        if self.is_empty() {
318            return Err(MeshError::EmptyMesh {
319                details: "Cannot reconstruct mesh from empty point cloud".to_string(),
320            });
321        }
322
323        match params.algorithm {
324            ReconstructionAlgorithm::BallPivoting => reconstruct_ball_pivoting(self, params),
325            ReconstructionAlgorithm::SdfBased => reconstruct_sdf_based(self, params),
326        }
327    }
328
329    /// Downsample the point cloud using voxel grid filtering.
330    ///
331    /// Points within each voxel are averaged into a single point.
332    ///
333    /// # Arguments
334    /// * `voxel_size` - Size of each voxel (in mm)
335    pub fn downsample(&self, voxel_size: f64) -> Self {
336        if self.is_empty() || voxel_size <= 0.0 {
337            return self.clone();
338        }
339
340        use std::collections::HashMap;
341
342        // Voxel data: (position_sum, normal_sum, count)
343        type VoxelData = (Vector3<f64>, Option<Vector3<f64>>, usize);
344
345        let (min_bound, _) = match self.bounds() {
346            Some(b) => b,
347            None => return self.clone(),
348        };
349
350        // Map voxel indices to accumulated points
351        let mut voxel_map: HashMap<(i64, i64, i64), VoxelData> = HashMap::new();
352
353        for point in &self.points {
354            let ix = ((point.position.x - min_bound.x) / voxel_size).floor() as i64;
355            let iy = ((point.position.y - min_bound.y) / voxel_size).floor() as i64;
356            let iz = ((point.position.z - min_bound.z) / voxel_size).floor() as i64;
357
358            let key = (ix, iy, iz);
359            let entry = voxel_map.entry(key).or_insert((Vector3::zeros(), None, 0));
360            entry.0 += point.position.coords;
361            if let Some(n) = point.normal {
362                entry.1 = Some(entry.1.unwrap_or(Vector3::zeros()) + n);
363            }
364            entry.2 += 1;
365        }
366
367        // Create averaged points
368        let mut result = PointCloud::with_capacity(voxel_map.len());
369        for (pos_sum, normal_sum, count) in voxel_map.values() {
370            let avg_pos = Point3::from(pos_sum / *count as f64);
371            let mut point = CloudPoint::new(avg_pos);
372            if let Some(n) = normal_sum {
373                let avg_normal = n / *count as f64;
374                let norm = avg_normal.norm();
375                if norm > 1e-10 {
376                    point.normal = Some(avg_normal / norm);
377                }
378            }
379            result.push(point);
380        }
381
382        debug!(
383            "Downsampled from {} to {} points (voxel_size={})",
384            self.len(),
385            result.len(),
386            voxel_size
387        );
388
389        result
390    }
391
392    /// Remove statistical outliers.
393    ///
394    /// Points whose mean distance to their k nearest neighbors exceeds
395    /// `mean + std_ratio * std` are removed.
396    ///
397    /// # Arguments
398    /// * `k` - Number of neighbors to consider
399    /// * `std_ratio` - Standard deviation multiplier threshold
400    pub fn remove_outliers(&self, k: usize, std_ratio: f64) -> Self {
401        if self.len() <= k {
402            return self.clone();
403        }
404
405        // Build KD-tree
406        let kdtree = build_kdtree(self);
407
408        // Compute mean distances to k neighbors for each point
409        let mut mean_distances = Vec::with_capacity(self.len());
410        for point in &self.points {
411            let neighbors = kdtree.nearest_n::<kiddo::SquaredEuclidean>(
412                &[point.position.x, point.position.y, point.position.z],
413                k + 1, // +1 because the point itself is included
414            );
415
416            let sum: f64 = neighbors
417                .iter()
418                .skip(1) // Skip self
419                .map(|n| n.distance.sqrt())
420                .sum();
421            mean_distances.push(sum / k as f64);
422        }
423
424        // Compute mean and std of mean distances
425        let global_mean: f64 = mean_distances.iter().sum::<f64>() / mean_distances.len() as f64;
426        let variance: f64 = mean_distances
427            .iter()
428            .map(|d| (d - global_mean).powi(2))
429            .sum::<f64>()
430            / mean_distances.len() as f64;
431        let std_dev = variance.sqrt();
432
433        let threshold = global_mean + std_ratio * std_dev;
434
435        // Filter points
436        let mut result = PointCloud::with_capacity(self.len());
437        for (point, &mean_dist) in self.points.iter().zip(mean_distances.iter()) {
438            if mean_dist <= threshold {
439                result.push(point.clone());
440            }
441        }
442
443        let removed = self.len() - result.len();
444        debug!("Removed {} outliers (threshold={:.4})", removed, threshold);
445
446        result
447    }
448
449    /// Translate the point cloud by the given vector.
450    pub fn translate(&mut self, offset: Vector3<f64>) {
451        for point in &mut self.points {
452            point.position += offset;
453        }
454    }
455
456    /// Scale the point cloud uniformly around the centroid.
457    pub fn scale(&mut self, factor: f64) {
458        let centroid = match self.centroid() {
459            Some(c) => c,
460            None => return,
461        };
462
463        for point in &mut self.points {
464            let offset = point.position - centroid;
465            point.position = centroid + offset * factor;
466        }
467    }
468}
469
470impl Default for PointCloud {
471    fn default() -> Self {
472        Self::new()
473    }
474}
475
476// ============================================================================
477// File formats
478// ============================================================================
479
480/// Supported point cloud file formats.
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum PointCloudFormat {
483    /// PLY (Stanford Polygon File Format)
484    Ply,
485    /// XYZ (simple ASCII x y z [nx ny nz] format)
486    Xyz,
487    /// PCD (Point Cloud Data - PCL format)
488    Pcd,
489}
490
491impl PointCloudFormat {
492    /// Detect format from file extension.
493    pub fn from_path(path: &Path) -> Option<Self> {
494        path.extension()
495            .and_then(|ext| ext.to_str())
496            .map(|ext| ext.to_lowercase())
497            .and_then(|ext| match ext.as_str() {
498                "ply" => Some(PointCloudFormat::Ply),
499                "xyz" | "txt" | "asc" | "pts" => Some(PointCloudFormat::Xyz),
500                "pcd" => Some(PointCloudFormat::Pcd),
501                _ => None,
502            })
503    }
504}
505
506/// Load point cloud from PLY file.
507fn load_ply_pointcloud(path: &Path) -> MeshResult<PointCloud> {
508    use ply_rs::parser::Parser;
509
510    let file = File::open(path).map_err(|e| MeshError::IoRead {
511        path: path.to_path_buf(),
512        source: e,
513    })?;
514    let mut reader = BufReader::new(file);
515
516    let parser = Parser::<ply_rs::ply::DefaultElement>::new();
517    let ply = parser
518        .read_ply(&mut reader)
519        .map_err(|e| MeshError::ParseError {
520            path: path.to_path_buf(),
521            details: format!("PLY parse error: {:?}", e),
522        })?;
523
524    let mut cloud = PointCloud::new();
525
526    if let Some(vertices) = ply.payload.get("vertex") {
527        cloud = PointCloud::with_capacity(vertices.len());
528
529        for vertex_element in vertices {
530            let x = get_ply_float(vertex_element.get("x"))?;
531            let y = get_ply_float(vertex_element.get("y"))?;
532            let z = get_ply_float(vertex_element.get("z"))?;
533
534            let mut point = CloudPoint::from_coords(x, y, z);
535
536            // Load normals if present
537            if let (Some(nx), Some(ny), Some(nz)) = (
538                vertex_element.get("nx"),
539                vertex_element.get("ny"),
540                vertex_element.get("nz"),
541            ) && let (Ok(nx), Ok(ny), Ok(nz)) = (
542                get_ply_float(Some(nx)),
543                get_ply_float(Some(ny)),
544                get_ply_float(Some(nz)),
545            ) {
546                point.normal = Some(Vector3::new(nx, ny, nz));
547            }
548
549            // Load colors if present
550            if let (Some(r), Some(g), Some(b)) = (
551                vertex_element.get("red"),
552                vertex_element.get("green"),
553                vertex_element.get("blue"),
554            ) && let (Ok(r), Ok(g), Ok(b)) = (
555                get_ply_u8(Some(r)),
556                get_ply_u8(Some(g)),
557                get_ply_u8(Some(b)),
558            ) {
559                point.color = Some(VertexColor::new(r, g, b));
560            }
561
562            // Load intensity if present
563            if let Some(intensity) = vertex_element.get("intensity")
564                && let Ok(i) = get_ply_float(Some(intensity))
565            {
566                point.intensity = Some(i as f32);
567            }
568
569            cloud.push(point);
570        }
571    }
572
573    Ok(cloud)
574}
575
576fn get_ply_float(prop: Option<&ply_rs::ply::Property>) -> MeshResult<f64> {
577    match prop {
578        Some(Property::Float(v)) => Ok(*v as f64),
579        Some(Property::Double(v)) => Ok(*v),
580        Some(Property::Int(v)) => Ok(*v as f64),
581        Some(Property::UInt(v)) => Ok(*v as f64),
582        Some(Property::Short(v)) => Ok(*v as f64),
583        Some(Property::UShort(v)) => Ok(*v as f64),
584        Some(Property::Char(v)) => Ok(*v as f64),
585        Some(Property::UChar(v)) => Ok(*v as f64),
586        _ => Err(MeshError::ParseError {
587            path: std::path::PathBuf::new(),
588            details: "Missing or invalid PLY property".to_string(),
589        }),
590    }
591}
592
593fn get_ply_u8(prop: Option<&ply_rs::ply::Property>) -> Result<u8, ()> {
594    match prop {
595        Some(Property::UChar(v)) => Ok(*v),
596        Some(Property::Char(v)) => Ok(*v as u8),
597        Some(Property::UShort(v)) => Ok((*v).min(255) as u8),
598        Some(Property::Short(v)) => Ok((*v).clamp(0, 255) as u8),
599        Some(Property::UInt(v)) => Ok((*v).min(255) as u8),
600        Some(Property::Int(v)) => Ok((*v).clamp(0, 255) as u8),
601        Some(Property::Float(v)) => Ok((v * 255.0).clamp(0.0, 255.0) as u8),
602        Some(Property::Double(v)) => Ok((v * 255.0).clamp(0.0, 255.0) as u8),
603        _ => Err(()),
604    }
605}
606
607/// Save point cloud to PLY file.
608fn save_ply_pointcloud(cloud: &PointCloud, path: &Path) -> MeshResult<()> {
609    let file = File::create(path).map_err(|e| MeshError::IoWrite {
610        path: path.to_path_buf(),
611        source: e,
612    })?;
613    let mut writer = BufWriter::new(file);
614
615    let has_normals = cloud.has_normals();
616    let has_colors = cloud.has_colors();
617
618    // Write header
619    writeln!(writer, "ply").map_err(|e| MeshError::IoWrite {
620        path: path.to_path_buf(),
621        source: e,
622    })?;
623    writeln!(writer, "format ascii 1.0").map_err(|e| MeshError::IoWrite {
624        path: path.to_path_buf(),
625        source: e,
626    })?;
627    writeln!(writer, "element vertex {}", cloud.len()).map_err(|e| MeshError::IoWrite {
628        path: path.to_path_buf(),
629        source: e,
630    })?;
631    writeln!(writer, "property float x").map_err(|e| MeshError::IoWrite {
632        path: path.to_path_buf(),
633        source: e,
634    })?;
635    writeln!(writer, "property float y").map_err(|e| MeshError::IoWrite {
636        path: path.to_path_buf(),
637        source: e,
638    })?;
639    writeln!(writer, "property float z").map_err(|e| MeshError::IoWrite {
640        path: path.to_path_buf(),
641        source: e,
642    })?;
643
644    if has_normals {
645        writeln!(writer, "property float nx").map_err(|e| MeshError::IoWrite {
646            path: path.to_path_buf(),
647            source: e,
648        })?;
649        writeln!(writer, "property float ny").map_err(|e| MeshError::IoWrite {
650            path: path.to_path_buf(),
651            source: e,
652        })?;
653        writeln!(writer, "property float nz").map_err(|e| MeshError::IoWrite {
654            path: path.to_path_buf(),
655            source: e,
656        })?;
657    }
658
659    if has_colors {
660        writeln!(writer, "property uchar red").map_err(|e| MeshError::IoWrite {
661            path: path.to_path_buf(),
662            source: e,
663        })?;
664        writeln!(writer, "property uchar green").map_err(|e| MeshError::IoWrite {
665            path: path.to_path_buf(),
666            source: e,
667        })?;
668        writeln!(writer, "property uchar blue").map_err(|e| MeshError::IoWrite {
669            path: path.to_path_buf(),
670            source: e,
671        })?;
672    }
673
674    writeln!(writer, "end_header").map_err(|e| MeshError::IoWrite {
675        path: path.to_path_buf(),
676        source: e,
677    })?;
678
679    // Write data
680    for point in &cloud.points {
681        let p = &point.position;
682        let mut line = format!("{} {} {}", p.x, p.y, p.z);
683
684        if has_normals {
685            if let Some(n) = &point.normal {
686                line.push_str(&format!(" {} {} {}", n.x, n.y, n.z));
687            } else {
688                line.push_str(" 0 0 0");
689            }
690        }
691
692        if has_colors {
693            if let Some(c) = &point.color {
694                line.push_str(&format!(" {} {} {}", c.r, c.g, c.b));
695            } else {
696                line.push_str(" 255 255 255");
697            }
698        }
699
700        writeln!(writer, "{}", line).map_err(|e| MeshError::IoWrite {
701            path: path.to_path_buf(),
702            source: e,
703        })?;
704    }
705
706    Ok(())
707}
708
709/// Load point cloud from XYZ file (simple ASCII format).
710fn load_xyz(path: &Path) -> MeshResult<PointCloud> {
711    let file = File::open(path).map_err(|e| MeshError::IoRead {
712        path: path.to_path_buf(),
713        source: e,
714    })?;
715    let reader = BufReader::new(file);
716
717    let mut cloud = PointCloud::new();
718
719    for (line_num, line) in reader.lines().enumerate() {
720        let line = line.map_err(|e| MeshError::IoRead {
721            path: path.to_path_buf(),
722            source: e,
723        })?;
724
725        let line = line.trim();
726        if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
727            continue;
728        }
729
730        let parts: Vec<&str> = line.split_whitespace().collect();
731        if parts.len() < 3 {
732            warn!("Skipping invalid line {} in XYZ file", line_num + 1);
733            continue;
734        }
735
736        let x: f64 = parts[0].parse().map_err(|_| MeshError::ParseError {
737            path: path.to_path_buf(),
738            details: format!("Invalid x coordinate on line {}", line_num + 1),
739        })?;
740        let y: f64 = parts[1].parse().map_err(|_| MeshError::ParseError {
741            path: path.to_path_buf(),
742            details: format!("Invalid y coordinate on line {}", line_num + 1),
743        })?;
744        let z: f64 = parts[2].parse().map_err(|_| MeshError::ParseError {
745            path: path.to_path_buf(),
746            details: format!("Invalid z coordinate on line {}", line_num + 1),
747        })?;
748
749        let mut point = CloudPoint::from_coords(x, y, z);
750
751        // Check for normals (6 values: x y z nx ny nz)
752        if parts.len() >= 6
753            && let (Ok(nx), Ok(ny), Ok(nz)) = (
754                parts[3].parse::<f64>(),
755                parts[4].parse::<f64>(),
756                parts[5].parse::<f64>(),
757            )
758        {
759            point.normal = Some(Vector3::new(nx, ny, nz));
760        }
761
762        // Check for colors (9 values: x y z nx ny nz r g b, or 6: x y z r g b)
763        if parts.len() >= 9 {
764            if let (Ok(r), Ok(g), Ok(b)) = (
765                parts[6].parse::<u8>(),
766                parts[7].parse::<u8>(),
767                parts[8].parse::<u8>(),
768            ) {
769                point.color = Some(VertexColor::new(r, g, b));
770            }
771        } else if parts.len() == 6 && point.normal.is_none() {
772            // Might be x y z r g b (no normals)
773            if let (Ok(r), Ok(g), Ok(b)) = (
774                parts[3].parse::<u8>(),
775                parts[4].parse::<u8>(),
776                parts[5].parse::<u8>(),
777            ) {
778                point.color = Some(VertexColor::new(r, g, b));
779            }
780        }
781
782        cloud.push(point);
783    }
784
785    Ok(cloud)
786}
787
788/// Save point cloud to XYZ file.
789fn save_xyz(cloud: &PointCloud, path: &Path) -> MeshResult<()> {
790    let file = File::create(path).map_err(|e| MeshError::IoWrite {
791        path: path.to_path_buf(),
792        source: e,
793    })?;
794    let mut writer = BufWriter::new(file);
795
796    for point in &cloud.points {
797        let mut line = format!(
798            "{} {} {}",
799            point.position.x, point.position.y, point.position.z
800        );
801
802        if let Some(n) = &point.normal {
803            line.push_str(&format!(" {} {} {}", n.x, n.y, n.z));
804        }
805
806        if let Some(c) = &point.color {
807            line.push_str(&format!(" {} {} {}", c.r, c.g, c.b));
808        }
809
810        writeln!(writer, "{}", line).map_err(|e| MeshError::IoWrite {
811            path: path.to_path_buf(),
812            source: e,
813        })?;
814    }
815
816    Ok(())
817}
818
819/// Load point cloud from PCD file (Point Cloud Library format).
820fn load_pcd(path: &Path) -> MeshResult<PointCloud> {
821    let file = File::open(path).map_err(|e| MeshError::IoRead {
822        path: path.to_path_buf(),
823        source: e,
824    })?;
825    let mut reader = BufReader::new(file);
826
827    // Parse header
828    let mut fields: Vec<String> = Vec::new();
829    let mut width: usize = 0;
830    let mut height: usize = 1;
831    #[allow(unused_assignments)]
832    let mut is_binary = false;
833
834    loop {
835        let mut line = String::new();
836        reader.read_line(&mut line).map_err(|e| MeshError::IoRead {
837            path: path.to_path_buf(),
838            source: e,
839        })?;
840
841        let line = line.trim();
842        if line.starts_with("DATA") {
843            is_binary = line.contains("binary");
844            break;
845        }
846
847        if line.starts_with("FIELDS") {
848            fields = line
849                .split_whitespace()
850                .skip(1)
851                .map(|s| s.to_lowercase())
852                .collect();
853        } else if line.starts_with("WIDTH") {
854            width = line
855                .split_whitespace()
856                .nth(1)
857                .and_then(|s| s.parse().ok())
858                .unwrap_or(0);
859        } else if line.starts_with("HEIGHT") {
860            height = line
861                .split_whitespace()
862                .nth(1)
863                .and_then(|s| s.parse().ok())
864                .unwrap_or(1);
865        }
866    }
867
868    if is_binary {
869        return Err(MeshError::ParseError {
870            path: path.to_path_buf(),
871            details: "Binary PCD files not yet supported, use ASCII".to_string(),
872        });
873    }
874
875    let expected_points = width * height;
876    let mut cloud = PointCloud::with_capacity(expected_points);
877
878    // Find indices for common fields
879    let x_idx = fields.iter().position(|f| f == "x");
880    let y_idx = fields.iter().position(|f| f == "y");
881    let z_idx = fields.iter().position(|f| f == "z");
882    let nx_idx = fields.iter().position(|f| f == "normal_x" || f == "nx");
883    let ny_idx = fields.iter().position(|f| f == "normal_y" || f == "ny");
884    let nz_idx = fields.iter().position(|f| f == "normal_z" || f == "nz");
885    let r_idx = fields.iter().position(|f| f == "r" || f == "red");
886    let g_idx = fields.iter().position(|f| f == "g" || f == "green");
887    let b_idx = fields.iter().position(|f| f == "b" || f == "blue");
888    let intensity_idx = fields.iter().position(|f| f == "intensity");
889
890    // Read data
891    for line in reader.lines() {
892        let line = line.map_err(|e| MeshError::IoRead {
893            path: path.to_path_buf(),
894            source: e,
895        })?;
896
897        let parts: Vec<&str> = line.split_whitespace().collect();
898        if parts.is_empty() {
899            continue;
900        }
901
902        // Extract coordinates
903        let x = x_idx
904            .and_then(|i| parts.get(i))
905            .and_then(|s| s.parse::<f64>().ok())
906            .unwrap_or(0.0);
907        let y = y_idx
908            .and_then(|i| parts.get(i))
909            .and_then(|s| s.parse::<f64>().ok())
910            .unwrap_or(0.0);
911        let z = z_idx
912            .and_then(|i| parts.get(i))
913            .and_then(|s| s.parse::<f64>().ok())
914            .unwrap_or(0.0);
915
916        // Skip invalid points (NaN)
917        if x.is_nan() || y.is_nan() || z.is_nan() {
918            continue;
919        }
920
921        let mut point = CloudPoint::from_coords(x, y, z);
922
923        // Extract normals
924        if let (Some(nxi), Some(nyi), Some(nzi)) = (nx_idx, ny_idx, nz_idx)
925            && let (Some(nx), Some(ny), Some(nz)) = (
926                parts.get(nxi).and_then(|s| s.parse::<f64>().ok()),
927                parts.get(nyi).and_then(|s| s.parse::<f64>().ok()),
928                parts.get(nzi).and_then(|s| s.parse::<f64>().ok()),
929            )
930            && !nx.is_nan()
931            && !ny.is_nan()
932            && !nz.is_nan()
933        {
934            point.normal = Some(Vector3::new(nx, ny, nz));
935        }
936
937        // Extract colors
938        if let (Some(ri), Some(gi), Some(bi)) = (r_idx, g_idx, b_idx)
939            && let (Some(r), Some(g), Some(b)) = (
940                parts.get(ri).and_then(|s| s.parse::<u8>().ok()),
941                parts.get(gi).and_then(|s| s.parse::<u8>().ok()),
942                parts.get(bi).and_then(|s| s.parse::<u8>().ok()),
943            )
944        {
945            point.color = Some(VertexColor::new(r, g, b));
946        }
947
948        // Extract intensity
949        if let Some(ii) = intensity_idx
950            && let Some(i) = parts.get(ii).and_then(|s| s.parse::<f32>().ok())
951        {
952            point.intensity = Some(i);
953        }
954
955        cloud.push(point);
956    }
957
958    Ok(cloud)
959}
960
961/// Save point cloud to PCD file.
962fn save_pcd(cloud: &PointCloud, path: &Path) -> MeshResult<()> {
963    let file = File::create(path).map_err(|e| MeshError::IoWrite {
964        path: path.to_path_buf(),
965        source: e,
966    })?;
967    let mut writer = BufWriter::new(file);
968
969    let has_normals = cloud.has_normals();
970    let has_colors = cloud.has_colors();
971
972    // Write header
973    writeln!(writer, "# .PCD v0.7 - Point Cloud Data file format").map_err(|e| {
974        MeshError::IoWrite {
975            path: path.to_path_buf(),
976            source: e,
977        }
978    })?;
979    writeln!(writer, "VERSION 0.7").map_err(|e| MeshError::IoWrite {
980        path: path.to_path_buf(),
981        source: e,
982    })?;
983
984    // Build fields list
985    let mut fields = String::from("FIELDS x y z");
986    let mut sizes = String::from("SIZE 4 4 4");
987    let mut types = String::from("TYPE F F F");
988    let mut counts = String::from("COUNT 1 1 1");
989
990    if has_normals {
991        fields.push_str(" normal_x normal_y normal_z");
992        sizes.push_str(" 4 4 4");
993        types.push_str(" F F F");
994        counts.push_str(" 1 1 1");
995    }
996
997    if has_colors {
998        fields.push_str(" red green blue");
999        sizes.push_str(" 1 1 1");
1000        types.push_str(" U U U");
1001        counts.push_str(" 1 1 1");
1002    }
1003
1004    writeln!(writer, "{}", fields).map_err(|e| MeshError::IoWrite {
1005        path: path.to_path_buf(),
1006        source: e,
1007    })?;
1008    writeln!(writer, "{}", sizes).map_err(|e| MeshError::IoWrite {
1009        path: path.to_path_buf(),
1010        source: e,
1011    })?;
1012    writeln!(writer, "{}", types).map_err(|e| MeshError::IoWrite {
1013        path: path.to_path_buf(),
1014        source: e,
1015    })?;
1016    writeln!(writer, "{}", counts).map_err(|e| MeshError::IoWrite {
1017        path: path.to_path_buf(),
1018        source: e,
1019    })?;
1020    writeln!(writer, "WIDTH {}", cloud.len()).map_err(|e| MeshError::IoWrite {
1021        path: path.to_path_buf(),
1022        source: e,
1023    })?;
1024    writeln!(writer, "HEIGHT 1").map_err(|e| MeshError::IoWrite {
1025        path: path.to_path_buf(),
1026        source: e,
1027    })?;
1028    writeln!(writer, "VIEWPOINT 0 0 0 1 0 0 0").map_err(|e| MeshError::IoWrite {
1029        path: path.to_path_buf(),
1030        source: e,
1031    })?;
1032    writeln!(writer, "POINTS {}", cloud.len()).map_err(|e| MeshError::IoWrite {
1033        path: path.to_path_buf(),
1034        source: e,
1035    })?;
1036    writeln!(writer, "DATA ascii").map_err(|e| MeshError::IoWrite {
1037        path: path.to_path_buf(),
1038        source: e,
1039    })?;
1040
1041    // Write data
1042    for point in &cloud.points {
1043        let mut line = format!(
1044            "{} {} {}",
1045            point.position.x, point.position.y, point.position.z
1046        );
1047
1048        if has_normals {
1049            if let Some(n) = &point.normal {
1050                line.push_str(&format!(" {} {} {}", n.x, n.y, n.z));
1051            } else {
1052                line.push_str(" 0 0 0");
1053            }
1054        }
1055
1056        if has_colors {
1057            if let Some(c) = &point.color {
1058                line.push_str(&format!(" {} {} {}", c.r, c.g, c.b));
1059            } else {
1060                line.push_str(" 255 255 255");
1061            }
1062        }
1063
1064        writeln!(writer, "{}", line).map_err(|e| MeshError::IoWrite {
1065            path: path.to_path_buf(),
1066            source: e,
1067        })?;
1068    }
1069
1070    Ok(())
1071}
1072
1073// ============================================================================
1074// Normal estimation
1075// ============================================================================
1076
1077/// Build a KD-tree from the point cloud.
1078fn build_kdtree(cloud: &PointCloud) -> kiddo::KdTree<f64, 3> {
1079    let mut kdtree = kiddo::KdTree::new();
1080    for (i, point) in cloud.points.iter().enumerate() {
1081        kdtree.add(
1082            &[point.position.x, point.position.y, point.position.z],
1083            i as u64,
1084        );
1085    }
1086    kdtree
1087}
1088
1089/// Estimate normals using PCA on local neighborhoods.
1090fn estimate_normals(cloud: &PointCloud, k: usize) -> MeshResult<Vec<Vector3<f64>>> {
1091    let kdtree = build_kdtree(cloud);
1092    let mut normals = Vec::with_capacity(cloud.len());
1093
1094    for point in &cloud.points {
1095        let neighbors = kdtree.nearest_n::<kiddo::SquaredEuclidean>(
1096            &[point.position.x, point.position.y, point.position.z],
1097            k,
1098        );
1099
1100        // Collect neighbor positions
1101        let neighbor_points: Vec<Point3<f64>> = neighbors
1102            .iter()
1103            .map(|n| cloud.points[n.item as usize].position)
1104            .collect();
1105
1106        // Compute centroid
1107        let centroid: Vector3<f64> = neighbor_points
1108            .iter()
1109            .map(|p| p.coords)
1110            .fold(Vector3::zeros(), |acc, v| acc + v)
1111            / neighbor_points.len() as f64;
1112
1113        // Build covariance matrix
1114        let mut cov = nalgebra::Matrix3::zeros();
1115        for np in &neighbor_points {
1116            let d = np.coords - centroid;
1117            cov += d * d.transpose();
1118        }
1119
1120        // Eigen decomposition (smallest eigenvector is the normal)
1121        let eig = cov.symmetric_eigen();
1122        let mut min_idx = 0;
1123        let mut min_val = eig.eigenvalues[0];
1124        for i in 1..3 {
1125            if eig.eigenvalues[i] < min_val {
1126                min_val = eig.eigenvalues[i];
1127                min_idx = i;
1128            }
1129        }
1130
1131        let normal = eig.eigenvectors.column(min_idx).into_owned();
1132        let norm = normal.norm();
1133        if norm > 1e-10 {
1134            normals.push(normal / norm);
1135        } else {
1136            normals.push(Vector3::new(0.0, 0.0, 1.0));
1137        }
1138    }
1139
1140    Ok(normals)
1141}
1142
1143// ============================================================================
1144// Surface reconstruction
1145// ============================================================================
1146
1147/// Algorithm for surface reconstruction.
1148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1149pub enum ReconstructionAlgorithm {
1150    /// Ball-pivoting algorithm (works well for uniformly sampled point clouds).
1151    BallPivoting,
1152    /// SDF-based reconstruction (similar to Poisson, works well for noisy data).
1153    SdfBased,
1154}
1155
1156/// Parameters for surface reconstruction.
1157#[derive(Debug, Clone)]
1158pub struct ReconstructionParams {
1159    /// Algorithm to use.
1160    pub algorithm: ReconstructionAlgorithm,
1161
1162    /// Ball radius for ball-pivoting (auto-detected if None).
1163    pub ball_radius: Option<f64>,
1164
1165    /// Voxel size for SDF-based reconstruction (auto-detected if None).
1166    pub voxel_size: Option<f64>,
1167
1168    /// Number of neighbors for normal estimation if normals are missing.
1169    pub normal_neighbors: usize,
1170
1171    /// Whether to auto-estimate normals if missing.
1172    pub auto_estimate_normals: bool,
1173}
1174
1175impl Default for ReconstructionParams {
1176    fn default() -> Self {
1177        Self {
1178            algorithm: ReconstructionAlgorithm::BallPivoting,
1179            ball_radius: None,
1180            voxel_size: None,
1181            normal_neighbors: 16,
1182            auto_estimate_normals: true,
1183        }
1184    }
1185}
1186
1187impl ReconstructionParams {
1188    /// Create params for ball-pivoting with auto-detected radius.
1189    pub fn ball_pivoting() -> Self {
1190        Self {
1191            algorithm: ReconstructionAlgorithm::BallPivoting,
1192            ..Default::default()
1193        }
1194    }
1195
1196    /// Create params for ball-pivoting with specific radius.
1197    pub fn ball_pivoting_with_radius(radius: f64) -> Self {
1198        Self {
1199            algorithm: ReconstructionAlgorithm::BallPivoting,
1200            ball_radius: Some(radius),
1201            ..Default::default()
1202        }
1203    }
1204
1205    /// Create params for SDF-based reconstruction.
1206    pub fn sdf_based() -> Self {
1207        Self {
1208            algorithm: ReconstructionAlgorithm::SdfBased,
1209            ..Default::default()
1210        }
1211    }
1212
1213    /// Create params for SDF-based reconstruction with specific voxel size.
1214    pub fn sdf_based_with_voxel_size(voxel_size: f64) -> Self {
1215        Self {
1216            algorithm: ReconstructionAlgorithm::SdfBased,
1217            voxel_size: Some(voxel_size),
1218            ..Default::default()
1219        }
1220    }
1221}
1222
1223/// Result of surface reconstruction.
1224#[derive(Debug)]
1225pub struct ReconstructionResult {
1226    /// The reconstructed mesh.
1227    pub mesh: Mesh,
1228
1229    /// Number of faces generated.
1230    pub face_count: usize,
1231
1232    /// Number of vertices in the output.
1233    pub vertex_count: usize,
1234
1235    /// Effective ball radius used (for ball-pivoting).
1236    pub ball_radius: Option<f64>,
1237
1238    /// Effective voxel size used (for SDF-based).
1239    pub voxel_size: Option<f64>,
1240
1241    /// Whether normals were auto-estimated.
1242    pub normals_estimated: bool,
1243}
1244
1245/// Ball-pivoting surface reconstruction.
1246fn reconstruct_ball_pivoting(
1247    cloud: &PointCloud,
1248    params: &ReconstructionParams,
1249) -> MeshResult<ReconstructionResult> {
1250    let start = std::time::Instant::now();
1251
1252    // Ensure normals are present
1253    let mut cloud = cloud.clone();
1254    let normals_estimated = if !cloud.has_normals() && params.auto_estimate_normals {
1255        cloud = cloud.with_estimated_normals(params.normal_neighbors)?;
1256        cloud.orient_normals_outward();
1257        true
1258    } else {
1259        false
1260    };
1261
1262    if !cloud.has_normals() {
1263        return Err(MeshError::RepairFailed {
1264            details: "Ball-pivoting requires point normals".to_string(),
1265        });
1266    }
1267
1268    // Estimate ball radius if not provided
1269    let ball_radius = params
1270        .ball_radius
1271        .unwrap_or_else(|| estimate_point_spacing(&cloud) * 2.0);
1272
1273    info!(
1274        "Ball-pivoting reconstruction: {} points, radius={:.4}",
1275        cloud.len(),
1276        ball_radius
1277    );
1278
1279    // Build KD-tree for efficient neighbor lookups
1280    let kdtree = build_kdtree(&cloud);
1281
1282    // Ball-pivoting algorithm implementation
1283    let mut mesh = Mesh::new();
1284    let mut used_points: Vec<bool> = vec![false; cloud.len()];
1285    let mut edge_front: Vec<(usize, usize)> = Vec::new();
1286
1287    // Convert cloud points to mesh vertices
1288    for point in &cloud.points {
1289        mesh.vertices.push(point.to_vertex());
1290    }
1291
1292    // Find a seed triangle
1293    if let Some(seed) = find_seed_triangle(&cloud, &kdtree, ball_radius, &used_points) {
1294        mesh.faces
1295            .push([seed.0 as u32, seed.1 as u32, seed.2 as u32]);
1296        used_points[seed.0] = true;
1297        used_points[seed.1] = true;
1298        used_points[seed.2] = true;
1299
1300        // Add edges to front
1301        edge_front.push((seed.0, seed.1));
1302        edge_front.push((seed.1, seed.2));
1303        edge_front.push((seed.2, seed.0));
1304    }
1305
1306    // Process edge front
1307    let mut iterations = 0;
1308    let max_iterations = cloud.len() * 10;
1309
1310    while !edge_front.is_empty() && iterations < max_iterations {
1311        iterations += 1;
1312
1313        let (v1, v2) = edge_front.pop().unwrap();
1314
1315        // Try to find a point to pivot to
1316        if let Some(v3) = find_pivot_point(
1317            &cloud,
1318            &kdtree,
1319            v1,
1320            v2,
1321            ball_radius,
1322            &used_points,
1323            &mesh.faces,
1324        ) {
1325            mesh.faces.push([v1 as u32, v2 as u32, v3 as u32]);
1326
1327            if !used_points[v3] {
1328                used_points[v3] = true;
1329                // Add new edges to front (in reverse order for correct winding)
1330                edge_front.push((v2, v3));
1331                edge_front.push((v3, v1));
1332            }
1333        }
1334    }
1335
1336    debug!(
1337        "Ball-pivoting completed in {:?}: {} faces",
1338        start.elapsed(),
1339        mesh.faces.len()
1340    );
1341
1342    Ok(ReconstructionResult {
1343        face_count: mesh.faces.len(),
1344        vertex_count: mesh.vertices.len(),
1345        ball_radius: Some(ball_radius),
1346        voxel_size: None,
1347        normals_estimated,
1348        mesh,
1349    })
1350}
1351
1352/// Estimate average point spacing using k-nearest neighbors.
1353fn estimate_point_spacing(cloud: &PointCloud) -> f64 {
1354    if cloud.len() < 2 {
1355        return 1.0;
1356    }
1357
1358    let kdtree = build_kdtree(cloud);
1359    let sample_size = cloud.len().min(1000);
1360    let step = cloud.len() / sample_size;
1361
1362    let mut total_spacing = 0.0;
1363    let mut count = 0;
1364
1365    for i in (0..cloud.len()).step_by(step.max(1)) {
1366        let point = &cloud.points[i];
1367        let neighbors = kdtree.nearest_n::<kiddo::SquaredEuclidean>(
1368            &[point.position.x, point.position.y, point.position.z],
1369            2,
1370        );
1371
1372        if neighbors.len() >= 2 {
1373            total_spacing += neighbors[1].distance.sqrt();
1374            count += 1;
1375        }
1376    }
1377
1378    if count > 0 {
1379        total_spacing / count as f64
1380    } else {
1381        1.0
1382    }
1383}
1384
1385/// Find a seed triangle for ball-pivoting.
1386fn find_seed_triangle(
1387    cloud: &PointCloud,
1388    kdtree: &kiddo::KdTree<f64, 3>,
1389    ball_radius: f64,
1390    used_points: &[bool],
1391) -> Option<(usize, usize, usize)> {
1392    let search_radius = ball_radius * 2.0;
1393
1394    for (i, point) in cloud.points.iter().enumerate() {
1395        if used_points[i] {
1396            continue;
1397        }
1398
1399        let neighbors = kdtree.within::<kiddo::SquaredEuclidean>(
1400            &[point.position.x, point.position.y, point.position.z],
1401            search_radius * search_radius,
1402        );
1403
1404        // Find pairs of neighbors that form valid triangles
1405        for j_item in &neighbors {
1406            let j = j_item.item as usize;
1407            if j == i || used_points[j] {
1408                continue;
1409            }
1410
1411            for k_item in &neighbors {
1412                let k = k_item.item as usize;
1413                if k == i || k == j || used_points[k] {
1414                    continue;
1415                }
1416
1417                // Check if triangle is valid
1418                if is_valid_triangle(cloud, i, j, k, ball_radius) {
1419                    return Some((i, j, k));
1420                }
1421            }
1422        }
1423    }
1424
1425    None
1426}
1427
1428/// Check if three points form a valid triangle for ball-pivoting.
1429fn is_valid_triangle(cloud: &PointCloud, i: usize, j: usize, k: usize, ball_radius: f64) -> bool {
1430    let p1 = &cloud.points[i].position;
1431    let p2 = &cloud.points[j].position;
1432    let p3 = &cloud.points[k].position;
1433
1434    // Check edge lengths
1435    let e1 = (p2 - p1).norm();
1436    let e2 = (p3 - p2).norm();
1437    let e3 = (p1 - p3).norm();
1438
1439    let max_edge = ball_radius * 2.0;
1440    if e1 > max_edge || e2 > max_edge || e3 > max_edge {
1441        return false;
1442    }
1443
1444    // Check if triangle normal is consistent with vertex normals
1445    let edge1 = p2 - p1;
1446    let edge2 = p3 - p1;
1447    let face_normal = edge1.cross(&edge2);
1448    let face_normal_norm = face_normal.norm();
1449
1450    if face_normal_norm < 1e-10 {
1451        return false; // Degenerate triangle
1452    }
1453
1454    let face_normal = face_normal / face_normal_norm;
1455
1456    // Check consistency with vertex normals
1457    if let (Some(n1), Some(n2), Some(n3)) = (
1458        &cloud.points[i].normal,
1459        &cloud.points[j].normal,
1460        &cloud.points[k].normal,
1461    ) {
1462        let avg_normal = (n1 + n2 + n3) / 3.0;
1463        if face_normal.dot(&avg_normal) < 0.0 {
1464            return false;
1465        }
1466    }
1467
1468    true
1469}
1470
1471/// Find a pivot point for an edge.
1472fn find_pivot_point(
1473    cloud: &PointCloud,
1474    kdtree: &kiddo::KdTree<f64, 3>,
1475    v1: usize,
1476    v2: usize,
1477    ball_radius: f64,
1478    used_points: &[bool],
1479    existing_faces: &[[u32; 3]],
1480) -> Option<usize> {
1481    let p1 = &cloud.points[v1].position;
1482    let p2 = &cloud.points[v2].position;
1483    let midpoint = Point3::from((p1.coords + p2.coords) / 2.0);
1484
1485    let search_radius = ball_radius * 2.0;
1486    let neighbors = kdtree.within::<kiddo::SquaredEuclidean>(
1487        &[midpoint.x, midpoint.y, midpoint.z],
1488        search_radius * search_radius,
1489    );
1490
1491    let mut best_candidate: Option<usize> = None;
1492    let mut best_angle = f64::MAX;
1493
1494    for item in &neighbors {
1495        let idx = item.item as usize;
1496        if idx == v1 || idx == v2 {
1497            continue;
1498        }
1499
1500        // Check if this triangle already exists
1501        let face_exists = existing_faces.iter().any(|f| {
1502            let mut sorted = [f[0] as usize, f[1] as usize, f[2] as usize];
1503            sorted.sort_unstable();
1504            let mut test = [v1, v2, idx];
1505            test.sort_unstable();
1506            sorted == test
1507        });
1508
1509        if face_exists {
1510            continue;
1511        }
1512
1513        // Check if valid triangle
1514        if !is_valid_triangle(cloud, v1, v2, idx, ball_radius) {
1515            continue;
1516        }
1517
1518        // Compute pivot angle (prefer smaller angles)
1519        let p3 = &cloud.points[idx].position;
1520        let edge = p2 - p1;
1521        let to_p3 = p3 - p1;
1522        let angle = edge.angle(&to_p3);
1523
1524        // Prefer unused points, but allow used points if no unused available
1525        let penalty = if used_points[idx] { 1000.0 } else { 0.0 };
1526        let score = angle + penalty;
1527
1528        if score < best_angle {
1529            best_angle = score;
1530            best_candidate = Some(idx);
1531        }
1532    }
1533
1534    best_candidate
1535}
1536
1537/// SDF-based surface reconstruction (similar to Poisson).
1538fn reconstruct_sdf_based(
1539    cloud: &PointCloud,
1540    params: &ReconstructionParams,
1541) -> MeshResult<ReconstructionResult> {
1542    let start = std::time::Instant::now();
1543
1544    // Ensure normals are present
1545    let mut cloud = cloud.clone();
1546    let normals_estimated = if !cloud.has_normals() && params.auto_estimate_normals {
1547        cloud = cloud.with_estimated_normals(params.normal_neighbors)?;
1548        cloud.orient_normals_outward();
1549        true
1550    } else {
1551        false
1552    };
1553
1554    if !cloud.has_normals() {
1555        return Err(MeshError::RepairFailed {
1556            details: "SDF reconstruction requires point normals".to_string(),
1557        });
1558    }
1559
1560    // Estimate voxel size if not provided
1561    let point_spacing = estimate_point_spacing(&cloud);
1562    let voxel_size = params.voxel_size.unwrap_or(point_spacing);
1563
1564    info!(
1565        "SDF reconstruction: {} points, voxel_size={:.4}",
1566        cloud.len(),
1567        voxel_size
1568    );
1569
1570    // Get bounds with padding
1571    let (min_bound, max_bound) = cloud.bounds().ok_or_else(|| MeshError::EmptyMesh {
1572        details: "Point cloud has no bounds".to_string(),
1573    })?;
1574
1575    let padding = voxel_size * 3.0;
1576    let min_bound = Point3::new(
1577        min_bound.x - padding,
1578        min_bound.y - padding,
1579        min_bound.z - padding,
1580    );
1581    let max_bound = Point3::new(
1582        max_bound.x + padding,
1583        max_bound.y + padding,
1584        max_bound.z + padding,
1585    );
1586
1587    // Compute grid dimensions
1588    let extent = max_bound - min_bound;
1589    let dims = [
1590        ((extent.x / voxel_size).ceil() as usize).max(2),
1591        ((extent.y / voxel_size).ceil() as usize).max(2),
1592        ((extent.z / voxel_size).ceil() as usize).max(2),
1593    ];
1594
1595    debug!("SDF grid: {}x{}x{} voxels", dims[0], dims[1], dims[2]);
1596
1597    // Build KD-tree
1598    let kdtree = build_kdtree(&cloud);
1599
1600    // Compute SDF values at grid points
1601    let total_voxels = dims[0] * dims[1] * dims[2];
1602    let mut sdf_values = vec![f64::MAX; total_voxels];
1603
1604    for iz in 0..dims[2] {
1605        for iy in 0..dims[1] {
1606            for ix in 0..dims[0] {
1607                let idx = ix + iy * dims[0] + iz * dims[0] * dims[1];
1608                let pos = Point3::new(
1609                    min_bound.x + (ix as f64 + 0.5) * voxel_size,
1610                    min_bound.y + (iy as f64 + 0.5) * voxel_size,
1611                    min_bound.z + (iz as f64 + 0.5) * voxel_size,
1612                );
1613
1614                // Find nearest point
1615                let nearest = kdtree.nearest_one::<kiddo::SquaredEuclidean>(&[pos.x, pos.y, pos.z]);
1616                let nearest_point = &cloud.points[nearest.item as usize];
1617                let dist = nearest.distance.sqrt();
1618
1619                // Compute signed distance using normal
1620                if let Some(normal) = &nearest_point.normal {
1621                    let to_grid = pos - nearest_point.position;
1622                    let sign = if to_grid.dot(normal) >= 0.0 {
1623                        1.0
1624                    } else {
1625                        -1.0
1626                    };
1627                    sdf_values[idx] = sign * dist;
1628                } else {
1629                    sdf_values[idx] = dist;
1630                }
1631            }
1632        }
1633    }
1634
1635    // Extract isosurface using marching cubes (via fast_surface_nets)
1636    let mesh = extract_isosurface_from_sdf(&sdf_values, dims, min_bound, voxel_size)?;
1637
1638    debug!(
1639        "SDF reconstruction completed in {:?}: {} faces",
1640        start.elapsed(),
1641        mesh.faces.len()
1642    );
1643
1644    Ok(ReconstructionResult {
1645        face_count: mesh.faces.len(),
1646        vertex_count: mesh.vertices.len(),
1647        ball_radius: None,
1648        voxel_size: Some(voxel_size),
1649        normals_estimated,
1650        mesh,
1651    })
1652}
1653
1654/// Extract isosurface from SDF values using marching cubes.
1655fn extract_isosurface_from_sdf(
1656    sdf_values: &[f64],
1657    dims: [usize; 3],
1658    min_bound: Point3<f64>,
1659    voxel_size: f64,
1660) -> MeshResult<Mesh> {
1661    use fast_surface_nets::ndshape::ConstShape;
1662    use fast_surface_nets::{SurfaceNetsBuffer, ndshape::ConstShape3u32, surface_nets};
1663
1664    // Convert to the format expected by fast_surface_nets
1665    // We need a padded grid for boundary handling
1666    type SampleShape = ConstShape3u32<66, 66, 66>;
1667
1668    if dims[0] > 64 || dims[1] > 64 || dims[2] > 64 {
1669        // For large grids, fall back to simple marching cubes
1670        return extract_isosurface_simple(sdf_values, dims, min_bound, voxel_size);
1671    }
1672
1673    // Pad the SDF values
1674    let mut padded_sdf = vec![1.0f32; SampleShape::SIZE as usize];
1675    for iz in 0..dims[2] {
1676        for iy in 0..dims[1] {
1677            for ix in 0..dims[0] {
1678                let src_idx = ix + iy * dims[0] + iz * dims[0] * dims[1];
1679                let dst_idx = (ix + 1) + (iy + 1) * 66 + (iz + 1) * 66 * 66;
1680                padded_sdf[dst_idx] = sdf_values[src_idx] as f32;
1681            }
1682        }
1683    }
1684
1685    let mut buffer = SurfaceNetsBuffer::default();
1686    surface_nets(&padded_sdf, &SampleShape {}, [0; 3], [65; 3], &mut buffer);
1687
1688    // Convert to mesh
1689    let mut mesh = Mesh::new();
1690
1691    for pos in &buffer.positions {
1692        let world_pos = Point3::new(
1693            min_bound.x + (pos[0] as f64 - 1.0) * voxel_size,
1694            min_bound.y + (pos[1] as f64 - 1.0) * voxel_size,
1695            min_bound.z + (pos[2] as f64 - 1.0) * voxel_size,
1696        );
1697        mesh.vertices.push(Vertex::new(world_pos));
1698    }
1699
1700    for chunk in buffer.indices.chunks(3) {
1701        if chunk.len() == 3 {
1702            mesh.faces.push([chunk[0], chunk[1], chunk[2]]);
1703        }
1704    }
1705
1706    Ok(mesh)
1707}
1708
1709/// Simple marching cubes for larger grids.
1710fn extract_isosurface_simple(
1711    sdf_values: &[f64],
1712    dims: [usize; 3],
1713    min_bound: Point3<f64>,
1714    voxel_size: f64,
1715) -> MeshResult<Mesh> {
1716    // Simplified marching cubes implementation
1717    let mut mesh = Mesh::new();
1718    let mut vertex_map: std::collections::HashMap<(usize, usize, usize, u8), u32> =
1719        std::collections::HashMap::new();
1720
1721    let get_sdf = |ix: usize, iy: usize, iz: usize| -> f64 {
1722        if ix >= dims[0] || iy >= dims[1] || iz >= dims[2] {
1723            return 1.0;
1724        }
1725        sdf_values[ix + iy * dims[0] + iz * dims[0] * dims[1]]
1726    };
1727
1728    for iz in 0..dims[2] - 1 {
1729        for iy in 0..dims[1] - 1 {
1730            for ix in 0..dims[0] - 1 {
1731                // Get corner values
1732                let corners = [
1733                    get_sdf(ix, iy, iz),
1734                    get_sdf(ix + 1, iy, iz),
1735                    get_sdf(ix + 1, iy + 1, iz),
1736                    get_sdf(ix, iy + 1, iz),
1737                    get_sdf(ix, iy, iz + 1),
1738                    get_sdf(ix + 1, iy, iz + 1),
1739                    get_sdf(ix + 1, iy + 1, iz + 1),
1740                    get_sdf(ix, iy + 1, iz + 1),
1741                ];
1742
1743                // Compute cube index
1744                let mut cube_index = 0u8;
1745                for (i, &val) in corners.iter().enumerate() {
1746                    if val < 0.0 {
1747                        cube_index |= 1 << i;
1748                    }
1749                }
1750
1751                if cube_index == 0 || cube_index == 255 {
1752                    continue;
1753                }
1754
1755                // Generate triangles for this cube configuration
1756                let triangles = get_marching_cubes_triangles(cube_index);
1757                for tri in triangles {
1758                    let mut face_indices = [0u32; 3];
1759                    for (i, &edge) in tri.iter().enumerate() {
1760                        let key = (ix, iy, iz, edge);
1761                        let vertex_idx = *vertex_map.entry(key).or_insert_with(|| {
1762                            let (v1, v2) = edge_to_vertices(edge);
1763                            let t = -corners[v1 as usize]
1764                                / (corners[v2 as usize] - corners[v1 as usize] + 1e-10);
1765                            let p1 = corner_position(ix, iy, iz, v1);
1766                            let p2 = corner_position(ix, iy, iz, v2);
1767                            let pos = Point3::new(
1768                                min_bound.x + (p1.0 + t * (p2.0 - p1.0)) * voxel_size,
1769                                min_bound.y + (p1.1 + t * (p2.1 - p1.1)) * voxel_size,
1770                                min_bound.z + (p1.2 + t * (p2.2 - p1.2)) * voxel_size,
1771                            );
1772                            mesh.vertices.push(Vertex::new(pos));
1773                            (mesh.vertices.len() - 1) as u32
1774                        });
1775                        face_indices[i] = vertex_idx;
1776                    }
1777                    mesh.faces.push(face_indices);
1778                }
1779            }
1780        }
1781    }
1782
1783    Ok(mesh)
1784}
1785
1786/// Get corner position offset for marching cubes.
1787fn corner_position(ix: usize, iy: usize, iz: usize, corner: u8) -> (f64, f64, f64) {
1788    let dx = if corner & 1 != 0 { 1.0 } else { 0.0 };
1789    let dy = if corner & 2 != 0 { 1.0 } else { 0.0 };
1790    let dz = if corner & 4 != 0 { 1.0 } else { 0.0 };
1791    (ix as f64 + dx, iy as f64 + dy, iz as f64 + dz)
1792}
1793
1794/// Convert edge index to vertex indices.
1795fn edge_to_vertices(edge: u8) -> (u8, u8) {
1796    const EDGES: [(u8, u8); 12] = [
1797        (0, 1),
1798        (1, 2),
1799        (2, 3),
1800        (3, 0), // Bottom
1801        (4, 5),
1802        (5, 6),
1803        (6, 7),
1804        (7, 4), // Top
1805        (0, 4),
1806        (1, 5),
1807        (2, 6),
1808        (3, 7), // Vertical
1809    ];
1810    EDGES[edge as usize]
1811}
1812
1813/// Get triangles for a cube configuration (simplified lookup).
1814fn get_marching_cubes_triangles(cube_index: u8) -> Vec<[u8; 3]> {
1815    // Simplified: only handle basic cases
1816    // In production, use a full 256-entry lookup table
1817    let mut triangles = Vec::new();
1818
1819    // Count set bits to determine complexity
1820    let count = cube_index.count_ones();
1821
1822    if count == 1 {
1823        // Single corner inside: one triangle
1824        let corner = cube_index.trailing_zeros() as u8;
1825        let edges = corner_edges(corner);
1826        triangles.push(edges);
1827    } else if count == 7 {
1828        // Single corner outside: one triangle (inverted)
1829        let outside = !cube_index;
1830        let corner = outside.trailing_zeros() as u8;
1831        let edges = corner_edges(corner);
1832        triangles.push([edges[2], edges[1], edges[0]]);
1833    }
1834    // More cases would be needed for full implementation
1835
1836    triangles
1837}
1838
1839/// Get edges adjacent to a corner.
1840fn corner_edges(corner: u8) -> [u8; 3] {
1841    const CORNER_EDGES: [[u8; 3]; 8] = [
1842        [0, 3, 8],  // 0
1843        [0, 1, 9],  // 1
1844        [1, 2, 10], // 2
1845        [2, 3, 11], // 3
1846        [4, 7, 8],  // 4
1847        [4, 5, 9],  // 5
1848        [5, 6, 10], // 6
1849        [6, 7, 11], // 7
1850    ];
1851    CORNER_EDGES[corner as usize]
1852}
1853
1854// ============================================================================
1855// Tests
1856// ============================================================================
1857
1858#[cfg(test)]
1859mod tests {
1860    use super::*;
1861
1862    fn approx_eq(a: f64, b: f64) -> bool {
1863        (a - b).abs() < 1e-6
1864    }
1865
1866    #[test]
1867    fn test_cloud_point_creation() {
1868        let p = CloudPoint::from_coords(1.0, 2.0, 3.0);
1869        assert!(approx_eq(p.position.x, 1.0));
1870        assert!(approx_eq(p.position.y, 2.0));
1871        assert!(approx_eq(p.position.z, 3.0));
1872        assert!(p.normal.is_none());
1873        assert!(p.color.is_none());
1874    }
1875
1876    #[test]
1877    fn test_cloud_point_with_normal() {
1878        let p = CloudPoint::with_normal(Point3::new(1.0, 2.0, 3.0), Vector3::new(0.0, 0.0, 1.0));
1879        assert!(p.normal.is_some());
1880        let n = p.normal.unwrap();
1881        assert!(approx_eq(n.z, 1.0));
1882    }
1883
1884    #[test]
1885    fn test_point_cloud_new() {
1886        let cloud = PointCloud::new();
1887        assert!(cloud.is_empty());
1888        assert_eq!(cloud.len(), 0);
1889    }
1890
1891    #[test]
1892    fn test_point_cloud_from_positions() {
1893        let positions = vec![
1894            Point3::new(0.0, 0.0, 0.0),
1895            Point3::new(1.0, 0.0, 0.0),
1896            Point3::new(0.0, 1.0, 0.0),
1897        ];
1898        let cloud = PointCloud::from_positions(&positions);
1899        assert_eq!(cloud.len(), 3);
1900    }
1901
1902    #[test]
1903    fn test_point_cloud_bounds() {
1904        let mut cloud = PointCloud::new();
1905        cloud.push_coords(0.0, 0.0, 0.0);
1906        cloud.push_coords(10.0, 5.0, 3.0);
1907        cloud.push_coords(-2.0, 8.0, 1.0);
1908
1909        let (min, max) = cloud.bounds().unwrap();
1910        assert!(approx_eq(min.x, -2.0));
1911        assert!(approx_eq(min.y, 0.0));
1912        assert!(approx_eq(min.z, 0.0));
1913        assert!(approx_eq(max.x, 10.0));
1914        assert!(approx_eq(max.y, 8.0));
1915        assert!(approx_eq(max.z, 3.0));
1916    }
1917
1918    #[test]
1919    fn test_point_cloud_centroid() {
1920        let mut cloud = PointCloud::new();
1921        cloud.push_coords(0.0, 0.0, 0.0);
1922        cloud.push_coords(2.0, 0.0, 0.0);
1923        cloud.push_coords(1.0, 2.0, 0.0);
1924
1925        let centroid = cloud.centroid().unwrap();
1926        assert!(approx_eq(centroid.x, 1.0));
1927        assert!(approx_eq(centroid.y, 2.0 / 3.0));
1928        assert!(approx_eq(centroid.z, 0.0));
1929    }
1930
1931    #[test]
1932    fn test_point_cloud_has_normals() {
1933        let mut cloud = PointCloud::new();
1934        cloud.push(CloudPoint::from_coords(0.0, 0.0, 0.0));
1935        assert!(!cloud.has_normals());
1936
1937        let mut cloud2 = PointCloud::new();
1938        cloud2.push(CloudPoint::with_normal(
1939            Point3::new(0.0, 0.0, 0.0),
1940            Vector3::new(0.0, 0.0, 1.0),
1941        ));
1942        assert!(cloud2.has_normals());
1943    }
1944
1945    #[test]
1946    fn test_point_cloud_from_mesh() {
1947        let mut mesh = Mesh::new();
1948        mesh.vertices.push(Vertex::from_coords(0.0, 0.0, 0.0));
1949        mesh.vertices.push(Vertex::from_coords(1.0, 0.0, 0.0));
1950        mesh.vertices.push(Vertex::from_coords(0.0, 1.0, 0.0));
1951
1952        let cloud = PointCloud::from_mesh(&mesh);
1953        assert_eq!(cloud.len(), 3);
1954    }
1955
1956    #[test]
1957    fn test_point_cloud_downsample() {
1958        let mut cloud = PointCloud::new();
1959        // Create a grid of points
1960        for x in 0..10 {
1961            for y in 0..10 {
1962                for z in 0..10 {
1963                    cloud.push_coords(x as f64 * 0.1, y as f64 * 0.1, z as f64 * 0.1);
1964                }
1965            }
1966        }
1967
1968        let downsampled = cloud.downsample(0.5);
1969        assert!(downsampled.len() < cloud.len());
1970    }
1971
1972    #[test]
1973    fn test_point_cloud_translate() {
1974        let mut cloud = PointCloud::new();
1975        cloud.push_coords(0.0, 0.0, 0.0);
1976        cloud.push_coords(1.0, 1.0, 1.0);
1977
1978        cloud.translate(Vector3::new(10.0, 20.0, 30.0));
1979
1980        assert!(approx_eq(cloud.points[0].position.x, 10.0));
1981        assert!(approx_eq(cloud.points[0].position.y, 20.0));
1982        assert!(approx_eq(cloud.points[0].position.z, 30.0));
1983    }
1984
1985    #[test]
1986    fn test_point_cloud_scale() {
1987        let mut cloud = PointCloud::new();
1988        cloud.push_coords(0.0, 0.0, 0.0);
1989        cloud.push_coords(2.0, 0.0, 0.0);
1990        cloud.push_coords(0.0, 2.0, 0.0);
1991
1992        cloud.scale(2.0);
1993
1994        // Centroid is at (2/3, 2/3, 0), points should be scaled around it
1995        let (min, max) = cloud.bounds().unwrap();
1996        let extent = max - min;
1997        // Original extent was 2, should now be 4
1998        assert!(approx_eq(extent.x, 4.0));
1999        assert!(approx_eq(extent.y, 4.0));
2000    }
2001
2002    #[test]
2003    fn test_estimate_point_spacing() {
2004        let mut cloud = PointCloud::new();
2005        // Create uniformly spaced points
2006        for i in 0..10 {
2007            cloud.push_coords(i as f64, 0.0, 0.0);
2008        }
2009
2010        let spacing = estimate_point_spacing(&cloud);
2011        assert!(spacing > 0.5 && spacing < 2.0);
2012    }
2013
2014    #[test]
2015    fn test_normal_estimation() {
2016        let mut cloud = PointCloud::new();
2017        // Create a planar point cloud in the XY plane
2018        for x in 0..5 {
2019            for y in 0..5 {
2020                cloud.push_coords(x as f64, y as f64, 0.0);
2021            }
2022        }
2023
2024        let cloud_with_normals = cloud.with_estimated_normals(8).unwrap();
2025        assert!(cloud_with_normals.has_normals());
2026
2027        // All normals should be roughly (0, 0, ±1)
2028        for point in &cloud_with_normals.points {
2029            let n = point.normal.unwrap();
2030            assert!(n.z.abs() > 0.9);
2031        }
2032    }
2033
2034    #[test]
2035    fn test_orient_normals_outward() {
2036        let mut cloud = PointCloud::new();
2037        // Create a simple cube-like point cloud
2038        for x in [-1.0, 1.0] {
2039            for y in [-1.0, 1.0] {
2040                for z in [-1.0, 1.0] {
2041                    let mut p = CloudPoint::from_coords(x, y, z);
2042                    // Initially set normals pointing inward
2043                    p.normal = Some(Vector3::new(-x, -y, -z).normalize());
2044                    cloud.push(p);
2045                }
2046            }
2047        }
2048
2049        cloud.orient_normals_outward();
2050
2051        // Now all normals should point outward
2052        let centroid = cloud.centroid().unwrap();
2053        for point in &cloud.points {
2054            let to_point = point.position - centroid;
2055            let normal = point.normal.unwrap();
2056            assert!(normal.dot(&to_point) > 0.0);
2057        }
2058    }
2059
2060    #[test]
2061    fn test_reconstruction_params_defaults() {
2062        let params = ReconstructionParams::default();
2063        assert_eq!(params.algorithm, ReconstructionAlgorithm::BallPivoting);
2064        assert!(params.ball_radius.is_none());
2065        assert!(params.auto_estimate_normals);
2066    }
2067
2068    #[test]
2069    fn test_reconstruction_params_sdf() {
2070        let params = ReconstructionParams::sdf_based_with_voxel_size(1.0);
2071        assert_eq!(params.algorithm, ReconstructionAlgorithm::SdfBased);
2072        assert_eq!(params.voxel_size, Some(1.0));
2073    }
2074
2075    #[test]
2076    fn test_cloud_point_to_vertex() {
2077        let mut p = CloudPoint::from_coords(1.0, 2.0, 3.0);
2078        p.normal = Some(Vector3::new(0.0, 0.0, 1.0));
2079        p.color = Some(VertexColor::new(255, 128, 64));
2080        p.tag = Some(42);
2081
2082        let v = p.to_vertex();
2083        assert!(approx_eq(v.position.x, 1.0));
2084        assert!(v.normal.is_some());
2085        assert!(v.color.is_some());
2086        assert_eq!(v.tag, Some(42));
2087    }
2088
2089    #[test]
2090    fn test_remove_outliers() {
2091        let mut cloud = PointCloud::new();
2092        // Create a cluster of points
2093        for x in 0..5 {
2094            for y in 0..5 {
2095                cloud.push_coords(x as f64, y as f64, 0.0);
2096            }
2097        }
2098        // Add an outlier
2099        cloud.push_coords(100.0, 100.0, 100.0);
2100
2101        let filtered = cloud.remove_outliers(5, 2.0);
2102        assert!(filtered.len() < cloud.len());
2103    }
2104
2105    #[test]
2106    fn test_point_cloud_format_detection() {
2107        assert_eq!(
2108            PointCloudFormat::from_path(Path::new("test.ply")),
2109            Some(PointCloudFormat::Ply)
2110        );
2111        assert_eq!(
2112            PointCloudFormat::from_path(Path::new("test.xyz")),
2113            Some(PointCloudFormat::Xyz)
2114        );
2115        assert_eq!(
2116            PointCloudFormat::from_path(Path::new("test.pcd")),
2117            Some(PointCloudFormat::Pcd)
2118        );
2119        assert_eq!(
2120            PointCloudFormat::from_path(Path::new("test.txt")),
2121            Some(PointCloudFormat::Xyz)
2122        );
2123        assert_eq!(PointCloudFormat::from_path(Path::new("test.stl")), None);
2124    }
2125
2126    #[test]
2127    fn test_ball_pivoting_simple() {
2128        // Create a simple planar point cloud
2129        let mut cloud = PointCloud::new();
2130        for x in 0..3 {
2131            for y in 0..3 {
2132                let mut p = CloudPoint::from_coords(x as f64, y as f64, 0.0);
2133                p.normal = Some(Vector3::new(0.0, 0.0, 1.0));
2134                cloud.push(p);
2135            }
2136        }
2137
2138        let params = ReconstructionParams::ball_pivoting_with_radius(2.0);
2139        let result = cloud.to_mesh(&params);
2140
2141        // Should produce some triangles
2142        assert!(result.is_ok());
2143        let result = result.unwrap();
2144        assert!(result.vertex_count > 0);
2145    }
2146
2147    #[test]
2148    fn test_sdf_reconstruction_simple() {
2149        // Create a simple spherical point cloud
2150        // Using golden angle spiral to avoid duplicate points at poles
2151        let mut cloud = PointCloud::new();
2152        let n_points = 500;
2153        let golden_angle = std::f64::consts::PI * (3.0 - 5.0_f64.sqrt());
2154        let r = 5.0;
2155
2156        for i in 0..n_points {
2157            // Use Fibonacci sphere distribution to avoid pole clustering
2158            let y = 1.0 - (i as f64 / (n_points - 1) as f64) * 2.0; // y goes from 1 to -1
2159            let radius_at_y = (1.0 - y * y).sqrt();
2160            let theta = golden_angle * i as f64;
2161
2162            let x = (theta.cos() * radius_at_y) * r;
2163            let z = (theta.sin() * radius_at_y) * r;
2164            let y = y * r;
2165
2166            let mut p = CloudPoint::from_coords(x, y, z);
2167            let normal = Vector3::new(x, y, z);
2168            let norm = normal.norm();
2169            if norm > 1e-10 {
2170                p.normal = Some(normal / norm);
2171            }
2172            cloud.push(p);
2173        }
2174
2175        let params = ReconstructionParams::sdf_based_with_voxel_size(1.0);
2176        let result = cloud.to_mesh(&params);
2177
2178        assert!(result.is_ok());
2179    }
2180
2181    #[test]
2182    fn test_empty_cloud_to_mesh() {
2183        let cloud = PointCloud::new();
2184        let result = cloud.to_mesh(&ReconstructionParams::default());
2185        assert!(result.is_err());
2186    }
2187}